feat: update and harden steel foresight

This commit is contained in:
alireza 2026-07-16 09:07:33 +03:30
parent 03405e3f5d
commit c9e6be287e
35 changed files with 3585 additions and 670 deletions

37
PRODUCT.md Normal file
View File

@ -0,0 +1,37 @@
# Product
## Register
product
## Users
Steel-industry analysts, managers, and institute members who need fast, credible market and risk intelligence. They use the frontend as a Persian-first research portal and the dashboard as a scanning surface for world, industry, pricing, competitor, and strategic signals.
## Product Purpose
The product helps users monitor steel-sector change, understand risks and opportunities, and move from broad market signals to sharper decisions. Success means the interface feels trustworthy, dense enough for expert work, and easy to scan without decorative friction.
## Brand Personality
Analytical, authoritative, restrained. The experience should feel like a serious research desk: calm, precise, and current.
## Anti-references
Avoid generic SaaS dashboards, decorative card piles, overdone glass effects, playful motion, weak gray text, and controls that look novel but are hard to understand. Avoid making data-heavy surfaces feel like a marketing landing page.
## Design Principles
Prioritize scanning over spectacle.
Use brand color to clarify state, not decorate inactive UI.
Keep Persian RTL reading comfortable with strong hierarchy and stable spacing.
Make interaction states obvious: active, hover, focus, disabled, and reduced-motion behavior should all be intentional.
Preserve expert trust through restraint, consistency, and clean data presentation.
## Accessibility & Inclusion
Target WCAG AA contrast for text and controls. Respect reduced motion. Do not rely on color alone for active state. Preserve keyboard-accessible controls and visible focus states.

View File

@ -33,22 +33,53 @@ server {
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
# add_header directives are not inherited when a location defines one,
# so keep the security policy on cached assets too.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
try_files $uri =404;
}
# Never cache index.html (so deploys take effect immediately)
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
try_files $uri =404;
}
# Never let the SPA fallback turn hidden files such as /.env into a 200.
location ~ /\.(?!well-known(?:/|$)) {
return 404;
}
# Same-origin proxy for CMS audio/video files. Keeping Range headers is
# required for seeking and reliable HTMLMediaElement playback.
location /media/ {
proxy_pass https://cms-steelforesight.liara.run;
proxy_ssl_server_name on;
proxy_set_header Host cms-steelforesight.liara.run;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off;
}
# SPA history-fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site https://www.msc.ir https://*.basemaps.cartocdn.com https://www.google.com; media-src 'self' blob: https://cms-steelforesight.liara.run https://api.steelforesight.ir https://*.storage.iran.liara.site; connect-src 'self' https://cms-steelforesight.liara.run https://api.steelforesight.ir; frame-src https://maps.google.com; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

BIN
frontend/public/hero-bg.mp4 (Stored with Git LFS)

Binary file not shown.

View File

@ -10,7 +10,25 @@ function scrollToTop() {
/* curved top edge with a centered scroll-to-top button (matches the Figma) */
function FooterCurve() {
return (
<div style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper, #fff)' }}>
<div className="footer-curve" style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper, #fff)' }}>
<style>{`
@media (max-width: 767px) {
.footer-curve {
width: 100% !important;
height: 76px !important;
background: linear-gradient(to bottom, var(--paper, #fff) 0 48px, #032340 48px 100%) !important;
}
.footer-curve > svg { display: none !important; }
.footer-scroll-top {
top: 5px !important;
left: 50% !important;
width: 44px !important;
height: 44px !important;
border: 3px solid var(--paper, #fff) !important;
box-shadow: 0 7px 20px rgba(3,35,64,.22) !important;
}
}
`}</style>
<svg viewBox="0 0 1440 64" preserveAspectRatio="none" width="100%" height="64" style={{ display: 'block' }}>
<path
d="M0,0 L132,0 C192,0 206,52 240,52 C274,52 288,0 348,0 L1440,0 L1440,64 L0,64 Z"
@ -19,6 +37,7 @@ function FooterCurve() {
</svg>
{/* scroll-to-top button sitting in the dip (left side) */}
<button
className="footer-scroll-top"
onClick={scrollToTop}
aria-label="بازگشت به بالا"
style={{

View File

@ -25,7 +25,7 @@ import Contact from '@/pages/Contact/Contact'
import About from '@/pages/About/About'
import AuthPage from '@/pages/Auth/AuthPage'
import ProfilePage from '@/pages/Profile/ProfilePage'
import Podcast from '@/pages/Podcast/Podcast'
import Podcast from '@/pages/Podcast/PodcastFigma'
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
import SearchResults from '@/pages/Search/SearchResults'

View File

@ -0,0 +1,210 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { Pause, Play, Volume2, VolumeX } from 'lucide-react'
const NAVY = '#032340'
const GOLD = '#CD9E53'
function createWaveform(seedValue: string | number, count = 72) {
let seed = String(seedValue).split('').reduce((sum, char) => sum + char.charCodeAt(0), 17)
return Array.from({ length: count }, (_, index) => {
seed = (seed * 9301 + 49297) % 233280
const random = seed / 233280
const envelope = 0.48 + 0.27 * Math.sin(index * 0.31) + 0.15 * Math.sin(index * 0.83)
return Math.max(0.18, Math.min(0.96, envelope + random * 0.3))
})
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const minutes = Math.floor(seconds / 60)
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
}
type MediaWavePlayerProps = {
src: string
episodeKey: string | number
tone?: 'light' | 'dark'
compact?: boolean
}
export default function MediaWavePlayer({ src, episodeKey, tone = 'dark', compact = false }: MediaWavePlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(0)
const [failed, setFailed] = useState(false)
const bars = useMemo(() => createWaveform(episodeKey), [episodeKey])
const progress = duration > 0 ? Math.min(current / duration, 1) : 0
const dark = tone === 'dark'
const ink = dark ? '#fff' : NAVY
const mutedInk = dark ? 'rgba(255,255,255,0.52)' : 'rgba(3,35,64,0.56)'
const idleBar = dark ? 'rgba(255,255,255,0.18)' : 'rgba(3,35,64,0.16)'
useEffect(() => {
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.currentTime = 0
audio.muted = false
setPlaying(false)
setMuted(false)
setCurrent(0)
setDuration(0)
setFailed(false)
audio.load()
}, [src, episodeKey])
const togglePlayback = async () => {
const audio = audioRef.current
if (!audio || failed) return
if (!audio.paused) {
audio.pause()
return
}
try {
await audio.play()
} catch {
setFailed(true)
}
}
const seekToRatio = (ratio: number) => {
const audio = audioRef.current
if (!audio || !duration) return
audio.currentTime = Math.max(0, Math.min(1, ratio)) * duration
setCurrent(audio.currentTime)
}
const seekFromPointer = (event: MouseEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
seekToRatio((event.clientX - rect.left) / rect.width)
}
const seekFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
const audio = audioRef.current
if (!audio || !duration || !['ArrowRight', 'ArrowLeft'].includes(event.key)) return
event.preventDefault()
const delta = event.key === 'ArrowRight' ? 5 : -5
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + delta))
setCurrent(audio.currentTime)
}
const toggleMute = () => {
const audio = audioRef.current
if (!audio) return
audio.muted = !audio.muted
setMuted(audio.muted)
}
return (
<div style={{
width: '100%', minWidth: 0, display: 'flex', gap: compact ? 12 : 10,
flexDirection: compact ? 'row-reverse' : 'column',
alignItems: compact ? 'center' : undefined,
direction: compact ? 'ltr' : undefined,
}}>
<audio
ref={audioRef}
src={src}
preload="metadata"
onLoadedMetadata={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
onDurationChange={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => { setPlaying(false); setCurrent(0) }}
onError={() => setFailed(true)}
style={{ display: 'none' }}
/>
<div
role="slider"
aria-label="موقعیت پخش پادکست"
aria-valuemin={0}
aria-valuemax={Math.max(0, Math.round(duration))}
aria-valuenow={Math.round(current)}
tabIndex={0}
onClick={seekFromPointer}
onKeyDown={seekFromKeyboard}
style={{
height: compact ? 58 : 48,
flex: compact ? 1 : undefined,
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 2,
direction: 'ltr',
cursor: duration ? 'pointer' : 'default',
overflow: 'hidden',
outline: 'none',
}}
>
{bars.map((height, index) => {
const played = (index + 0.5) / bars.length <= progress
return (
<span key={index} style={{
flex: '1 1 0',
minWidth: 1,
maxWidth: compact ? 'none' : 6,
height: `${Math.round(height * (compact ? 52 : 42))}px`,
borderRadius: 999,
background: played ? GOLD : idleBar,
transition: 'background-color 140ms ease',
}} />
)
})}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, direction: 'ltr', flexShrink: 0 }}>
<button
type="button"
onClick={togglePlayback}
disabled={failed}
aria-label={playing ? 'توقف پادکست' : 'پخش پادکست'}
style={{
width: 42,
height: 42,
borderRadius: '50%',
border: 0,
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: failed ? idleBar : GOLD,
color: dark ? NAVY : '#fff',
cursor: failed ? 'not-allowed' : 'pointer',
}}
>
{playing
? <Pause size={17} fill="currentColor" />
: <Play size={17} fill="currentColor" style={{ marginLeft: 2 }} />}
</button>
{!compact && (
<>
<span style={{ minWidth: 36, color: ink, fontSize: 11, fontWeight: 800 }}>{formatTime(current)}</span>
<div style={{ flex: 1, height: 3, borderRadius: 999, background: idleBar, overflow: 'hidden' }}>
<div style={{ width: `${progress * 100}%`, height: '100%', background: GOLD, transition: 'width 120ms linear' }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', color: mutedInk, fontSize: 11, fontWeight: 700 }}>{formatTime(duration)}</span>
<button
type="button"
onClick={toggleMute}
aria-label={muted ? 'فعال کردن صدا' : 'قطع صدا'}
style={{ border: 0, background: 'transparent', padding: 5, color: mutedInk, cursor: 'pointer', display: 'flex' }}
>
{muted ? <VolumeX size={17} /> : <Volume2 size={17} />}
</button>
</>
)}
</div>
{failed && (
<div style={{ color: dark ? '#fca5a5' : '#b42318', fontSize: 11, textAlign: 'right' }}>
فایل صوتی قابل پخش نیست. آدرس فایل یا فرمت آن را بررسی کنید.
</div>
)}
</div>
)
}

View File

@ -12,7 +12,17 @@ const STRENGTH_FA: Record<string, string> = { weak: 'ضعیف', moderate: 'مت
type Strength = 'none' | 'weak' | 'moderate' | 'strong'
const Strength = { None: 'none' as const, Weak: 'weak' as const, Moderate: 'moderate' as const, Strong: 'strong' as const }
interface FinancialScoreProps { title: string; description: string; initialScore?: number }
interface FinancialScoreDetailSource { label: string; url: string; note: string }
interface FinancialScoreProps {
title: string;
description: string;
initialScore?: number;
meaning?: string;
basis?: string[];
implication?: string;
actions?: string[];
sources?: FinancialScoreDetailSource[];
}
interface FinancialScoreButtonProps { children?: React.ReactNode; isOutlined?: boolean; onClick?: () => void }
interface FinancialScoreCardProps { children?: React.ReactNode }
interface FinancialScoreDisplayProps { value: Score; max: number }
@ -176,11 +186,13 @@ function FinancialScoreHeader({ title, strength }: FinancialScoreHeaderProps) {
)
}
function FinancialScore({ title, description, initialScore }: FinancialScoreProps) {
function FinancialScore({ title, description, initialScore, meaning, basis, implication, actions, sources }: FinancialScoreProps) {
const [score, setScore] = useState<Score>(initialScore ?? null)
const [showDetails, setShowDetails] = useState(false)
const hasScore = score !== null
const max = 100
const strength = Utils.getStrength(score, max)
const hasDetails = Boolean(meaning || basis?.length || implication || actions?.length || sources?.length)
return (
<FinancialScoreCard>
@ -190,9 +202,70 @@ function FinancialScore({ title, description, initialScore }: FinancialScoreProp
<FinancialScoreDisplay value={score} max={max} />
</div>
<p className="text-muted-foreground text-center text-xs leading-relaxed mb-5 min-h-[3.5rem]">{description}</p>
<FinancialScoreButton isOutlined={hasScore} onClick={() => !hasScore && setScore(Utils.randomInt(0, max))}>
{hasScore ? 'جزئیات بیشتر' : 'محاسبه امتیاز'}
<FinancialScoreButton
isOutlined={hasScore}
onClick={() => {
if (!hasScore) setScore(Utils.randomInt(0, max))
if (hasDetails) setShowDetails((value) => !value)
}}
>
{hasScore ? (showDetails ? 'بستن جزئیات' : 'جزئیات بیشتر') : 'محاسبه امتیاز'}
</FinancialScoreButton>
{showDetails && hasDetails && (
<div className="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4 text-right animate-in fade-in slide-in-from-bottom-2 duration-300">
{meaning && (
<div className="mb-4">
<div className="mb-1 text-[11px] font-black" style={{ color: '#032340' }}>این شاخص چه چیزی را نشان میدهد؟</div>
<p className="text-[11px] leading-7 text-slate-700">{meaning}</p>
</div>
)}
{basis && basis.length > 0 && (
<div className="mb-4">
<div className="mb-2 text-[11px] font-black" style={{ color: '#032340' }}>دادههای مبنا</div>
<ul className="space-y-2 pr-4 text-[11px] leading-7 text-slate-700">
{basis.map((item) => <li key={item} className="list-disc">{item}</li>)}
</ul>
</div>
)}
{implication && (
<div className="mb-4 rounded-lg border border-blue-100 bg-white p-3">
<div className="mb-1 text-[11px] font-black text-blue-800">برداشت برای فولاد مبارکه</div>
<p className="text-[11px] leading-7 text-slate-800">{implication}</p>
</div>
)}
{actions && actions.length > 0 && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="mb-2 text-[11px] font-black text-amber-800">نیاز به اقدام</div>
<ul className="space-y-2 pr-4 text-[11px] leading-7 text-slate-800">
{actions.map((item) => <li key={item} className="list-disc">{item}</li>)}
</ul>
</div>
)}
{sources && sources.length > 0 && (
<div>
<div className="mb-2 text-[11px] font-black" style={{ color: '#032340' }}>منابع و گزارشها</div>
<div className="grid gap-2">
{sources.map((source) => (
<a
key={source.url}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-slate-200 bg-white px-3 py-2 no-underline transition-colors hover:border-blue-300"
>
<div className="text-[11px] font-bold leading-6 text-blue-800">{source.label}</div>
<div className="text-[10px] leading-6 text-slate-500">{source.note}</div>
</a>
))}
</div>
</div>
)}
</div>
)}
</FinancialScoreCard>
)
}

View File

@ -20,10 +20,12 @@ interface WordProps {
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: 'relative', display: 'inline-block' }}>
<span style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>{children}</span>
<motion.span style={{ opacity }}>{children}</motion.span>
</span>
</span>{' '}
</>
)
}
@ -45,7 +47,7 @@ export const MagicText: React.FC<MagicTextProps> = ({ text, dir = 'rtl', style,
ref={container}
dir={dir}
className={className}
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
style={{ display: 'block', width: '100%', textAlign: 'justify', ...style }}
>
{words.map((word, i) => {
const start = i / words.length
@ -92,7 +94,7 @@ export const MagicTextGroup: React.FC<MagicTextGroupProps> = ({ paragraphs, dir
key={pi}
dir={dir}
className={className}
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
style={{ display: 'block', width: '100%', textAlign: 'justify', ...style }}
>
{words.map((word, wi) => {
const start = globalIndex / totalWords

View File

@ -9,6 +9,8 @@ export type RFBlock = { type: 'text' | 'image'; value?: string; url?: string; ca
export type RadarFutureArticle = {
id: string
category: string // one of the 5 sub-menu labels
featured: boolean
createdAt: string
img: string
tags: string[]
sources: { title: string; url: string }[]
@ -37,7 +39,9 @@ export const RADAR_SLUG_LABEL: Record<string, string> = {
technology: 'فناوری و نوآوری',
}
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
const PANEL_API = import.meta.env.DEV
? ''
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
export async function bootstrapRadarFuture(): Promise<void> {
try {
@ -55,6 +59,8 @@ export async function bootstrapRadarFuture(): Promise<void> {
return {
id: String(a.id),
category: String(a.category ?? ''),
featured: Boolean(a.featured),
createdAt: String(a.createdAt ?? ''),
img: cover,
tags: Array.isArray(a.tags) ? (a.tags as string[]) : [],
sources: Array.isArray(a.sources) ? (a.sources as { title: string; url: string }[]) : [],

View File

@ -4,6 +4,7 @@ export interface Event {
id: string;
title: string;
date: number;
dateEnd?: number;
month: string;
year: number;
type: EventType;
@ -14,6 +15,45 @@ export interface Event {
}
export const events: Event[] = [
{
id: 'smu-steel-summit-2026',
title: 'اجلاس فولاد SMU ۲۰۲۶',
date: 2,
dateEnd: 4,
month: 'شهریور',
year: 1405,
type: 'conference',
location: 'مرکز همایش‌های بین‌المللی جورجیا، آتلانتا، ایالات متحده',
city: 'آتلانتا',
registrationOpen: true,
description: 'گردهمایی مدیران بازار فولاد آمریکای شمالی با تمرکز بر قیمت، عرضه، تقاضا، تجارت و فناوری‌های مؤثر بر زنجیره فولاد.',
},
{
id: 'china-international-steel-congress-2026',
title: 'پانزدهمین کنگره بین‌المللی فولاد چین',
date: 28,
dateEnd: 29,
month: 'شهریور',
year: 1405,
type: 'conference',
location: 'شانگهای، چین',
city: 'شانگهای',
registrationOpen: true,
description: 'کنگره صنعت فولاد چین با محورهای ارتقای کیفیت، تحول دیجیتال، هوشمندسازی، توسعه کم‌کربن و همکاری‌های بین‌المللی.',
},
{
id: 'future-green-steel-2026',
title: 'اجلاس آینده فولاد سبز ۲۰۲۶',
date: 6,
dateEnd: 7,
month: 'آبان',
year: 1405,
type: 'conference',
location: 'لندن، بریتانیا',
city: 'لندن',
registrationOpen: true,
description: 'اجلاس مدیران صنعت فولاد، انرژی و سرمایه‌گذاری برای بررسی تولید فولاد کم‌کربن، هیدروژن، استانداردها و تأمین مالی پروژه‌ها.',
},
{
id: 'iran-steel-congress-1403',
title: 'کنگره بین‌المللی فولاد ایران ۱۴۰۳',
@ -96,6 +136,31 @@ export const events: Event[] = [
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩'
const normalizeDigits = (value: unknown) => String(value ?? '').replace(/[۰-۹٠-٩]/g, (digit) => {
const faIndex = FA_DIGITS.indexOf(digit)
if (faIndex >= 0) return String(faIndex)
const arIndex = AR_DIGITS.indexOf(digit)
return arIndex >= 0 ? String(arIndex) : digit
})
const parseEventRange = (value: unknown): [number, number | undefined] => {
const candidates = normalizeDigits(value)
.match(/\d+/g)
?.map(Number)
.filter((number) => number >= 1 && number <= 31)
const start = candidates?.[0] ?? 1
const end = candidates && candidates.length > 1 ? candidates.at(-1) : undefined
return [start, end && end >= start ? end : undefined]
}
const parseEventYear = (value: unknown) => {
const year = Number(normalizeDigits(value).match(/\d{4}/)?.[0])
return Number.isFinite(year) ? year : 1405
}
export async function bootstrapEvents(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/events?limit=200`)
@ -103,11 +168,14 @@ export async function bootstrapEvents(): Promise<void> {
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,
events.push(...fetched.map((e: Record<string, unknown>) => {
const [date, dateEnd] = parseEventRange(e.dateFa)
return {
id: String(e.id), title: String(e.titleFa ?? ''), date, dateEnd,
month: String(e.monthFa ?? ''), year: parseEventYear(e.year), type: e.type as EventType,
location: String(e.locationFa ?? ''), city: String(e.cityFa ?? ''),
registrationOpen: Boolean(e.registrationOpen), description: String(e.descriptionFa ?? ''),
})))
}
}))
} catch { /* panel offline → keep static fallback */ }
}

View File

@ -20,6 +20,7 @@ export interface VideoItem {
date: string;
thumb: string;
mediaUrl?: string;
tags?: string[];
}
export interface PodcastItem {
@ -32,6 +33,7 @@ export interface PodcastItem {
fa: MediaLangText & { ep: string };
en: MediaLangText & { ep: string };
mediaUrl?: string;
tags?: string[];
}
export const videos: VideoItem[] = [
@ -133,6 +135,7 @@ export const podcasts: PodcastItem[] = [
body: 'کاربردهای عملی یادگیری ماشین در بهینه‌سازی فرآیند ذوب، کنترل کیفیت و نگهداری پیش‌گیرانه.' },
en: { title: 'AI on the Steel Production Line', ep: 'Episode 72', guest: 'Dr. Arash Karimi',
body: 'Practical applications of machine learning in melt optimization, quality control and predictive maintenance.' },
mediaUrl: '/media/8ldX5dfRQsE0.mp3',
},
]
@ -151,7 +154,8 @@ function articleToVideo(a: Record<string, unknown>, i: number): VideoItem {
duration: '',
date: String(a.publishDate ?? ''),
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
mediaUrl: proxiedMediaUrl(a.body), // panel stores the media URL in the article body field
tags: Array.isArray(a.tags) ? a.tags.map(String).filter(Boolean) : [],
}
}
@ -159,20 +163,36 @@ 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}`
const episodeNumber = String(a.episodeNumber ?? '').trim() || String(i + 1)
const cleanEpisodeNumber = episodeNumber.replace(/^(اپیزود|قسمت|episode|ep\.?)[\s:#-]*/i, '').trim() || String(i + 1)
const ep = `قسمت ${cleanEpisodeNumber}`
return {
id: String(a.id ?? i),
epNum: `اپیزود ${i + 1}`,
epNum: `اپیزود ${cleanEpisodeNumber}`,
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 },
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
mediaUrl: proxiedMediaUrl(a.body), // panel stores the media URL in the article body field
tags: Array.isArray(a.tags) ? a.tags.map(String).filter(Boolean) : [],
}
}
function proxiedMediaUrl(value: unknown) {
const raw = String(value ?? '').trim()
if (!raw) return ''
try {
const url = new URL(raw)
const mediaHosts = new Set(['cms-steelforesight.liara.run', 'api.steelforesight.ir'])
if (mediaHosts.has(url.hostname) && url.pathname.startsWith('/media/')) {
return `${url.pathname}${url.search}`
}
} catch { /* keep non-URL and relative values unchanged */ }
return raw
}
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
export async function bootstrapMedia(): Promise<void> {

View File

@ -465,3 +465,238 @@ body {
PRINT
*/
@media print { .no-print { display: none !important; } }
.steel-switcher {
--steel-glass: #7f909c;
--steel-ink: #eef6ff;
--steel-muted: rgba(238, 246, 255, 0.68);
position: fixed;
z-index: 100;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 64px;
min-height: 132px;
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
padding: 9px;
border: 0;
border-radius: 26px;
isolation: isolate;
overflow: hidden;
background:
radial-gradient(circle at 28% 12%, rgba(255,255,255,0.44), transparent 28%),
linear-gradient(150deg, rgba(255,255,255,0.24), rgba(126,145,158,0.58) 38%, rgba(70,86,99,0.68));
color: var(--steel-muted);
backdrop-filter: blur(18px) saturate(165%) contrast(1.08);
-webkit-backdrop-filter: blur(18px) saturate(165%) contrast(1.08);
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.30),
inset 5px 7px 10px -8px rgba(255,255,255,0.92),
inset -7px -12px 18px -14px rgba(3,35,64,0.78),
inset 0 -1px 8px rgba(255,255,255,0.16),
0 18px 45px rgba(3,35,64,0.26),
0 4px 14px rgba(3,35,64,0.12);
}
.steel-switcher::before {
content: '';
position: absolute;
inset: 1px;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background:
linear-gradient(145deg, rgba(255,255,255,0.42), rgba(255,255,255,0.07) 24%, transparent 48%),
radial-gradient(circle at 18% 16%, rgba(255,255,255,0.72), transparent 16%),
radial-gradient(circle at 85% 84%, rgba(3,35,64,0.22), transparent 32%);
opacity: 0.82;
}
.steel-switcher__legend {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
border: 0;
padding: 0;
white-space: nowrap;
clip-path: inset(100%);
clip: rect(0 0 0 0);
overflow: hidden;
}
.steel-switcher__input {
position: absolute;
width: 1px;
height: 1px;
clip: rect(0 0 0 0);
clip-path: inset(100%);
overflow: hidden;
white-space: nowrap;
}
.steel-switcher__option {
position: relative;
z-index: 1;
width: 46px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
color: var(--steel-muted);
cursor: pointer;
transition: color 180ms ease, transform 180ms ease, background-color 180ms ease;
background: rgba(255,255,255,0.08);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.18),
inset 0 -1px 4px rgba(3,35,64,0.10);
}
.steel-switcher__icon {
display: block;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.steel-switcher__option:hover {
color: var(--steel-ink);
background: rgba(255,255,255,0.14);
}
.steel-switcher__option:hover .steel-switcher__icon {
transform: scale(1.12);
}
.steel-switcher__option:has(input:checked) {
color: #fff;
cursor: default;
background: transparent;
}
.steel-switcher__option:has(input:focus-visible) {
outline: 2px solid rgba(255,255,255,0.9);
outline-offset: 3px;
}
.steel-switcher::after {
content: '';
position: absolute;
left: 8px;
top: 9px;
width: 46px;
height: 52px;
border-radius: 15px;
background:
radial-gradient(circle at 32% 18%, rgba(255,255,255,0.38), transparent 28%),
linear-gradient(180deg, color-mix(in srgb, var(--steel-active, #1a6bcf) 92%, #ffffff 8%), color-mix(in srgb, var(--steel-active, #1a6bcf) 86%, #032340 14%));
z-index: 0;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.24),
inset 3px 3px 4px -3px rgba(255,255,255,0.85),
inset -3px -8px 8px -8px rgba(3,35,64,0.8),
0 8px 18px color-mix(in srgb, var(--steel-active, #1a6bcf) 44%, transparent),
0 2px 4px rgba(3,35,64,0.14);
transition:
translate 420ms cubic-bezier(0.19, 1, 0.22, 1),
background 260ms ease,
box-shadow 260ms ease;
}
.steel-switcher:has(input[c-option="1"]:checked)::after {
translate: 0 0;
transform-origin: bottom;
animation: steelToggle 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.steel-switcher:has(input[c-option="2"]:checked)::after {
translate: 0 62px;
transform-origin: top;
animation: steelToggle 420ms cubic-bezier(0.19, 1, 0.22, 1);
}
.steel-switcher[c-previous="1"]:has(input[c-option="2"]:checked)::after {
transform-origin: top;
}
.steel-switcher[c-previous="2"]:has(input[c-option="1"]:checked)::after {
transform-origin: bottom;
}
@keyframes steelToggle {
0% { scale: 1 1; }
45% { scale: 0.96 1.22; }
100% { scale: 1 1; }
}
@media (prefers-reduced-motion: reduce) {
.steel-switcher,
.steel-switcher::after,
.steel-switcher__option,
.steel-switcher__icon {
animation: none !important;
transition: none !important;
}
}
@media (max-width: 767px) {
.steel-dashboard-page {
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.steel-switcher {
right: 50%;
top: auto;
bottom: max(14px, env(safe-area-inset-bottom));
transform: translateX(50%);
width: 132px;
min-height: 64px;
height: 64px;
flex-direction: row;
gap: 10px;
padding: 9px;
border-radius: 24px;
}
.steel-switcher__option {
width: 52px;
height: 46px;
border-radius: 14px;
}
.steel-switcher::after {
left: 9px;
top: 9px;
width: 52px;
height: 46px;
border-radius: 13px;
}
.steel-switcher:has(input[c-option="1"]:checked)::after {
translate: 0 0;
transform-origin: right;
animation: steelToggleHorizontal 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.steel-switcher:has(input[c-option="2"]:checked)::after {
translate: 62px 0;
transform-origin: left;
animation: steelToggleHorizontal 420ms cubic-bezier(0.19, 1, 0.22, 1);
}
.steel-switcher[c-previous="1"]:has(input[c-option="2"]:checked)::after {
transform-origin: left;
}
.steel-switcher[c-previous="2"]:has(input[c-option="1"]:checked)::after {
transform-origin: right;
}
}
@keyframes steelToggleHorizontal {
0% { scale: 1 1; }
45% { scale: 1.18 0.96; }
100% { scale: 1 1; }
}

View File

@ -20,11 +20,33 @@ import { bootstrapMedia } from './data/media'
import { bootstrapPosts } from './content/posts'
import { bootstrapRadarFuture } from './content/radarFuture'
await Promise.all([
bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(),
bootstrapPlans(), bootstrapRisks(), bootstrapMarket(),
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), bootstrapRadarFuture(),
])
const BOOTSTRAP_TIMEOUT_MS = 3500
const bootstraps = [
bootstrapReports,
bootstrapEvents,
bootstrapTeam,
bootstrapRadar,
bootstrapRadarPages,
bootstrapBanners,
bootstrapPlans,
bootstrapRisks,
bootstrapMarket,
bootstrapFactoryReports,
bootstrapIntegrations,
bootstrapAbout,
bootstrapMedia,
bootstrapPosts,
bootstrapRadarFuture,
]
await Promise.all(
bootstraps.map((bootstrap) =>
Promise.race([
bootstrap().catch(() => {}),
new Promise<void>((resolve) => window.setTimeout(resolve, BOOTSTRAP_TIMEOUT_MS)),
]),
),
)
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
if ('serviceWorker' in navigator && import.meta.env.PROD) {

View File

@ -2,6 +2,7 @@ import { useEffect, useState, type CSSProperties } from 'react'
import { useLocation } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
import { Telescope, Lightbulb, BarChart3, Leaf, Quote, Mail, Target, Link2, Gavel, ListChecks, Compass, ShieldCheck, type LucideIcon } from 'lucide-react'
import RadarTechSection from '@/pages/Home/sections/RadarTechSection'
const NAVY = '#032340'
const GOLD = '#CD9E53'
@ -281,6 +282,9 @@ export default function About() {
</div>
</section>
{/* ── THINKING FRAMEWORK ── */}
<RadarTechSection only="approach" />
{/* ── POLICY COUNCIL ── (hidden for now: whole section blurred + non-interactive) */}
<section className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 56, filter: 'blur(18px)', userSelect: 'none', pointerEvents: 'none' }} aria-hidden="true">
<SectionLabel text={t.councilTitle} />

View File

@ -3,6 +3,8 @@ import { AnimatePresence, motion } from 'framer-motion'
import { X, Globe, Calendar, LayoutGrid, Newspaper, ChevronDown } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
import { sortByPublishedDesc } from '@/utils/dateSort'
import { resolveSourceUrl } from '@/utils/sourceLinks'
const NAVY = '#032340'
const GOLD = '#CD9E53'
@ -19,7 +21,9 @@ type Card = {
analysisFa: string; analysisEn: string
tags: string[]
source: string
sourceUrl?: string
dateFa: string; dateEn: string
createdAt?: string; updatedAt?: string
}
/* ─── data ─── */
@ -287,7 +291,7 @@ function CardModal({ card, lang, onClose, onTagClick }: { card: Card; lang: 'fa'
<LayoutGrid size={16} color="#fff" strokeWidth={1.6} />
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<span style={metaItem}><span>{card.source}</span><Globe size={18} color={GOLD} strokeWidth={1.6} /></span>
{card.source && <SourceLink source={card.source} sourceUrl={card.sourceUrl} style={metaItem} iconSize={18} iconFirst={false} />}
<span style={metaItem}><span>{isFa ? card.dateFa : card.dateEn}</span><Calendar size={18} color={GOLD} strokeWidth={1.6} /></span>
</div>
</div>
@ -331,16 +335,53 @@ function CardModal({ card, lang, onClose, onTagClick }: { card: Card; lang: 'fa'
}
/* ─── source + date meta row (Figma: «Bloomberg» globe + date calendar, #606060 / 12px) ─── */
function Meta({ source, date, light }: { source?: string; date: string; light?: boolean }) {
function Meta({ source, sourceUrl, date, light }: { source?: string; sourceUrl?: string; date: string; light?: boolean }) {
const item = { fontSize: 12, color: light ? 'rgba(255,255,255,0.85)' : '#606060', fontWeight: 400, display: 'inline-flex', alignItems: 'center', gap: 5 } as const
return (
<div style={{ display: 'flex', gap: 18, direction: 'rtl', alignItems: 'center', flexWrap: 'wrap' }}>
{source && <span style={item}><Globe size={13} color={GOLD} />{source}</span>}
{source && <SourceLink source={source} sourceUrl={sourceUrl} style={item} iconSize={13} />}
<span style={item}><Calendar size={13} color={GOLD} />{date}</span>
</div>
)
}
function SourceLink({
source,
sourceUrl,
style,
iconSize = 13,
iconFirst = true,
}: {
source: string
sourceUrl?: string
style: CSSProperties
iconSize?: number
iconFirst?: boolean
}) {
const href = resolveSourceUrl(source, sourceUrl)
const content = (
<>
{iconFirst && <Globe size={iconSize} color={GOLD} />}
<span>{source}</span>
{!iconFirst && <Globe size={iconSize} color={GOLD} strokeWidth={1.6} />}
</>
)
if (!href) return <span style={style}>{content}</span>
return (
<a
href={href}
target={href.startsWith('http') ? '_blank' : undefined}
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
style={{ ...style, textDecoration: 'underline', textUnderlineOffset: 3, cursor: 'pointer' }}
onClick={(e) => e.stopPropagation()}
>
{content}
</a>
)
}
/* ─── category badge — Figma «Link» pill: gold #CD9E53, white text 14/500, element icon, r8 ─── */
function Badge({ label }: { label: string }) {
return (
@ -383,7 +424,9 @@ export default function AtAGlance() {
// load «در یک نگاه» cards from the panel (radar_items); fall back to the static list
useEffect(() => {
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
const PANEL_API = import.meta.env.DEV
? ''
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
let alive = true
fetch(`${PANEL_API}/api/radar?limit=100`)
.then(r => (r.ok ? r.json() : null))
@ -400,16 +443,18 @@ export default function AtAGlance() {
analysisFa: String(r.analysisFa ?? ''), analysisEn: String(r.analysisEn || r.analysisFa || ''),
tags: Array.isArray(r.tags) ? (r.tags as string[]) : [],
source: String(r.source ?? ''),
sourceUrl: resolveSourceUrl(String(r.source ?? ''), String(r.sourceUrl ?? r.source_url ?? r.url ?? r.link ?? '')),
dateFa: String(r.dateFa ?? ''), dateEn: String(r.dateEn || r.dateFa || ''),
createdAt: String(r.createdAt ?? ''), updatedAt: String(r.updatedAt ?? ''),
})))
})
.catch(() => {})
return () => { alive = false }
}, [])
const filteredCards = tagFilter
const filteredCards = sortByPublishedDesc(tagFilter
? cards.filter(c => c.tags.includes(tagFilter))
: activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab)
: activeTab === 'all' ? cards : cards.filter(c => c.tab === activeTab))
const hero = filteredCards[0]
const smallItems = filteredCards.slice(1, 4)
const listCards = filteredCards.slice(1)
@ -473,7 +518,7 @@ export default function AtAGlance() {
<Badge label={catLabel(hero.tab, isFa)} />
<h2 style={{ fontSize: 'clamp(18px,2vw,26px)', fontWeight: 700, color: '#fff', lineHeight: 1.6, margin: '14px 0 10px' }}>{isFa ? hero.titleFa : hero.titleEn}</h2>
<p style={{ fontSize: 13.5, color: 'rgba(255,255,255,0.82)', lineHeight: 1.9, margin: 0, maxWidth: 580, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? hero.bodyFa : hero.bodyEn)[0] || ''}</p>
<div style={{ marginTop: 14 }}><Meta source={hero.source} date={isFa ? hero.dateFa : hero.dateEn} light /></div>
<div style={{ marginTop: 14 }}><Meta source={hero.source} sourceUrl={hero.sourceUrl} date={isFa ? hero.dateFa : hero.dateEn} light /></div>
</div>
</div>
)}
@ -483,7 +528,7 @@ export default function AtAGlance() {
<img src={card.img} alt="" style={{ width: 96, height: 96, objectFit: 'cover', flexShrink: 0, borderRadius: 12 }} />
<div style={{ textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div><Badge label={catLabel(card.tab, isFa)} /></div>
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
<Meta source={card.source} sourceUrl={card.sourceUrl} date={isFa ? card.dateFa : card.dateEn} />
<div style={{ fontSize: 14, fontWeight: 700, color: '#262626', lineHeight: 1.7, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{isFa ? card.titleFa : card.titleEn}</div>
<TagRow tags={card.tags} onTagClick={handleTagClick} />
</div>
@ -520,7 +565,7 @@ export default function AtAGlance() {
<div style={{ padding: '8px 12px', textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
<Badge label={catLabel(card.tab, isFa)} />
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
<Meta source={card.source} sourceUrl={card.sourceUrl} date={isFa ? card.dateFa : card.dateEn} />
</div>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { events } from '@/data/events'
import { bootstrapEvents, events } from '@/data/events'
const NAVY = '#032340'
const GOLD = '#CD9E53'
@ -15,6 +15,9 @@ const TYPE_LABEL: Record<string, { fa: string; en: string; color: string; bg: st
}
const EVENT_IMGS: Record<string, string> = {
'smu-steel-summit-2026': '/news/photo-1566873535350-6586b0b7a1f2.jpg',
'china-international-steel-congress-2026': '/news/photo-1504307651254-35680f356dfd.jpg',
'future-green-steel-2026': '/news/photo-1611273426858-450d8e3c9fce.jpg',
'iran-steel-congress-1403': '/news/photo-1611273426858-450d8e3c9fce.jpg',
'middle-east-steel-metals-2024': '/news/photo-1570168007204-dfb528c6958f.jpg',
'steel-policy-seminar-1404': '/news/photo-1567789884554-0b844b597180.jpg',
@ -24,10 +27,9 @@ const EVENT_IMGS: Record<string, string> = {
}
/* ── Countdown ─────────────────────────────────────────── */
const HERO_TARGET = new Date('2026-10-16T09:00:00')
function useCountdown(target: Date) {
function useCountdown(targetTime: number) {
const calc = () => {
const diff = Math.max(0, target.getTime() - Date.now())
const diff = Math.max(0, targetTime - Date.now())
return {
days: Math.floor(diff / 86400000),
hours: Math.floor((diff % 86400000) / 3600000),
@ -36,7 +38,11 @@ function useCountdown(target: Date) {
}
}
const [t, setT] = useState(calc)
useEffect(() => { const id = setInterval(() => setT(calc()), 1000); return () => clearInterval(id) }, [])
useEffect(() => {
setT(calc())
const id = setInterval(() => setT(calc()), 1000)
return () => clearInterval(id)
}, [targetTime])
return t
}
@ -45,7 +51,81 @@ const WEEKDAYS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه',
const J_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']
const faNum = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false })
type CalendarEventDate = { day: number; month: number; year: number }
function jalaliMonthNumber(value: string) {
const normalized = String(value ?? '')
.replace(/ماه/g, '')
.replace(/[\s\u200c]/g, '')
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
return J_MONTHS.findIndex(month => month === normalized) + 1
}
function calendarDateKey(date: CalendarEventDate) {
return date.year * 10000 + date.month * 100 + date.day
}
function gregorianDateForJalali(target: CalendarEventDate) {
const cursor = new Date(target.year + 621, 2, 15, 12)
for (let index = 0; index < 380; index += 1) {
const current = jParts(cursor)
if (current.day === target.day && current.month === target.month && current.year === target.year) {
return new Date(cursor)
}
cursor.setDate(cursor.getDate() + 1)
}
return null
}
function initialCalendarAnchor(sourceEvents = events) {
const today = new Date()
const todayJ = jParts(today)
const todayKey = calendarDateKey(todayJ)
const nearestHomeEvent = sourceEvents
.slice(0, 3)
.map(event => ({
day: Number(event.date),
month: jalaliMonthNumber(event.month),
year: Number(event.year),
}))
.filter(date => date.day >= 1 && date.month >= 1 && date.year > 0 && calendarDateKey(date) >= todayKey)
.sort((a, b) => calendarDateKey(a) - calendarDateKey(b))[0]
return nearestHomeEvent ? gregorianDateForJalali(nearestHomeEvent) ?? today : today
}
function nearestUpcomingEvent(sourceEvents: typeof events) {
const todayJ = jParts(new Date())
const todayKey = calendarDateKey(todayJ)
return [...sourceEvents]
.filter(event => {
const date = { day: Number(event.date), month: jalaliMonthNumber(event.month), year: Number(event.year) }
return date.day >= 1 && date.month >= 1 && date.year > 0 && calendarDateKey(date) >= todayKey
})
.sort((a, b) => {
const aKey = calendarDateKey({ day: Number(a.date), month: jalaliMonthNumber(a.month), year: Number(a.year) })
const bKey = calendarDateKey({ day: Number(b.date), month: jalaliMonthNumber(b.month), year: Number(b.year) })
return aKey - bKey
})[0]
}
function eventStartTimestamp(event: (typeof events)[number] | undefined) {
if (!event) return Date.now()
const date = gregorianDateForJalali({
day: Number(event.date),
month: jalaliMonthNumber(event.month),
year: Number(event.year),
})
if (!date) return Date.now()
date.setHours(9, 0, 0, 0)
return date.getTime()
}
function jParts(d: Date) {
if (!(d instanceof Date) || !Number.isFinite(d.getTime())) {
return { day: 0, month: 0, year: 0 }
}
const p = new Intl.DateTimeFormat('en-US-u-ca-persian', { day:'numeric', month:'numeric', year:'numeric' }).formatToParts(d)
const get = (t: string) => Number(p.find(x => x.type === t)?.value)
return { day: get('day'), month: get('month'), year: get('year') }
@ -67,7 +147,7 @@ function buildMonth(anchor: Date) {
function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
anchor: Date; onNav: (d: 1|-1) => void
eventDates: Date[]; onSelect: (d: Date) => void
eventDates: CalendarEventDate[]; onSelect: (d: Date) => void
}) {
const { days, firstDow, month, year } = buildMonth(anchor)
const today = new Date(); const todayJ = jParts(today)
@ -79,23 +159,23 @@ function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
padding: 12, height: '100%', boxSizing: 'border-box',
}}>
{/* تقویم label row */}
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '0 8px' }}>
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'right' }}>تقویم</span>
<div style={{ width: '100%', padding: '0 8px', boxSizing: 'border-box' }}>
<span style={{ display: 'block', width: '100%', fontSize: 18, fontWeight: 700, color: '#262626', textAlign: 'right' }}>تقویم</span>
</div>
{/* month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 8px', direction: 'rtl' }}>
<button onClick={() => onNav(1)} style={{
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 8px', direction: 'ltr' }}>
<button type="button" aria-label="ماه بعد" title="ماه بعد" onClick={() => onNav(1)} style={{
width: 24, height: 24, borderRadius: 24, border: 'none',
background: '#FBEED9', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<ChevronRight size={12} color={GOLD} />
</button>
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'center', flex: 1 }}>
{J_MONTHS[month - 1]}ماه {faNum(year)}
<span dir="rtl" style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'center', flex: 1 }}>
{J_MONTHS[month - 1]} ماه {faNum(year)}
</span>
<button onClick={() => onNav(-1)} style={{
<button type="button" aria-label="ماه قبل" title="ماه قبل" onClick={() => onNav(-1)} style={{
width: 24, height: 24, borderRadius: 24, border: 'none',
background: '#FBEED9', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
@ -116,27 +196,33 @@ function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
{/* day cells — bg #F7F7F7 */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', direction: 'rtl',
background: '#F7F7F7', borderRadius: 16, padding: 4, gap: 3, flex: 1,
display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', direction: 'rtl',
background: '#F7F7F7', borderRadius: 16, padding: 8, gap: 4,
alignContent: 'start', width: '100%', boxSizing: 'border-box', overflow: 'hidden',
}}>
{Array.from({ length: firstDow }, (_, i) => <div key={`e${i}`} />)}
{days.map(d => {
const jp = jParts(d)
const isToday = jp.day === todayJ.day && jp.month === todayJ.month && jp.year === todayJ.year
const hasEv = eventDates.some(ed => {
const ej = jParts(ed); return ej.day === jp.day && ej.month === jp.month && ej.year === jp.year
})
const hasEv = eventDates.some(ed => ed.day === jp.day && ed.month === jp.month && ed.year === jp.year)
return (
<button key={jp.day} onClick={() => onSelect(d)} style={{
aspectRatio: '1', border: hasEv && !isToday ? `1px solid ${GOLD}` : 'none',
width: '100%', minWidth: 0, padding: 0,
borderRadius: isToday ? 99 : hasEv ? 60 : '50%',
fontSize: isToday ? 14 : 12, fontWeight: 500,
background: isToday ? GOLD : '#fff',
color: isToday ? '#fff' : '#888888',
fontSize: isToday ? 14 : 12, fontWeight: hasEv || isToday ? 800 : 500,
background: isToday ? GOLD : hasEv ? '#FFF6E6' : '#fff',
color: isToday ? '#fff' : hasEv ? NAVY : '#888888',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background .15s',
transition: 'background .15s', position: 'relative',
}}>
{faNum(jp.day)}
{hasEv && (
<span aria-hidden style={{
position: 'absolute', bottom: '12%', left: '50%', transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%', background: isToday ? '#fff' : GOLD,
}} />
)}
</button>
)
})}
@ -164,14 +250,32 @@ function CBox({ v, label }: { v: number; label: string }) {
export default function Events() {
const { lang } = useLang()
const isFa = lang === 'fa'
const cd = useCountdown(HERO_TARGET)
const [calAnchor, setCalAnchor] = useState(new Date())
const [calAnchor, setCalAnchor] = useState(initialCalendarAnchor)
const [eventList, setEventList] = useState(() => [...events])
const [selEvt, setSelEvt] = useState(events[0])
const [showAll, setShowAll] = useState(false)
const nextEvent = nearestUpcomingEvent(eventList) ?? eventList[0]
const nextEventTarget = eventStartTimestamp(nextEvent)
const cd = useCountdown(nextEventTarget)
useEffect(() => {
let active = true
bootstrapEvents().then(() => {
if (!active) return
const latestEvents = [...events]
setEventList(latestEvents)
if (latestEvents[0]) setSelEvt(latestEvents[0])
setCalAnchor(initialCalendarAnchor(latestEvents))
})
return () => { active = false }
}, [])
const handleCalNav = (dir: 1 | -1) => {
const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n)
const firstDay = new Date(calAnchor)
while (jParts(firstDay).day !== 1) firstDay.setDate(firstDay.getDate() - 1)
firstDay.setDate(firstDay.getDate() + (dir === 1 ? 32 : -1))
setCalAnchor(firstDay)
}
// scroll to the calendar when arriving via the «تقویم اندیشکده» nav link (/events#calendar)
@ -184,19 +288,20 @@ export default function Events() {
return () => clearTimeout(timer)
}, [hash])
// approximate Gregorian marker dates for each event
const MONTH_OFF: Record<string, number> = {
فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8,
مهر:9, آبان:10, آذر:11, دی:12, بهمن:1, اسفند:2,
}
const eventDates = events.map(ev => {
const m = MONTH_OFF[ev.month] ?? 3
const yr = m < 3 ? 2026 : 2025
return new Date(yr, m - 1, ev.date)
const eventDates = eventList
.flatMap(ev => {
const start = Number(ev.date)
const end = Number(ev.dateEnd ?? ev.date)
return Array.from({ length: Math.max(1, end - start + 1) }, (_, index) => ({
day: start + index,
month: jalaliMonthNumber(ev.month),
year: Number(ev.year),
}))
})
.filter(date => date.day >= 1 && date.day <= 31 && date.month >= 1 && date.year > 0)
const featured = events[0]
const others = events.slice(1)
const featured = eventList[0]
const others = eventList.slice(1)
const visibleOthers = showAll ? others : others.slice(0, 4)
const selType = TYPE_LABEL[selEvt.type] ?? { fa: selEvt.type, en: selEvt.type, color: GOLD, bg: '#f5f5f5' }
@ -217,14 +322,14 @@ export default function Events() {
{/* text + countdown — right side, vertically centered */}
<div style={{ position: 'absolute', top: 0, right: 0, bottom: 0, width: '52%', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 5vw 48px 24px', direction: 'rtl' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: GOLD }}>استکهلم، سوئد</span>
<span style={{ fontSize: 13, fontWeight: 700, color: GOLD }}>{nextEvent.location || nextEvent.city}</span>
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 16, height: 16, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
</div>
<h1 style={{ fontSize: 'clamp(22px,3vw,42px)', fontWeight: 900, color: GOLD, margin: '0 0 14px', lineHeight: 1.2 }}>
اجلاس جهانی فولاد سبز
{nextEvent.title}
</h1>
<p style={{ fontSize: 14, lineHeight: 2, color: 'rgba(255,255,255,0.72)', margin: '0 0 24px' }}>
مرجع نهایی برای تعیین استانداردهای کربنزدایی در زنجیره ارزش فولاد با حضور وزرای انرژی اتحادیه اروپا و مدیران ارشد ۵۰ شرکت برتر.
{nextEvent.description}
</p>
{/* countdown — inline below description */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, direction: 'ltr', justifyContent: 'flex-end' }}>
@ -234,7 +339,15 @@ export default function Events() {
<CBox v={cd.secs} label={isFa ? 'ثانیه' : 'Sec'} />
</div>
<div>
<a href="#" style={{
<a href="#calendar" onClick={() => {
setSelEvt(nextEvent)
const eventDate = gregorianDateForJalali({
day: Number(nextEvent.date),
month: jalaliMonthNumber(nextEvent.month),
year: Number(nextEvent.year),
})
if (eventDate) setCalAnchor(eventDate)
}} style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
background: GOLD, color: NAVY, borderRadius: 10, padding: '12px 28px',
fontSize: 14, fontWeight: 800, textDecoration: 'none',
@ -259,11 +372,11 @@ export default function Events() {
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
onSelect={d => {
const jp = jParts(d)
const hit = events.find(ev => {
const m = MONTH_OFF[ev.month] ?? 3
const yr = m < 3 ? 2026 : 2025
const ed = new Date(yr, m - 1, ev.date)
return jParts(ed).day === jp.day && jParts(ed).month === jp.month
const hit = eventList.find(ev => {
return jp.day >= Number(ev.date)
&& jp.day <= Number(ev.dateEnd ?? ev.date)
&& jalaliMonthNumber(ev.month) === jp.month
&& Number(ev.year) === jp.year
})
if (hit) setSelEvt(hit)
}}
@ -275,16 +388,6 @@ export default function Events() {
background: '#fff', border: '1px solid #E7E7E7', borderRadius: 16,
padding: 20, display: 'flex', flexDirection: 'column', gap: 20,
}}>
{/* hint text — styled box (Figma: bg #F8F9FA, border gold-10%, radius 16, center) */}
<div style={{
background: '#F8F9FA', border: '1px solid rgba(115,92,0,0.2)',
borderRadius: 16, padding: '8px 24px', textAlign: 'center',
}}>
<span style={{ fontSize: 16, fontWeight: 500, color: '#4D4635', direction: 'rtl' }}>
"برای مشاهده جزئیات هر رویداد، روی تاریخ مورد نظر در تقویم کلیک کنید."
</span>
</div>
{/* content row: text LEFT (flex 1) | image RIGHT (251px) — forced LTR */}
<div style={{ display: 'flex', gap: 18, direction: 'ltr', flex: 1, minHeight: 0 }}>
{/* text section */}
@ -297,7 +400,9 @@ export default function Events() {
</span>
{/* date */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, color: '#262626', direction: 'rtl' }}>{selEvt.date} {selEvt.month} {selEvt.year}</span>
<span style={{ fontSize: 12, color: '#262626', direction: 'rtl' }}>
{selEvt.dateEnd && selEvt.dateEnd !== selEvt.date ? `${selEvt.date} تا ${selEvt.dateEnd}` : selEvt.date} {selEvt.month} {selEvt.year}
</span>
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(40%) sepia(90%) saturate(400%) hue-rotate(190deg)' }} />
</div>
</div>
@ -332,9 +437,9 @@ export default function Events() {
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56 }}>
{/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24,
direction: 'rtl',
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
borderInlineStart: `5px solid ${GOLD}`, paddingInlineStart: 10, marginBottom: 24,
direction: isFa ? 'rtl' : 'ltr', width: '100%',
}}>
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
{isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'}
@ -371,7 +476,7 @@ export default function Events() {
</div>
{/* NAVY PANEL */}
<div style={{ background: NAVY, padding: '0 36px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 10, direction: isFa ? 'rtl' : 'ltr' }}>
<div style={{ background: NAVY, padding: '24px 36px 32px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 10, direction: isFa ? 'rtl' : 'ltr', boxSizing: 'border-box' }}>
{/* gold decorative bar */}
<div style={{ width: 108, height: 14, background: GOLD, borderRadius: 7, alignSelf: 'flex-end' }} />
@ -417,7 +522,7 @@ export default function Events() {
*/}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 80 }}>
{/* heading — gold right border */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24, direction: 'rtl' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', borderInlineStart: `5px solid ${GOLD}`, paddingInlineStart: 10, marginBottom: 24, direction: isFa ? 'rtl' : 'ltr', width: '100%' }}>
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
{isFa ? 'سایر رویدادها' : 'Other Events'}
</h2>

View File

@ -17,7 +17,6 @@ export default function Home() {
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
<SpreadFeatures /> {/* هیرو */}
<RadarTechSection only="radar" /> {/* رادار آینده */}
<RadarTechSection only="approach" /> {/* چارچوب اندیشه ما */}
<IntegrationsSection /> {/* آمار و داده — نبض صنعت */}
<AtAGlanceSection /> {/* در یک نگاه */}
<MapEventsSection /> {/* دیده‌بان رویدادها */}

View File

@ -1,59 +1,175 @@
import { useState } from 'react'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import { Link } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
import { sortByPublishedDesc } from '@/utils/dateSort'
import { resolveSourceUrl } from '@/utils/sourceLinks'
const NAVY = '#032340'
const GOLD = '#CD9E53'
type Glance = {
id: string
source: string
date: string; dateEn: string
title: string; titleEn: string
desc: string; descEn: string
sourceUrl?: string
date: string
dateFa: string
dateEn: string
dateIso: string
createdAt?: string
updatedAt?: string
title: string
titleEn: string
desc: string
descEn: string
img: string
}
const ITEMS: Glance[] = [
export const FALLBACK_ITEMS: Glance[] = [
{
id: 'h2-green-steel',
source: 'Financial Times',
date: '۱۴ اردیبهشت ۱۴۰۵', dateEn: 'May 4, 2026',
date: '۱۴ اردیبهشت ۱۴۰۵',
dateFa: '۱۴ اردیبهشت ۱۴۰۵',
dateEn: 'May 4, 2026',
dateIso: '2026-05-04',
title: 'شوک هیدروژنی به بازار جهانی؛ آغاز رسمی عصر فولادسازی بدون کربن',
titleEn: 'Hydrogen Shock to Global Markets; the Carbon-Free Steel Era Begins',
desc: 'پروژه عظیم «فولاد سبز H2» در سوئد با جذب بی‌سابقه ۶.۵ میلیارد یورو سرمایه، رسماً وارد فاز اجرایی شد تا نخستین کارخانه فولادسازی در مقیاس تجاری بر پایه هیدروژن سبز (بدون نیاز به زغال‌سنگ) در جهان متولد شود. این تأسیسات با هدف کاهش ۹۵ درصدی انتشار CO₂، قواعد رقابت در زنجیره ارزش اروپا را برای همیشه بازتعریف می‌کند.',
descEn: 'Sweden\'s massive "Green Steel H2" project, having raised an unprecedented €6.5bn, has officially entered its execution phase to birth the world\'s first commercial-scale steel plant powered by green hydrogen — redefining Europe\'s value-chain competition for good.',
desc: 'پروژه فولاد سبز مبتنی بر هیدروژن در اروپا، رقابت را از قیمت صرف به شدت کربن، دسترسی به انرژی پاک و قابلیت اخذ پریمیوم سبز منتقل می‌کند. برای فولاد مبارکه، پیام اصلی روشن است: بدون مسیر معتبر کاهش کربن، بخشی از بازارهای صادراتی آینده گران‌تر یا محدودتر می‌شوند.',
descEn: 'Hydrogen-based green steel is shifting competition from price alone toward carbon intensity, clean-energy access and green premium capture.',
img: '/news/photo-1504307651254-35680f356dfd.jpg',
},
{
source: 'Reuters',
date: '۱۲ اردیبهشت ۱۴۰۵', dateEn: 'May 2, 2026',
title: 'مکانیسم تنظیم مرزهای کربنی اروپا وارد فاز اجرای کامل شد',
titleEn: 'EU Carbon Border Mechanism Enters Full Enforcement',
desc: 'با اجرای کامل CBAM، صادرکنندگان فولاد به اتحادیه اروپا اکنون موظف‌اند هزینه کامل کربن نهفته در محصولات خود را بپردازند. این تحول، فشار بی‌سابقه‌ای بر تولیدکنندگان مبتنی بر کوره بلند وارد کرده و مزیت رقابتی را به سمت فولاد کم‌کربن سوق می‌دهد.',
descEn: 'With CBAM now fully enforced, steel exporters to the EU must pay the full cost of embedded carbon — placing unprecedented pressure on blast-furnace producers and shifting the competitive edge toward low-carbon steel.',
id: 'eu-cbam',
source: 'European Commission / Reuters',
date: '۱۲ اردیبهشت ۱۴۰۵',
dateFa: '۱۲ اردیبهشت ۱۴۰۵',
dateEn: 'May 2, 2026',
dateIso: '2026-05-02',
title: 'CBAM اروپا؛ کربن به متغیر مستقیم قیمت صادرات فولاد تبدیل می‌شود',
titleEn: 'EU CBAM Turns Carbon into a Direct Steel Export Cost',
desc: 'ورود CBAM به فاز مالی، مزیت تولیدکنندگانی را که داده انتشار شفاف، برق کم‌کربن و مسیر کاهش CO₂ دارند تقویت می‌کند. برای صادرکنندگان فولاد، موضوع فقط گزارش‌دهی نیست؛ اثر مستقیم روی قیمت تمام‌شده، قراردادهای بلندمدت و پذیرش مشتری اروپایی دارد.',
descEn: 'CBAM strengthens producers with transparent emissions data, low-carbon electricity and credible decarbonization roadmaps.',
img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
},
{
source: 'Bloomberg',
date: '۳۰ فروردین ۱۴۰۵', dateEn: 'Apr 19, 2026',
title: 'هوش مصنوعی، تقاضای جهانی فولاد مراکز داده را بازتعریف می‌کند',
titleEn: 'AI Is Rewriting Global Steel Demand for Data Centers',
desc: 'موج ساخت مراکز داده هوش مصنوعی، تقاضای جدیدی برای مقاطع ساختمانی و فولاد ویژه ایجاد کرده است. تحلیلگران پیش‌بینی می‌کنند این روند تا پایان دهه، یکی از پیشران‌های اصلی رشد مصرف فولاد در اقتصادهای پیشرفته باشد.',
descEn: 'The AI data-center construction wave is creating fresh demand for structural sections and specialty steel — projected to be a leading driver of steel consumption growth in advanced economies through the decade.',
id: 'ai-data-centers',
source: 'Bloomberg / worldsteel',
date: '۱۰ اردیبهشت ۱۴۰۵',
dateFa: '۱۰ اردیبهشت ۱۴۰۵',
dateEn: 'Apr 30, 2026',
dateIso: '2026-04-30',
title: 'مراکز داده هوش مصنوعی، الگوی تقاضای فولاد ویژه را تغییر می‌دهند',
titleEn: 'AI Data Centers Reshape Specialty Steel Demand',
desc: 'رشد سرمایه‌گذاری در مراکز داده، تقاضا برای مقاطع سازه‌ای، ورق‌های باکیفیت، تجهیزات سرمایشی و فولادهای مهندسی را بالا می‌برد. فرصت برای فولاد مبارکه در محصولاتی است که کیفیت، تحویل قابل اتکا و استاندارد پروژه‌ای بالاتری می‌خواهند.',
descEn: 'AI data-center construction lifts demand for structural sections, high-quality flat steel and engineered steel applications.',
img: '/news/photo-1518770660439-4636190af475.jpg',
},
{
id: 'middle-east-logistics',
source: 'MSC / AP / Al Jazeera',
date: '۶ اردیبهشت ۱۴۰۵',
dateFa: '۶ اردیبهشت ۱۴۰۵',
dateEn: 'Apr 26, 2026',
dateIso: '2026-04-26',
title: 'ریسک مسیرهای دریایی، هزینه صادرات منطقه‌ای فولاد را بالا نگه می‌دارد',
titleEn: 'Maritime Risk Keeps Regional Steel Export Costs Elevated',
desc: 'اختلال در مسیرهای دریایی و افزایش حق بیمه حمل، مزیت بازارهای نزدیک را پررنگ‌تر می‌کند. برای فولاد مبارکه، مدیریت بازارهای عراق، CIS و خلیج فارس باید همزمان با سناریوی حمل جایگزین و قراردادهای منعطف انجام شود.',
descEn: 'Shipping disruption and higher insurance costs increase the value of nearby export markets and flexible logistics planning.',
img: '/news/photo-1540575467063-178a50c2df87.jpg',
},
{
id: 'energy-security',
source: 'IEA / گزارش‌های صنعت فولاد ایران',
date: '۲ اردیبهشت ۱۴۰۵',
dateFa: '۲ اردیبهشت ۱۴۰۵',
dateEn: 'Apr 22, 2026',
dateIso: '2026-04-22',
title: 'امنیت انرژی، سقف عملیاتی تولید فولاد ایران را تعیین می‌کند',
titleEn: 'Energy Security Sets Iran Steels Operating Ceiling',
desc: 'ناترازی گاز و برق، دیگر فقط مسئله هزینه نیست؛ ریسک توقف تولید، کاهش قابلیت تحویل و افت اعتماد مشتری را ایجاد می‌کند. اولویت عملیاتی، قراردادهای انرژی پایدار، بازیافت حرارت و سرمایه‌گذاری در برق اختصاصی است.',
descEn: 'Gas and power imbalance is now an availability risk, not only a cost issue, affecting delivery reliability and customer confidence.',
img: '/news/photo-1466611653911-95081537e5b7.jpg',
},
]
const normalizePanelItem = (row: Record<string, unknown>, index: number): Glance => {
const bodyFa = String(row.bodyFa ?? row.descFa ?? row.descriptionFa ?? '').trim()
const bodyEn = String(row.bodyEn ?? row.descEn ?? row.descriptionEn ?? bodyFa).trim()
const dateEn = String(row.dateEn ?? row.dateIso ?? row.createdAt ?? row.updatedAt ?? '').trim()
const dateFa = String(row.dateFa ?? row.date ?? dateEn).trim()
return {
id: String(row.id ?? `panel-${index}`),
source: String(row.source ?? ''),
sourceUrl: resolveSourceUrl(String(row.source ?? ''), String(row.sourceUrl ?? row.source_url ?? row.url ?? row.link ?? '')),
date: dateFa,
dateFa,
dateEn,
dateIso: String(row.dateIso ?? row.createdAt ?? row.updatedAt ?? dateEn),
createdAt: String(row.createdAt ?? ''),
updatedAt: String(row.updatedAt ?? ''),
title: String(row.titleFa ?? row.title ?? ''),
titleEn: String(row.titleEn ?? row.titleFa ?? row.title ?? ''),
desc: bodyFa.split(/\n{2,}/)[0] || bodyFa,
descEn: bodyEn.split(/\n{2,}/)[0] || bodyEn,
img: String(row.img ?? row.image ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
}
}
export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) {
const { lang } = useLang()
const isFa = lang === 'fa'
const [remoteItems, setRemoteItems] = useState<Glance[] | null>(null)
const [i, setI] = useState(0)
const [dir, setDir] = useState<1 | -1>(1)
const it = ITEMS[i]
useEffect(() => {
const PANEL_API = import.meta.env.DEV
? ''
: ((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || '')
let alive = true
fetch(`${PANEL_API}/api/radar?limit=30`)
.then(r => (r.ok ? r.json() : null))
.then((rows) => {
if (!alive || !Array.isArray(rows) || rows.length === 0) return
const mapped = rows
.map(normalizePanelItem)
.filter(item => item.title && item.desc)
if (mapped.length) setRemoteItems(mapped)
})
.catch((error) => {
console.error('Failed to load homepage at-a-glance items', error)
})
return () => { alive = false }
}, [])
const items = useMemo(
() => sortByPublishedDesc(remoteItems ?? []),
[remoteItems],
)
const latest = items.slice(0, 5)
const it = latest[i] ?? latest[0]
useEffect(() => {
setI(0)
}, [items])
const go = (d: 1 | -1) => {
setDir(d)
setI((prev) => (prev + d + ITEMS.length) % ITEMS.length)
setI((prev) => (prev + d + latest.length) % latest.length)
}
if (!it) {
return (
<section
id="at-a-glance"
aria-busy="true"
style={{ background: '#faf7f4', padding: '52px 0', minHeight: 500, scrollMarginTop: '90px' }}
/>
)
}
const T = {
@ -80,8 +196,8 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
padding: '8px 16px', border: `1.5px solid ${GOLD}`, borderRadius: 8,
transition: 'background 160ms, color 160ms', whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.color = '#fff' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = GOLD }}
onMouseEnter={(e) => { e.currentTarget.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = GOLD }}
>
{isFa ? 'مشاهده بیشتر' : 'See more'}
<span>{isFa ? '←' : '→'}</span>
@ -93,7 +209,6 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
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 ── */}
<div style={{ width: '35%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[400px]">
<div style={{
position: 'absolute', inset: 0,
@ -107,7 +222,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
>
<AnimatePresence mode="wait" custom={dir}>
<motion.img
key={i}
key={it.id}
src={it.img}
alt=""
initial={{ opacity: 0, scale: 1.06 }}
@ -120,7 +235,6 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
</div>
</div>
{/* ── TEXT card side ── */}
<div style={{ flex: 1, position: 'relative', zIndex: 2 }} className="max-lg:!w-full">
<div style={{
background: '#fff', borderRadius: 6,
@ -133,7 +247,7 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
<AnimatePresence mode="wait">
<motion.div
key={i}
key={it.id}
initial={{ opacity: 0, x: dir * 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: dir * -16 }}
@ -148,12 +262,41 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
<CalendarIcon />
{isFa ? it.date : it.dateEn}
</span>
<span style={{ fontSize: 14, fontWeight: 700, color: GOLD, letterSpacing: '0.3px' }}>{it.source}</span>
{it.source && <SourceLink source={it.source} sourceUrl={it.sourceUrl} />}
</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, 1.05vw, 15px)',
lineHeight: 2.1,
color: 'rgba(7,29,73,0.72)',
margin: 0,
textAlign: 'justify',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 4,
overflow: 'hidden',
}}>
{isFa ? it.desc : it.descEn}
</p>
<Link
to="/at-a-glance"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 6,
width: '100%',
marginTop: 14,
color: GOLD,
fontSize: 13,
fontWeight: 800,
textDecoration: 'none',
}}
>
{isFa ? 'مطالعه بیشتر' : 'Read more'}
<span aria-hidden="true">{isFa ? '←' : '→'}</span>
</Link>
</motion.div>
</AnimatePresence>
@ -169,6 +312,33 @@ export default function AtAGlanceSection({ embedded = false }: { embedded?: bool
)
}
function SourceLink({ source, sourceUrl }: { source: string; sourceUrl?: string }) {
const href = resolveSourceUrl(source, sourceUrl)
const style: CSSProperties = {
fontSize: 14,
fontWeight: 700,
color: GOLD,
letterSpacing: '0.3px',
textDecoration: href ? 'underline' : 'none',
textUnderlineOffset: 3,
cursor: href ? 'pointer' : 'default',
}
if (!href) return <span style={style}>{source}</span>
return (
<a
href={href}
target={href.startsWith('http') ? '_blank' : undefined}
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
style={style}
onClick={(e) => e.stopPropagation()}
>
{source}
</a>
)
}
function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClick: () => void; aria: string }) {
return (
<button
@ -181,8 +351,8 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'inherit', transition: 'background 180ms, color 180ms',
}}
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.style.background = GOLD; e.currentTarget.style.color = '#fff' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; e.currentTarget.style.color = GOLD }}
>
{children}
</button>

View File

@ -1,7 +1,10 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
import { Download, BookOpen, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
import { videos as VIDEOS, podcasts as PODCASTS } from '@/data/media'
import { Bookmark, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
import { bootstrapMedia, videos as VIDEOS, podcasts as PODCASTS } from '@/data/media'
import MediaWavePlayer from '@/components/MediaWavePlayer'
import { authFetch, useAuth } from '@/context/AuthContext'
const NAVY = '#032340'
const GOLD = '#CD9E53'
@ -23,10 +26,73 @@ const T = {
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
const { lang } = useLang()
const { member } = useAuth()
const navigate = useNavigate()
const t = T[lang]
const [activeVideo, setActiveVideo] = useState(0)
const [activePod, setActivePod] = useState(0)
const pod = PODCASTS[activePod]
const [activePod, setActivePod] = useState(() => {
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
return playableIndex >= 0 ? playableIndex : 0
})
const [podcasts, setPodcasts] = useState(() => [...PODCASTS])
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
const [bookmarkBusy, setBookmarkBusy] = useState(false)
const pod = podcasts[activePod] ?? podcasts[0]
useEffect(() => {
let active = true
bootstrapMedia().then(() => {
if (!active) return
setPodcasts([...PODCASTS])
const playableIndex = PODCASTS.findIndex(item => Boolean(item.mediaUrl?.trim()))
setActivePod(playableIndex >= 0 ? playableIndex : 0)
})
return () => { active = false }
}, [])
useEffect(() => {
if (!member) { setBookmarkedIds(new Set()); return }
let active = true
authFetch('/api/members/me/bookmarks')
.then(async response => response.ok ? response.json() : [])
.then(items => {
if (!active || !Array.isArray(items)) return
setBookmarkedIds(new Set(items.filter(item => item.contentType === 'podcast').map(item => String(item.contentId))))
})
.catch(() => {})
return () => { active = false }
}, [member])
const togglePodcastBookmark = async () => {
if (!member) { navigate('/login'); return }
if (!pod || bookmarkBusy) return
const contentId = String(pod.id)
const saved = bookmarkedIds.has(contentId)
setBookmarkBusy(true)
try {
const response = saved
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(contentId)}`, { method: 'DELETE' })
: await authFetch('/api/members/me/bookmarks', {
method: 'POST',
body: JSON.stringify({
contentId,
contentType: 'podcast',
title: pod[lang].title,
summary: pod[lang].body,
coverImage: pod.cover,
path: '/podcast',
}),
})
if (!response.ok) throw new Error('bookmark_failed')
setBookmarkedIds(current => {
const next = new Set(current)
if (saved) next.delete(contentId)
else next.add(contentId)
return next
})
} catch { /* keep the previous state when the request fails */ }
finally { setBookmarkBusy(false) }
}
const showVideo = only !== 'pod'
const showPod = only !== 'video'
@ -200,17 +266,27 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
direction: lang === 'fa' ? 'rtl' : 'ltr',
}}>
{/* buttons row + episode number */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, direction: 'ltr' }}>
{/* bookmark */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', marginBottom: 12, direction: 'ltr' }}>
<div style={{ display: 'flex', gap: 10 }}>
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
<Download size={16} strokeWidth={2} />
</button>
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
<BookOpen size={16} strokeWidth={2} />
<button
type="button"
onClick={togglePodcastBookmark}
disabled={bookmarkBusy}
aria-label={bookmarkedIds.has(String(pod.id)) ? 'حذف از علاقه‌مندی‌ها' : 'افزودن به علاقه‌مندی‌ها'}
title={bookmarkedIds.has(String(pod.id)) ? 'حذف از علاقه‌مندی‌ها' : 'ذخیره در علاقه‌مندی‌ها'}
style={{
width: 36, height: 36, borderRadius: '50%',
border: `1px solid ${bookmarkedIds.has(String(pod.id)) ? GOLD : 'rgba(3,35,64,0.18)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: bookmarkedIds.has(String(pod.id)) ? `${GOLD}22` : 'transparent',
color: bookmarkedIds.has(String(pod.id)) ? GOLD : NAVY,
cursor: bookmarkBusy ? 'wait' : 'pointer', transition: 'all 0.2s',
}}
>
<Bookmark size={16} strokeWidth={2} fill={bookmarkedIds.has(String(pod.id)) ? 'currentColor' : 'none'} />
</button>
</div>
<div style={{ fontSize: 12, fontWeight: 800, color: GOLD, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>{pod.epNum}</div>
</div>
{/* title */}
@ -227,6 +303,11 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div style={{ height: 1, background: 'rgba(3,35,64,0.1)', marginBottom: 16 }} />
{/* play + waveform */}
{pod.mediaUrl ? (
<div style={{ marginInlineStart: -16, minWidth: 0 }}>
<MediaWavePlayer src={pod.mediaUrl} episodeKey={pod.id} tone="light" compact />
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexDirection: lang === 'fa' ? 'row-reverse' : 'row', minWidth: 0 }}>
<button aria-label="play" style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'transform 0.2s, background-color 0.2s' }} className="hover:!scale-105 hover:!bg-[#b88d46]">
<Play size={18} fill="#fff" color="#fff" style={{ marginLeft: 2 }} />
@ -241,6 +322,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<span style={{ fontSize: 11, color: 'rgba(3,35,64,0.6)', fontWeight: 700, flexShrink: 0 }}>{pod.duration}</span>
</div>
</div>
)}
</div>
</div>
@ -259,8 +341,8 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
flexDirection: 'column',
gap: 16
}}>
{PODCASTS.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => {
const originalIndex = PODCASTS.findIndex(item => item.id === p.id);
{podcasts.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => {
const originalIndex = podcasts.findIndex(item => item.id === p.id);
return (
<button
key={p.id}

View File

@ -1,12 +1,13 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
import { Headphones, ChevronLeft, ChevronRight, PlayCircle, Play, Pause, Volume2, VolumeX } from 'lucide-react'
import { authFetch, useAuth } from '@/context/AuthContext'
import { ChevronLeft, ChevronRight, PlayCircle, Play, Pause, Volume2, VolumeX, CalendarDays, Bookmark, Mic2 } from 'lucide-react'
import { podcasts as PODCASTS, videos as VIDEOS } from '@/data/media'
import FeaturedReportBanner from '../Home/sections/FeaturedReportBanner'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const CARD = '#0d2137'
const T = {
fa: { listen: 'پخش', watch: 'تماشا', allVids: 'همه ویدیوها', videocast: 'ویدیوکست', podcast: 'پادکست اندیشکده', episodes: 'اپیزودها' },
@ -41,9 +42,167 @@ const STYLE = `
background: conic-gradient(from var(--angle), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00);
box-shadow: 0 0 24px #CD9E5366, 0 0 48px #CD9E5322;
}
.podcast-figma-feature { display:grid; grid-template-columns:minmax(0,1fr) 195px; }
.podcast-figma-content { padding:28px 30px 22px; }
.podcast-figma-cover { min-height:212px; }
.podcast-figma-intro { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:72px; }
@media (max-width: 760px) {
.podcast-figma-feature { grid-template-columns:1fr; }
.podcast-figma-cover { min-height:220px; order:-1; }
.podcast-figma-content { padding:22px 18px 18px; }
.podcast-figma-intro { grid-template-columns:1fr; gap:24px; }
}
`
/* ══ Real waveform audio player ══ */
function mediaWaveBars(seedValue: string | number, count = 74) {
let seed = String(seedValue).split('').reduce((sum, char) => sum + char.charCodeAt(0), 17)
return Array.from({ length: count }, (_, index) => {
seed = (seed * 9301 + 49297) % 233280
const random = seed / 233280
const envelope = 0.48 + 0.28 * Math.sin(index * 0.31) + 0.16 * Math.sin(index * 0.83)
return Math.max(0.16, Math.min(0.96, envelope + random * 0.3))
})
}
/* ══ Uploaded-file waveform player ══ */
function MediaWavePlayer({ src, episodeKey, light = false }: { src: string; episodeKey: string | number; light?: boolean }) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(0)
const [failed, setFailed] = useState(false)
const bars = useMemo(() => mediaWaveBars(episodeKey), [episodeKey])
const progress = duration > 0 ? Math.min(current / duration, 1) : 0
useEffect(() => {
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.currentTime = 0
setPlaying(false)
setMuted(false)
setCurrent(0)
setDuration(0)
setFailed(false)
audio.load()
}, [src, episodeKey])
const toggle = async () => {
const audio = audioRef.current
if (!audio || failed) return
if (audio.paused) {
try {
await audio.play()
setPlaying(true)
} catch {
setFailed(true)
}
} else {
audio.pause()
setPlaying(false)
}
}
const seek = (event: React.MouseEvent<HTMLDivElement>) => {
const audio = audioRef.current
if (!audio || !duration) return
const rect = event.currentTarget.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
audio.currentTime = ratio * duration
setCurrent(audio.currentTime)
}
const toggleMute = () => {
const audio = audioRef.current
if (!audio) return
audio.muted = !audio.muted
setMuted(audio.muted)
}
const fmt = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const minutes = Math.floor(seconds / 60)
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<audio
ref={audioRef}
src={src}
preload="metadata"
onLoadedMetadata={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
onDurationChange={(event) => setDuration(Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0)}
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => { setPlaying(false); setCurrent(0) }}
onError={() => setFailed(true)}
style={{ display: 'none' }}
/>
<div
role="slider"
aria-label="موقعیت پخش پادکست"
aria-valuemin={0}
aria-valuemax={Math.max(0, Math.round(duration))}
aria-valuenow={Math.round(current)}
tabIndex={0}
onClick={seek}
onKeyDown={(event) => {
const audio = audioRef.current
if (!audio || !duration) return
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault()
const delta = event.key === 'ArrowRight' ? 5 : -5
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + delta))
}
}}
style={{
width: '100%', maxWidth: '100%', height: 64, display: 'flex', alignItems: 'center', gap: 2,
cursor: duration ? 'pointer' : 'default', direction: 'ltr',
borderRadius: 10, padding: '0 4px', outline: 'none', overflow: 'hidden', boxSizing: 'border-box',
}}
>
{bars.map((height, index) => {
const played = (index + 0.5) / bars.length <= progress
return (
<span key={index} style={{
flex: '1 1 0', minWidth: 1, maxWidth: 6,
height: `${Math.round(height * 54)}px`, borderRadius: 999,
background: played ? GOLD : (light ? 'rgba(3,35,64,0.55)' : 'rgba(255,255,255,0.18)'),
boxShadow: played ? `0 0 8px ${GOLD}55` : 'none',
transition: 'background-color 160ms ease, box-shadow 160ms ease',
}} />
)
})}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, direction: 'ltr' }}>
<button type="button" onClick={toggle} disabled={failed} aria-label={playing ? 'توقف پادکست' : 'پخش پادکست'} style={{
width: 42, height: 42, borderRadius: '50%', border: 'none', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: failed ? 'rgba(3,35,64,0.12)' : GOLD,
color: NAVY, cursor: failed ? 'not-allowed' : 'pointer',
boxShadow: playing ? `0 0 18px ${GOLD}66` : 'none',
}}>
{playing ? <Pause size={17} fill={NAVY} /> : <Play size={17} fill={NAVY} style={{ marginLeft: 2 }} />}
</button>
<span style={{ minWidth: 38, color: light ? NAVY : '#fff', fontSize: 11, fontWeight: 800 }}>{fmt(current)}</span>
<span style={{ minWidth: 38, textAlign: 'right', color: light ? 'rgba(3,35,64,0.55)' : 'rgba(255,255,255,0.48)', fontSize: 11, fontWeight: 700 }}>{fmt(duration)}</span>
<button type="button" onClick={toggleMute} aria-label={muted ? 'فعال کردن صدا' : 'قطع صدا'} style={{ border: 0, background: 'transparent', padding: 5, cursor: 'pointer', display: 'flex' }}>
{muted ? <VolumeX size={17} color={light ? NAVY : 'rgba(255,255,255,0.55)'} /> : <Volume2 size={17} color={light ? NAVY : 'rgba(255,255,255,0.55)'} />}
</button>
</div>
{failed && <div style={{ color: '#fca5a5', fontSize: 11, textAlign: 'right' }}>فایل صوتی قابل پخش نیست. آدرس فایل یا فرمت آن را بررسی کنید.</div>}
</div>
)
}
function WavePlayer({ episodeKey }: { episodeKey: string | number }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animRef = useRef<number>(0)
@ -308,67 +467,56 @@ function WavePlayer({ episodeKey }: { episodeKey: string | number }) {
)
}
/* ─── active glow pill ─── */
function ActivePill({ active }: { active: boolean }) {
return active ? (
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: GOLD, boxShadow: `0 0 8px ${GOLD}`, flexShrink: 0 }} />
) : null
}
/* ─── episode sidebar item ─── */
function EpItem({ pod, active, onClick, lang }: {
pod: typeof PODCASTS[0]; active: boolean; onClick: () => void; lang: 'fa' | 'en'
}) {
const isFa = lang === 'fa'
return (
<button onClick={onClick} style={{
all: 'unset', display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 12, cursor: 'pointer',
background: active ? 'rgba(205,158,83,0.10)' : 'transparent',
border: active ? `1px solid rgba(205,158,83,0.25)` : '1px solid transparent',
transition: 'all .2s', width: '100%', boxSizing: 'border-box',
flexDirection: isFa ? 'row-reverse' : 'row',
}}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<img src={pod.cover} alt="" style={{
width: 48, height: 48, borderRadius: 10, objectFit: 'cover', display: 'block',
border: active ? `2px solid ${GOLD}` : '2px solid rgba(255,255,255,0.08)',
transition: 'border-color .2s',
}} />
{active && (
<div style={{ position: 'absolute', inset: 0, borderRadius: 10, background: 'rgba(205,158,83,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${GOLD}`, marginLeft: 2 }} />
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 0, textAlign: isFa ? 'right' : 'left' }}>
<div style={{
fontSize: 11.5, fontWeight: 800, color: active ? '#fff' : 'rgba(255,255,255,0.65)',
lineHeight: 1.4, marginBottom: 3,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
transition: 'color .2s',
}}>{pod[lang].title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
<ActivePill active={active} />
<span style={{ fontSize: 10, color: active ? GOLD : 'rgba(255,255,255,0.25)', fontWeight: 700 }}>{pod.epNum}</span>
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.2)' }}>·</span>
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.25)' }}>{pod.duration}</span>
</div>
</div>
</button>
)
}
/* ══ MAIN ══ */
export default function Podcast() {
const { lang } = useLang()
const { member } = useAuth()
const navigate = useNavigate()
const t = T[lang]
const isFa = lang === 'fa'
const [active, setActive] = useState(0)
const [activeVid, setActiveVid] = useState(0)
const [vidPlaying, setVidPlaying] = useState(false)
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(new Set())
const [bookmarkBusy, setBookmarkBusy] = useState(false)
const pod = PODCASTS[active]
const vid = VIDEOS[activeVid]
const showPodGuest = Boolean(pod[lang].guest?.trim())
useEffect(() => {
if (!member) { setBookmarkedIds(new Set()); return }
let mounted = true
authFetch('/api/members/me/bookmarks')
.then(async response => response.ok ? response.json() : [])
.then(items => {
if (!mounted || !Array.isArray(items)) return
setBookmarkedIds(new Set(items.filter(item => item.contentType === 'podcast').map(item => String(item.contentId))))
})
.catch(() => {})
return () => { mounted = false }
}, [member])
const togglePodcastBookmark = async () => {
if (!member) { navigate('/login'); return }
if (bookmarkBusy) return
const contentId = String(pod.id)
const saved = bookmarkedIds.has(contentId)
setBookmarkBusy(true)
try {
const response = saved
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(contentId)}`, { method: 'DELETE' })
: await authFetch('/api/members/me/bookmarks', {
method: 'POST',
body: JSON.stringify({ contentId, contentType: 'podcast', title: pod[lang].title, summary: pod[lang].body, coverImage: pod.cover, path: '/podcast' }),
})
if (!response.ok) return
setBookmarkedIds(current => {
const next = new Set(current)
if (saved) next.delete(contentId); else next.add(contentId)
return next
})
} finally { setBookmarkBusy(false) }
}
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '100vh', color: '#fff', ['--paper' as string]: '#fff', ['--page-bg' as string]: '#fff' }}>
@ -376,167 +524,70 @@ export default function Podcast() {
{/* ══ PODCAST SECTION ══ */}
<div style={{ background: 'transparent' }}>
<div className="max-w-7xl mx-auto px-8 max-md:px-4" style={{ paddingTop: 56, paddingBottom: 64 }}>
{/* label */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'rgba(205,158,83,0.12)', border: '1px solid rgba(205,158,83,0.25)',
borderRadius: 20, padding: '5px 14px', marginBottom: 32,
}}>
<Headphones size={13} color={GOLD} strokeWidth={2} />
<span style={{ fontSize: 12, fontWeight: 800, color: GOLD, letterSpacing: '0.04em' }}>{t.podcast}</span>
<div className="max-w-7xl mx-auto px-8 max-md:px-4" style={{ paddingTop: 56, paddingBottom: 72 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ color: '#606060', fontSize: 14 }}>{isFa ? 'مشاهده همه' : 'View all'}</span>
<h1 style={{ margin: 0, color: NAVY, fontSize: 24, fontWeight: 800, borderRight: isFa ? `5px solid ${GOLD}` : 0, borderLeft: !isFa ? `5px solid ${GOLD}` : 0, paddingRight: isFa ? 12 : 0, paddingLeft: !isFa ? 12 : 0 }}>
{isFa ? 'پادکست‌ها' : 'Podcasts'}
</h1>
</div>
{/* main grid */}
<div style={{
display: 'grid',
gridTemplateColumns: isFa ? '1fr 300px' : '300px 1fr',
gap: 0,
background: CARD,
borderRadius: 28,
overflow: 'hidden',
boxShadow: '0 32px 80px -24px rgba(0,0,0,0.7)',
minHeight: 560,
}} className="max-lg:!grid-cols-1">
{/* sidebar */}
<div style={{
borderRight: isFa ? 'none' : '1px solid rgba(255,255,255,0.06)',
borderLeft: isFa ? '1px solid rgba(255,255,255,0.06)' : 'none',
display: 'flex', flexDirection: 'column',
order: isFa ? 2 : 1,
}}>
<div style={{ padding: '20px 16px 14px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ fontSize: 11, fontWeight: 900, color: 'rgba(255,255,255,0.3)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{t.episodes}</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
{PODCASTS.map((p, i) => (
<EpItem key={p.id} pod={p} active={active === i} onClick={() => setActive(i)} lang={lang} />
))}
<section className="podcast-figma-feature" style={{ background: '#ededed', borderRadius: 16, overflow: 'hidden', color: NAVY }}>
<div className="podcast-figma-content" style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<button type="button" onClick={togglePodcastBookmark} disabled={bookmarkBusy} aria-label={bookmarkedIds.has(String(pod.id)) ? (isFa ? 'حذف از علاقه‌مندی‌ها' : 'Remove bookmark') : (isFa ? 'ذخیره در علاقه‌مندی‌ها' : 'Bookmark')} style={{ width: 36, height: 36, border: 0, background: 'transparent', color: bookmarkedIds.has(String(pod.id)) ? GOLD : NAVY, cursor: bookmarkBusy ? 'wait' : 'pointer', display: 'grid', placeItems: 'center' }}>
<Bookmark size={27} strokeWidth={1.6} fill={bookmarkedIds.has(String(pod.id)) ? 'currentColor' : 'none'} />
</button>
<div style={{ textAlign: isFa ? 'right' : 'left' }}>
<div style={{ color: GOLD, fontSize: 17, marginBottom: 14 }}>{pod.epNum}</div>
<h2 style={{ margin: 0, color: NAVY, fontSize: 'clamp(20px,2.2vw,29px)', lineHeight: 1.45, fontWeight: 700 }}>{pod[lang].title}</h2>
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 7, color: '#606060', fontSize: 13, justifyContent: isFa ? 'flex-start' : 'flex-end' }}>
<CalendarDays size={19} color={GOLD} />
<span>{pod.date}</span>
</div>
</div>
{/* hero */}
<div style={{ display: 'flex', flexDirection: 'column', order: isFa ? 1 : 2, position: 'relative', overflow: 'hidden' }}>
{/* blurred bg */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: `url(${pod.cover})`,
backgroundSize: 'cover', backgroundPosition: 'center',
filter: 'blur(48px) saturate(0.3)', opacity: 0.2, transform: 'scale(1.15)',
}} />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(135deg, ${CARD} 35%, rgba(13,33,55,0.6) 100%)` }} />
{/* inner grid: text | cover-image */}
<div style={{
position: 'relative', zIndex: 1,
display: 'grid',
gridTemplateColumns: isFa ? '300px 1fr' : '1fr 300px',
height: '100%',
}} className="max-lg:!flex max-lg:!flex-col">
{/* ── COVER image column ── */}
{/* outer: spinning gradient border */}
<div style={{
order: isFa ? 1 : 2,
margin: 10,
borderRadius: 16,
padding: 3,
background: 'conic-gradient(from var(--angle,0deg), #7a4f00, #CD9E53, #f0d080, #fff8e1, #CD9E53, #7a4f00)',
animation: 'borderRotate 3s linear infinite',
boxShadow: `0 0 28px ${GOLD}55, 0 0 56px ${GOLD}22`,
flexShrink: 0,
alignSelf: 'stretch',
}} className="max-lg:!order-first max-lg:!my-4 max-lg:!h-56 max-lg:!self-auto">
{/* inner: clips image to rounded rect */}
<div style={{ borderRadius: 13, overflow: 'hidden', height: '100%', position: 'relative' }}>
<img
src={pod.cover}
alt={pod[lang].title}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
}}
/>
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: isFa
? 'linear-gradient(to left, rgba(13,33,55,0) 50%, rgba(13,33,55,0.85) 100%)'
: 'linear-gradient(to right, rgba(13,33,55,0) 50%, rgba(13,33,55,0.85) 100%)',
}} />
</div>
</div>
{/* ── text column ── */}
<div style={{
order: isFa ? 2 : 1,
display: 'flex', flexDirection: 'column',
padding: '36px 36px 28px',
}} className="max-lg:!px-6 max-lg:!py-6">
{/* ep badge */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8, padding: '4px 10px', marginBottom: 20, width: 'fit-content',
}}>
<span style={{ fontSize: 11, fontWeight: 900, color: GOLD }}>{pod.epNum}</span>
<span style={{ width: 1, height: 10, background: 'rgba(255,255,255,0.15)' }} />
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', fontWeight: 600 }}>{pod.date}</span>
</div>
{/* title */}
<h1 style={{
fontSize: 'clamp(17px,1.8vw,24px)', fontWeight: 900, color: '#fff',
lineHeight: 1.4, margin: '0 0 10px', letterSpacing: '-0.3px',
textAlign: isFa ? 'right' : 'left',
}}>{pod[lang].title}</h1>
{/* guest */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'rgba(205,158,83,0.12)', borderRadius: 6, padding: '4px 10px',
marginBottom: 16, width: 'fit-content',
}}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: GOLD }} />
<span style={{ fontSize: 12, color: GOLD, fontWeight: 700 }}>{pod[lang].guest}</span>
</div>
{/* body */}
<p style={{
fontSize: 12.5, lineHeight: 1.9, color: 'rgba(255,255,255,0.45)',
margin: '0 0 20px', textAlign: 'justify', flex: 1,
display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{pod[lang].body}</p>
{/* ── AUDIO PLAYER ── */}
<div style={{
background: 'rgba(0,0,0,0.25)', borderRadius: 16,
padding: '14px 16px', marginBottom: 20,
border: '1px solid rgba(255,255,255,0.07)',
}}>
<div style={{ height: 1, background: '#adadad', margin: '16px 0 4px' }} />
<div style={{ marginTop: 'auto' }}>
{pod.mediaUrl
? <audio controls preload="none" src={pod.mediaUrl} style={{ width: '100%', display: 'block' }} />
? <MediaWavePlayer src={pod.mediaUrl} episodeKey={pod.id} light />
: <WavePlayer episodeKey={pod.id} />}
</div>
{/* bottom: rating only */}
<div style={{ paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.07)' }}>
<span style={{ fontSize: 12, color: GOLD, fontWeight: 800, background: 'rgba(205,158,83,0.1)', padding: '3px 10px', borderRadius: 6 }}>
{pod.rating}
</span>
</div>
<div className="podcast-figma-cover" style={{ position: 'relative', background: NAVY }}>
<img src={pod.cover} alt={pod[lang].title} style={{ width: '100%', height: '100%', minHeight: 'inherit', objectFit: 'cover', display: 'block' }} />
</div>
</section>
<section className="podcast-figma-intro" style={{ marginTop: 54, color: NAVY, alignItems: 'start' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<Mic2 size={28} color={GOLD} />
<h2 style={{ margin: 0, fontSize: 24, lineHeight: 1.5 }}>{pod[lang].title}</h2>
</div>
<p style={{ margin: 0, color: '#989898', fontSize: 16, lineHeight: 2.05, textAlign: 'justify' }}>{pod[lang].body}</p>
{showPodGuest && (
<div style={{ marginTop: 18, fontSize: 16, color: NAVY, fontWeight: 700 }}>
{isFa ? 'مهمان برنامه: ' : 'Guest: '}<span style={{ color: GOLD }}>{pod[lang].guest}</span>
</div>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 14 }}>
{PODCASTS.filter((_, index) => index !== active).slice(0, 2).map((item) => {
const sourceIndex = PODCASTS.indexOf(item)
return (
<button key={item.id} type="button" onClick={() => setActive(sourceIndex)} style={{ border: 0, background: '#f9f9f9', borderRadius: 12, padding: 8, display: 'grid', gridTemplateColumns: isFa ? '1fr 132px' : '132px 1fr', gap: 18, alignItems: 'center', minHeight: 142, cursor: 'pointer', color: NAVY, textAlign: isFa ? 'right' : 'left', fontFamily: 'inherit' }}>
<div style={{ padding: '12px 14px' }}>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>{item[lang].title}</div>
<div style={{ color: '#606060', fontSize: 13 }}>{item.duration}</div>
</div>
<img src={item.cover} alt="" style={{ width: '100%', height: 126, borderRadius: 16, objectFit: 'cover', order: isFa ? 2 : 0 }} />
</button>
)
})}
</div>
</section>
</div>
</div>
@ -797,7 +848,9 @@ export default function Podcast() {
fontSize: 13.5, fontWeight: 800, color: NAVY, lineHeight: 1.45, marginBottom: 4,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{p[lang].title}</div>
{Boolean(p[lang].guest?.trim()) && (
<div style={{ fontSize: 11.5, color: '#666', fontWeight: 600 }}>{p[lang].guest}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 8, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
<span style={{ fontSize: 10.5, color: GOLD, fontWeight: 700 }}> {p.rating}</span>

View File

@ -0,0 +1,200 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Bookmark, CalendarDays, ChevronLeft, Mic2, Pause, Play, RotateCcw, RotateCw, Video, Volume2, VolumeX } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { authFetch, useAuth } from '@/context/AuthContext'
import { podcasts as PODCASTS, videos as VIDEOS } from '@/data/media'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const CSS = `
.media-page{background:#fff;color:#032340;padding:48px 0 96px}.media-shell{width:min(1132px,calc(100% - 40px));margin:auto}.media-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.media-heading h1,.media-heading h2{font-size:24px;font-weight:700;margin:0;border-inline-start:5px solid #CD9E53;padding-inline-start:13px}.media-view-all{display:inline-flex;align-items:center;gap:7px;color:#606060;font-size:14px}.video-stage{position:relative;width:100%;aspect-ratio:1.9/1;border-radius:16px;overflow:hidden;background:#061b2f}.video-stage img,.video-stage video{width:100%;height:100%;object-fit:cover;display:block}.video-stage video{object-fit:contain;background:#000}.video-overlay{position:absolute;inset:42% 0 0;background:linear-gradient(0deg,rgba(25,65,101,.92),transparent)}.video-play{position:absolute;inset:0;margin:auto;width:70px;height:70px;border:0;border-radius:50%;background:rgba(255,255,255,.9);color:#032340;display:grid;place-items:center;cursor:pointer;box-shadow:0 12px 36px rgba(0,0,0,.25)}.video-meta{position:absolute;inset:auto 28px 22px;display:flex;align-items:center;justify-content:space-between;color:#fff}.video-meta strong{font-size:16px}.video-meta span{font-size:13px;opacity:.86}.video-details{display:grid;grid-template-columns:491px minmax(0,1fr);gap:76px;align-items:start;margin:42px 0 78px}.playlist-title,.story-title{display:flex;align-items:center;justify-content:flex-start;gap:11px;font-size:23px;font-weight:700;margin-bottom:18px}.playlist{display:flex;flex-direction:column;gap:18px}.playlist-item{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) 266px;align-items:center;gap:14px;text-align:right;color:#032340}.playlist-copy strong{display:block;font-size:15px;line-height:1.65;margin-bottom:8px}.playlist-copy span{display:flex;align-items:center;gap:6px;color:#777;font-size:12px}.playlist-thumb{height:102px;border-radius:8px;overflow:hidden;position:relative}.playlist-thumb img{width:100%;height:100%;object-fit:cover}.playlist-thumb i{position:absolute;inset:0;margin:auto;width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.92);display:grid;place-items:center;color:#032340}.playlist-item.active strong{color:#CD9E53}.video-story p,.pod-story p{margin:0;color:#7c7c7c;font-size:15px;line-height:2.25;text-align:justify}.video-story .tags{color:#CD9E53;margin-top:12px}.podcast-section{padding-top:2px}.pod-feature{display:grid;grid-template-columns:minmax(0,1fr) 195px;background:#ededed;border-radius:16px;overflow:hidden;min-height:248px}.pod-feature-main{padding:25px 28px 18px;display:flex;flex-direction:column;min-width:0}.pod-topline{display:flex;justify-content:space-between;gap:20px}.pod-label{color:#CD9E53;font-size:16px;margin-bottom:9px}.pod-title{font-size:22px;line-height:1.55;margin:0;font-weight:700}.pod-date{display:flex;align-items:center;gap:6px;color:#777;font-size:12px;margin-top:8px}.bookmark-btn{width:38px;height:38px;border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.wave-wrap{margin-top:auto;border-top:1px solid #b6b6b6;padding-top:9px}.pod-cover{min-height:248px}.pod-cover img{width:100%;height:100%;object-fit:cover;display:block}.player-wave{height:58px;display:flex;align-items:center;gap:2px;direction:ltr;overflow:hidden;cursor:pointer}.player-wave span{flex:1 1 0;min-width:1px;max-width:5px;border-radius:9px;background:rgba(3,35,64,.35)}.player-wave span.played{background:#CD9E53}.player-controls{display:flex;align-items:center;gap:12px;direction:ltr;color:#032340;font-size:11px}.round-play{width:38px;height:38px;border:0;border-radius:50%;background:#CD9E53;color:#032340;display:grid;place-items:center;cursor:pointer}.mute{border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.pod-lower{display:grid;grid-template-columns:1fr 1fr;gap:76px;margin-top:30px}.pod-story-title{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:14px}.guest{margin-top:17px;color:#032340;font-size:13px}.guest b{color:#CD9E53}.episode-cards{display:flex;flex-direction:column;gap:14px}.episode-card{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) 166px;background:#f7f7f7;border-radius:12px;overflow:hidden;min-height:112px;color:#032340}.episode-card-copy{padding:15px 17px;text-align:right}.episode-card-copy small{color:#CD9E53;font-size:10px}.episode-card-copy strong{display:block;font-size:14px;line-height:1.65;margin:8px 0}.episode-card-copy span{font-size:11px;color:#888}.episode-card img{width:100%;height:100%;object-fit:cover}.media-empty{color:#888;font-size:13px}.media-page button:focus-visible{outline:3px solid rgba(205,158,83,.55);outline-offset:3px}
@media(max-width:800px){.media-page{padding-top:30px}.media-shell{width:min(100% - 28px,1132px)}.media-heading h1,.media-heading h2{font-size:20px}.video-stage{aspect-ratio:16/10}.video-details{grid-template-columns:1fr;gap:36px;margin:30px 0 58px}.playlist-item{grid-template-columns:minmax(0,1fr) 44%;}.playlist-thumb{height:88px}.pod-feature{grid-template-columns:1fr}.pod-cover{order:-1;min-height:240px;max-height:300px}.pod-feature-main{padding:20px 17px}.pod-lower{grid-template-columns:1fr;gap:30px}.pod-story{order:-1}.video-meta{inset:auto 14px 14px}.video-meta strong{font-size:13px}.pod-title{font-size:18px}.media-view-all{font-size:12px}.episode-card{grid-template-columns:minmax(0,1fr) 120px}}
@media(max-width:460px){.media-shell{width:calc(100% - 20px)}.media-heading{margin-bottom:14px}.video-stage{border-radius:11px}.video-play{width:52px;height:52px}.video-meta span{display:none}.video-details{margin-top:25px}.playlist-title,.story-title{font-size:19px}.playlist-item{grid-template-columns:1fr 132px;gap:10px}.playlist-copy strong{font-size:12px}.playlist-copy span{font-size:10px}.playlist-thumb{height:76px}.pod-feature{border-radius:12px}.pod-cover{min-height:210px}.player-wave{height:52px}.episode-card{grid-template-columns:1fr 105px}.episode-card-copy{padding:12px}.episode-card-copy strong{font-size:12px}.video-story p,.pod-story p{font-size:13px;line-height:2.1}}
.pod-feature{grid-template-columns:minmax(0,1fr) 248px}.pod-cover{width:248px;min-height:248px;aspect-ratio:1}.pod-cover img{aspect-ratio:1;object-fit:cover}
.pod-story .tags{color:#CD9E53;margin-top:12px;white-space:pre-wrap}
.tag-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.tag-chip{border:1px solid rgba(205,158,83,.42);background:rgba(205,158,83,.08);color:#9a6a1e;border-radius:999px;padding:5px 11px;font:inherit;font-size:12px;cursor:pointer;transition:background .15s,color .15s}.tag-chip:hover{background:#CD9E53;color:#032340}
.media-divider{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:16px;margin:0 0 58px}.media-divider::before,.media-divider::after{content:'';height:1px;background:linear-gradient(90deg,transparent,rgba(3,35,64,.2),rgba(205,158,83,.7))}.media-divider::after{background:linear-gradient(90deg,rgba(205,158,83,.7),rgba(3,35,64,.2),transparent)}.media-divider-mark{width:42px;height:42px;border:1px solid rgba(205,158,83,.5);border-radius:50%;display:grid;place-items:center;background:#fff;box-shadow:0 8px 24px rgba(3,35,64,.08);position:relative}.media-divider-mark::before{content:'';width:8px;height:8px;background:#CD9E53;transform:rotate(45deg);border-radius:2px}.media-divider-mark::after{content:'';position:absolute;inset:6px;border:1px solid rgba(3,35,64,.1);border-radius:50%}
.wave-wrap{width:calc(100% - 20px);margin-inline:10px}
.player-wave{width:100%}.player-wave span{max-width:none}
.skip-btn{position:relative;width:30px;height:30px;border:0;background:transparent;color:#032340;display:grid;place-items:center;cursor:pointer}.skip-btn small{position:absolute;font-size:8px;font-weight:900}
@media(max-width:800px){.pod-feature{grid-template-columns:1fr}.wave-wrap{width:calc(100% - 12px);margin-inline:6px}.pod-cover{width:min(100%,320px);min-height:0;aspect-ratio:1;margin-inline:auto}.pod-cover img{aspect-ratio:1}}
@media(max-width:600px){
.podcast-section .media-heading{margin-bottom:16px}
.pod-feature{display:flex;flex-direction:column;border-radius:16px;padding-top:18px}
.pod-cover{order:-1;width:168px;height:168px;min-height:168px;max-height:168px;margin:0 auto;border-radius:14px;overflow:hidden;box-shadow:0 10px 28px rgba(3,35,64,.14)}
.pod-cover img{width:168px;height:168px;aspect-ratio:1;object-fit:cover}
.pod-feature-main{padding:18px 18px 20px}
.pod-topline{align-items:flex-start;gap:8px}
.pod-label{font-size:13px;margin-bottom:6px}
.pod-title{font-size:17px;line-height:1.65}
.pod-date{font-size:10.5px;line-height:1.8;flex-wrap:wrap}
.bookmark-btn{width:34px;height:34px;flex:0 0 34px}
.wave-wrap{width:100%;margin-inline:0;margin-top:16px;padding-top:10px}
.player-wave{height:50px;gap:1.5px}
.player-controls{justify-content:center;gap:8px;flex-wrap:nowrap}
.pod-lower{margin-top:34px;gap:28px}
.pod-story-title{font-size:17px;line-height:1.7;margin-bottom:10px}
.episode-cards{gap:0;width:100%;align-items:stretch;border-top:1px solid rgba(3,35,64,.09)}
.episode-card{direction:ltr;width:100%;grid-template-columns:minmax(0,1fr) 132px;height:104px;min-height:104px;padding:13px 0;background:transparent;border:0;border-bottom:1px solid rgba(3,35,64,.09);border-radius:0;box-shadow:none;gap:12px}
.episode-card-copy{direction:rtl;text-align:right;padding:0;display:flex;flex-direction:column;justify-content:center;min-width:0}
.episode-card-copy small{font-size:9.5px}
.episode-card-copy strong{font-size:12px;line-height:1.65;margin:4px 0 6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.episode-card-copy span{font-size:9.5px;line-height:1.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#818995}
.episode-card img{grid-column:2;width:132px;height:78px;align-self:center;object-fit:cover;border-radius:9px;box-shadow:none}
.tag-list{gap:6px}.tag-chip{font-size:10.5px;padding:4px 9px}
}
`
function barsFor(seedValue: string | number, count = 82) {
let seed = String(seedValue).split('').reduce((a, c) => a + c.charCodeAt(0), 19)
return Array.from({ length: count }, (_, i) => {
seed = (seed * 9301 + 49297) % 233280
const envelope = .42 + .25 * Math.sin(i * .31) + .13 * Math.sin(i * .83)
return Math.max(.18, Math.min(.94, envelope + (seed / 233280) * .28))
})
}
function MediaDuration({ src, fallback = '', video = false, fa = true }: { src?: string; fallback?: string; video?: boolean; fa?: boolean }) {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
setSeconds(0)
if (!src) return
const media = document.createElement(video ? 'video' : 'audio')
media.preload = 'metadata'
const loaded = () => { if (Number.isFinite(media.duration)) setSeconds(media.duration); media.removeAttribute('src'); media.load() }
media.addEventListener('loadedmetadata', loaded, { once: true })
media.src = src
return () => { media.removeEventListener('loadedmetadata', loaded); media.removeAttribute('src'); media.load() }
}, [src, video])
if (!seconds) return fallback ? <>{fallback}</> : null
const total = Math.round(seconds)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = String(total % 60).padStart(2, '0')
const value = hours ? `${hours}:${String(minutes).padStart(2, '0')}:${secs}` : `${minutes}:${secs}`
return <>{fa ? value.replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]) : value}</>
}
function AudioWave({ src, id }: { src?: string; id: string | number }) {
const audio = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(false)
const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(0)
const bars = useMemo(() => barsFor(id), [id])
const progress = duration ? current / duration : 0
useEffect(() => { setPlaying(false); setCurrent(0); setDuration(0); audio.current?.load() }, [src, id])
const fmt = (v: number) => Number.isFinite(v) ? `${Math.floor(v / 60)}:${String(Math.floor(v % 60)).padStart(2, '0')}` : '0:00'
const toggle = async () => {
if (!audio.current || !src) return
if (audio.current.paused) await audio.current.play(); else audio.current.pause()
}
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!audio.current || !duration) return
const box = e.currentTarget.getBoundingClientRect()
audio.current.currentTime = Math.max(0, Math.min(1, (e.clientX - box.left) / box.width)) * duration
}
const skip = (seconds: number) => {
if (!audio.current) return
const end = duration || audio.current.duration || 0
audio.current.currentTime = Math.max(0, Math.min(end, audio.current.currentTime + seconds))
setCurrent(audio.current.currentTime)
}
return <div>
<audio ref={audio} src={src} preload="metadata" onLoadedMetadata={e => setDuration(Number.isFinite(e.currentTarget.duration) ? e.currentTarget.duration : 0)} onTimeUpdate={e => setCurrent(e.currentTarget.currentTime)} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
<div className="player-wave" onClick={seek} role="slider" aria-label="موقعیت پخش" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(current)} tabIndex={0}>
{bars.map((h, i) => <span key={i} className={(i + .5) / bars.length <= progress ? 'played' : ''} style={{ height: `${Math.round(h * 50)}px` }} />)}
</div>
<div className="player-controls">
<button className="skip-btn" onClick={() => skip(-10)} disabled={!src} aria-label="۱۰ ثانیه عقب"><RotateCcw size={21} /><small>۱۰</small></button>
<button className="round-play" onClick={toggle} disabled={!src} aria-label={playing ? 'توقف' : 'پخش'}>{playing ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}</button>
<button className="skip-btn" onClick={() => skip(10)} disabled={!src} aria-label="۱۰ ثانیه جلو"><RotateCw size={21} /><small>۱۰</small></button>
<b>{fmt(current)}</b><span>{fmt(duration)}</span>
<button className="mute" onClick={() => { if (!audio.current) return; audio.current.muted = !audio.current.muted; setMuted(audio.current.muted) }} aria-label="صدا">{muted ? <VolumeX size={17} /> : <Volume2 size={17} />}</button>
</div>
</div>
}
export default function PodcastFigma() {
const { lang } = useLang()
const { member } = useAuth()
const navigate = useNavigate()
const fa = lang === 'fa'
const [activeVideo, setActiveVideo] = useState(0)
const [videoPlaying, setVideoPlaying] = useState(false)
const [activePodcast, setActivePodcast] = useState(0)
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const video = VIDEOS[activeVideo]
const podcast = PODCASTS[activePodcast]
useEffect(() => {
if (!member) { setBookmarks(new Set()); return }
authFetch('/api/members/me/bookmarks').then(r => r.ok ? r.json() : []).then(items => {
if (Array.isArray(items)) setBookmarks(new Set(items.filter(x => x.contentType === 'podcast').map(x => String(x.contentId))))
}).catch(() => {})
}, [member])
const toggleBookmark = async () => {
if (!member) { navigate('/login'); return }
if (busy) return
const id = String(podcast.id), saved = bookmarks.has(id)
setBusy(true)
try {
const response = saved
? await authFetch(`/api/members/me/bookmarks/podcast/${encodeURIComponent(id)}`, { method: 'DELETE' })
: await authFetch('/api/members/me/bookmarks', { method: 'POST', body: JSON.stringify({ contentId: id, contentType: 'podcast', title: podcast[lang].title, summary: podcast[lang].body, coverImage: podcast.cover, path: '/podcast' }) })
if (response.ok) setBookmarks(current => { const next = new Set(current); saved ? next.delete(id) : next.add(id); return next })
} finally { setBusy(false) }
}
if (!video || !podcast) return <div className="media-page"><div className="media-shell media-empty">محتوایی برای نمایش وجود ندارد.</div></div>
return <main className="media-page" dir={fa ? 'rtl' : 'ltr'}>
<style>{CSS}</style>
<div className="media-shell">
<section>
<header className="media-heading"><h1>{fa ? 'ویدیوکست‌ها' : 'Videocasts'}</h1><span className="media-view-all">{fa ? 'مشاهده همه' : 'View all'} <ChevronLeft size={15} /></span></header>
<div className="video-stage">
{videoPlaying && video.mediaUrl ? <video src={video.mediaUrl} controls autoPlay onEnded={() => setVideoPlaying(false)} /> : <>
<img src={video.thumb} alt={video[lang].title} /><div className="video-overlay" />
<button className="video-play" onClick={() => video.mediaUrl && setVideoPlaying(true)} aria-label={fa ? 'پخش ویدیو' : 'Play video'}><Play size={28} fill="currentColor" /></button>
<div className="video-meta"><strong>{video[lang].title}</strong><span><MediaDuration src={video.mediaUrl} fallback={video.duration} video fa={fa} /></span></div>
</>}
</div>
<div className="video-details">
<article className="video-story"><div className="story-title">{video[lang].title}<Video size={27} strokeWidth={1.6} /></div>{video[lang].body && <p>{video[lang].body}</p>}{Boolean(video.tags?.length) && <div className="tag-list">{video.tags!.map(tag => <button type="button" className="tag-chip" key={tag} onClick={() => navigate(`/search?tag=${encodeURIComponent(tag)}`)}>#{tag.replace(/\s+/g, '_')}</button>)}</div>}</article>
<div>
<div className="playlist-title"><Video size={27} strokeWidth={1.6} />{fa ? 'پلی‌لیست' : 'Playlist'}</div>
<div className="playlist">{VIDEOS.slice(0, 3).map((item, index) => <button key={item.id} className={`playlist-item ${index === activeVideo ? 'active' : ''}`} onClick={() => { setActiveVideo(index); setVideoPlaying(false) }}>
<div className="playlist-copy"><strong>{item[lang].title}</strong><span><CalendarDays size={16} color={GOLD} />{item.date}{(item.mediaUrl || item.duration) && <> · <MediaDuration src={item.mediaUrl} fallback={item.duration} video fa={fa} /></>}</span></div>
<div className="playlist-thumb"><img src={item.thumb} alt="" /><i><Play size={18} fill="currentColor" /></i></div>
</button>)}</div>
</div>
</div>
</section>
<div className="media-divider" aria-hidden="true"><span className="media-divider-mark" /></div>
<section className="podcast-section">
<header className="media-heading"><h2>{fa ? 'پادکست‌ها' : 'Podcasts'}</h2><span className="media-view-all">{fa ? 'مشاهده همه' : 'View all'} <ChevronLeft size={15} /></span></header>
<div className="pod-feature">
<div className="pod-feature-main">
<div className="pod-topline"><div><div className="pod-label">{podcast.epNum}</div><h3 className="pod-title">{podcast[lang].title}</h3><div className="pod-date"><CalendarDays size={17} color={GOLD} />{podcast.date}{(podcast.mediaUrl || podcast.duration) && <> · <MediaDuration src={podcast.mediaUrl} fallback={podcast.duration} fa={fa} /></>}</div></div><button className="bookmark-btn" onClick={toggleBookmark} disabled={busy} aria-label="ذخیره"><Bookmark size={26} fill={bookmarks.has(String(podcast.id)) ? 'currentColor' : 'none'} color={bookmarks.has(String(podcast.id)) ? GOLD : NAVY} /></button></div>
<div className="wave-wrap"><AudioWave src={podcast.mediaUrl} id={podcast.id} /></div>
</div>
<div className="pod-cover"><img src={podcast.cover} alt={podcast[lang].title} /></div>
</div>
<div className="pod-lower">
<article className="pod-story"><div className="pod-story-title"><Mic2 size={23} color={GOLD} />{podcast[lang].title}</div>{podcast[lang].body && <p>{podcast[lang].body}</p>}{Boolean(podcast[lang].guest?.trim()) && <div className="guest">{fa ? 'مهمان برنامه:' : 'Guest:'} <b>{podcast[lang].guest}</b></div>}{Boolean(podcast.tags?.length) && <div className="tag-list">{podcast.tags!.map(tag => <button type="button" className="tag-chip" key={tag} onClick={() => navigate(`/search?tag=${encodeURIComponent(tag)}`)}>#{tag.replace(/\s+/g, '_')}</button>)}</div>}</article>
<div className="episode-cards">{PODCASTS.filter((_, i) => i !== activePodcast).slice(0, 2).map(item => <button className="episode-card" key={item.id} onClick={() => setActivePodcast(PODCASTS.findIndex(p => p.id === item.id))}><div className="episode-card-copy"><small>{item.epNum}</small><strong>{item[lang].title}</strong><span>{item.date}{(item.mediaUrl || item.duration) && <> · <MediaDuration src={item.mediaUrl} fallback={item.duration} fa={fa} /></>}</span></div><img src={item.cover} alt="" /></button>)}</div>
</div>
</section>
</div>
</main>
}

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth, authFetch } from '@/context/AuthContext'
import { useLang } from '@/context/LangContext'
import { User, CalendarDays, KeyRound, Receipt, Heart, ChevronLeft, LogOut, Mail, Phone, Link2, SquarePen, Lock, Briefcase, type LucideIcon } from 'lucide-react'
import { User, CalendarDays, KeyRound, Receipt, Heart, ChevronLeft, LogOut, Mail, Phone, Link2, SquarePen, Lock, Briefcase, Bookmark, Trash2, type LucideIcon } from 'lucide-react'
const GOLD = '#CD9E53'
const NAVY = '#032340'
@ -51,6 +51,8 @@ export default function ProfilePage() {
</>
) : section === 'password' ? (
<PasswordCard fa={fa} />
) : section === 'saved' ? (
<SavedItemsCard fa={fa} />
) : (
<Placeholder fa={fa} title={MENU.find(m => m.key === section)?.label || ''} />
)}
@ -104,6 +106,89 @@ export default function ProfilePage() {
)
}
type SavedBookmark = {
id: number
contentId: string
contentType: string
title: string
summary?: string
coverImage?: string
path: string
createdAt: string
}
function SavedItemsCard({ fa }: { fa: boolean }) {
const [items, setItems] = useState<SavedBookmark[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
let active = true
authFetch('/api/members/me/bookmarks')
.then(async response => {
if (!response.ok) throw new Error('bookmarks_failed')
return response.json()
})
.then(data => { if (active) setItems(Array.isArray(data) ? data : []) })
.catch(() => { if (active) setError(true) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
const remove = async (item: SavedBookmark) => {
const response = await authFetch(
`/api/members/me/bookmarks/${encodeURIComponent(item.contentType)}/${encodeURIComponent(item.contentId)}`,
{ method: 'DELETE' },
)
if (response.ok) setItems(current => current.filter(saved => saved.id !== item.id))
}
return (
<div style={card}>
<CardHead title={fa ? 'مطالب ذخیره‌شده' : 'Saved content'} />
{loading ? (
<div style={{ padding: '48px 0', textAlign: 'center', color: '#8a8f98', fontSize: 13.5 }}>
{fa ? 'در حال دریافت علاقه‌مندی‌ها…' : 'Loading saved content…'}
</div>
) : error ? (
<div style={{ padding: '48px 0', textAlign: 'center', color: '#b42318', fontSize: 13.5 }}>
{fa ? 'دریافت مطالب ذخیره‌شده ناموفق بود.' : 'Could not load saved content.'}
</div>
) : items.length === 0 ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Bookmark size={30} color={GOLD} style={{ marginBottom: 12 }} />
<div style={{ color: NAVY, fontWeight: 800, fontSize: 15 }}>{fa ? 'هنوز مطلبی ذخیره نکرده‌اید' : 'No saved content yet'}</div>
<div style={{ color: '#8a8f98', fontSize: 13, marginTop: 6 }}>{fa ? 'از دکمه نشانک کنار مطالب استفاده کنید.' : 'Use the bookmark button beside content.'}</div>
</div>
) : (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map(item => (
<article key={item.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: 12, borderRadius: 12, background: '#f7f8fa' }}>
{item.coverImage ? (
<img src={item.coverImage} alt="" style={{ width: 92, height: 68, objectFit: 'cover', borderRadius: 9, flexShrink: 0 }} />
) : (
<div style={{ width: 92, height: 68, borderRadius: 9, background: `${GOLD}1a`, display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<Bookmark size={20} color={GOLD} />
</div>
)}
<div style={{ flex: 1, minWidth: 0, textAlign: fa ? 'right' : 'left' }}>
<span style={{ color: GOLD, fontSize: 11, fontWeight: 800 }}>{item.contentType === 'podcast' ? (fa ? 'پادکست' : 'Podcast') : item.contentType}</span>
<Link to={item.path} style={{ display: 'block', color: NAVY, textDecoration: 'none', fontSize: 14.5, fontWeight: 850, marginTop: 3, lineHeight: 1.6 }}>
{item.title}
</Link>
{item.summary && <p style={{ margin: '3px 0 0', color: '#737985', fontSize: 12, lineHeight: 1.7, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.summary}</p>}
</div>
<button type="button" onClick={() => remove(item)} aria-label={fa ? 'حذف از علاقه‌مندی‌ها' : 'Remove bookmark'} style={{ width: 34, height: 34, borderRadius: '50%', border: 0, background: '#fff', color: '#b42318', display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}>
<Trash2 size={15} />
</button>
</article>
))}
</div>
)}
</div>
)
}
/* ═══ Identity card (read ↔ inline edit) ═══ */
function IdentityCard({ member, fa }: { member: { name: string; email: string; phone?: string }; fa: boolean }) {
const { updateMember } = useAuth()

View File

@ -1,9 +1,9 @@
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams, Navigate, useNavigate } 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_FUTURE, RADAR_SLUG_LABEL } from '@/content/radarFuture'
import { RADAR_FUTURE, RADAR_SLUG_LABEL, bootstrapRadarFuture } from '@/content/radarFuture'
import { bannerFor } from '@/data/banners'
import {
RADAR_CATEGORY_PAGES,
@ -16,6 +16,11 @@ const GOLD = '#CD9E53'
const PAPER = '#faf7f4'
const PER_PAGE = 12
const EASE = [0.22, 1, 0.36, 1] as const
const normalizeCategoryLabel = (value?: string) =>
String(value || '')
.trim()
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
/* ─── small reveal wrapper (animates in once on mount, never hides content) ─── */
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
@ -204,15 +209,25 @@ function SpecialBanner({ kicker, title, desc, lang, img, ctaUrl, ctaLabel }: { k
</div>
</div>
{/* RIGHT SIDE — CMS cover image or fallback mockup, keeping background and tilt */}
<div style={{ width: '48%', position: 'relative', minHeight: 240 }} className="max-md:!w-full max-md:!min-height-none max-md:!order-1">
<div style={{
position: 'absolute', top: -38, right: 30, width: '92%', height: 'auto', zIndex: 10,
transform: 'rotate(-4deg)', transformOrigin: 'bottom right',
filter: 'drop-shadow(0 20px 40px rgba(0,0,0,0.35))',
}} className="max-md:!static max-md:!w-full max-md:!rotate-0 max-md:!p-6">
<img src={img || BOOK_IMG} alt="" style={{ width: '100%', height: 'auto', display: 'block' }} />
</div>
{/* BOOK — matches the homepage banner sizing and responsive behavior */}
<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">
<img
src={img || 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"
/>
<img
src={img || 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>
@ -257,6 +272,8 @@ export default function RadarCategory() {
const { lang } = useLang()
const { category } = useParams<{ category: string }>()
const [page, setPage] = useState(1)
const [items, setItems] = useState(() => [...RADAR_FUTURE])
const [loading, setLoading] = useState(() => RADAR_FUTURE.length === 0)
const navigate = useNavigate()
const cfg = category ? RADAR_CATEGORY_PAGES[category as RadarSlug] : undefined
@ -264,8 +281,31 @@ export default function RadarCategory() {
const c = cfg[lang]
useEffect(() => {
let cancelled = false
setLoading(true)
bootstrapRadarFuture().finally(() => {
if (cancelled) return
setItems([...RADAR_FUTURE])
setLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
setPage(1)
}, [category])
// card pool = رادار آینده block articles whose category == this sub-menu label
const base = RADAR_FUTURE.filter((r) => r.category === RADAR_SLUG_LABEL[category as string])
const base = useMemo(
() => {
const label = normalizeCategoryLabel(RADAR_SLUG_LABEL[category as string])
return items.filter((r) => normalizeCategoryLabel(r.category) === label)
},
[category, items],
)
if (!base.length) {
return (
@ -273,14 +313,18 @@ export default function RadarCategory() {
<div className="max-w-7xl mx-auto px-12 max-md:!px-4" style={{ paddingTop: 40, paddingBottom: 72 }}>
<Breadcrumb label={c.label} lang={lang} />
<p style={{ color: NAVY, fontSize: 15, opacity: 0.7, padding: '40px 0' }}>
{lang === 'fa' ? 'هنوز مطلبی برای این بخش منتشر نشده است.' : 'No articles published in this section yet.'}
{loading
? (lang === 'fa' ? 'در حال بارگذاری مطالب رادار آینده…' : 'Loading Future Radar articles…')
: (lang === 'fa' ? 'هنوز مطلبی برای این بخش منتشر نشده است.' : 'No articles published in this section yet.')}
</p>
</div>
</div>
)
}
const seed = base
const cards = seed
// CMS `featured` controls the large hero card. If a category has no featured
// article yet, use its newest CMS article instead of static page content.
const featured = base.find((item) => item.featured) ?? base[0]
const cards = base.filter((item) => item.id !== featured.id)
// latest 2 mini-cards
const latest = cards.slice(0, 2)
@ -306,13 +350,19 @@ export default function RadarCategory() {
<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} onClick={() => navigate(`/radar/post/${seed[0]?.id ?? 'r1'}`)} />
<FeaturedCard
img={featured.img}
title={featured[lang].title}
date={featured[lang].date}
lang={lang}
onClick={() => navigate(`/radar/post/${featured.id}`)}
/>
{/* 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, idx) => (
<MiniCard key={item.id} img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${seed[idx]?.id ?? seed[0]?.id ?? 'r1'}`)} />
{latest.map((item) => (
<MiniCard key={item.id} img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
))}
</div>
</div>
@ -321,29 +371,31 @@ export default function RadarCategory() {
{/* ── 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, idx) => (
{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} onClick={() => navigate(`/radar/post/${seed[idx % seed.length].id}`)} />
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
</Reveal>
))}
</div>
{/* ── SPECIAL BANNER (CMS banner for this radar page, else page default) ── */}
{/* The special-analysis banner is intentionally limited to sustainability. */}
{category === 'sustainability' && (
<Reveal>
{(() => {
const cms = bannerFor('radar-' + category)
const cms = bannerFor('radar-sustainability')
const bk = cms
? { kicker: cms[lang].overline || c.banner.kicker, title: cms[lang].title || c.banner.title, desc: cms[lang].subtitle || c.banner.desc, img: cms.image, ctaUrl: cms.ctaUrl, ctaLabel: cms[lang].cta }
: { kicker: c.banner.kicker, title: c.banner.title, desc: c.banner.desc }
return <SpecialBanner {...bk} 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, idx) => (
{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} onClick={() => navigate(`/radar/post/${seed[(idx + 4) % seed.length].id}`)} />
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} onClick={() => navigate(`/radar/post/${item.id}`)} />
</Reveal>
))}
</div>

View File

@ -1,11 +1,11 @@
import { useState, type ReactElement } from 'react'
import { useEffect, useState, type ReactElement } from 'react'
import { useParams, Navigate, Link } from 'react-router-dom'
import AuthPage from '@/pages/Auth/AuthPage'
import { motion } from 'framer-motion'
import { Calendar, Tag, UserPlus, LogIn, Send, Share2, Globe, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { useAuth } from '@/context/AuthContext'
import { RADAR_FUTURE, type RadarFutureArticle, type RFBlock } from '@/content/radarFuture'
import { RADAR_FUTURE, bootstrapRadarFuture, type RadarFutureArticle, type RFBlock } from '@/content/radarFuture'
import { MagicTextGroup } from '@/components/ui/magic-text'
const NAVY = '#032340'
@ -144,13 +144,36 @@ export default function RadarPost() {
const { lang } = useLang()
const { id } = useParams<{ id: string }>()
const isFa = lang === 'fa'
const [items, setItems] = useState(() => [...RADAR_FUTURE])
const [loading, setLoading] = useState(() => RADAR_FUTURE.length === 0)
const item = RADAR_FUTURE.find(r => r.id === id)
useEffect(() => {
let cancelled = false
setLoading(true)
bootstrapRadarFuture().finally(() => {
if (cancelled) return
setItems([...RADAR_FUTURE])
setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const item = items.find(r => r.id === id)
const { member } = useAuth()
const loggedIn = member !== null
const [showAuth, setShowAuth] = useState(false)
const handleAuthSuccess = () => setShowAuth(false)
if (!item && loading) {
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ minHeight: '70vh', display: 'grid', placeItems: 'center', color: NAVY, fontWeight: 800 }}>
{isFa ? 'در حال بارگذاری مطلب…' : 'Loading article…'}
</div>
)
}
if (!item) return <Navigate to="/radar" replace />
const content = item[lang]
@ -220,7 +243,7 @@ export default function RadarPost() {
let run: string[] = []
const flush = (k: string) => {
if (!run.length) return
out.push(<MagicTextGroup key={k} paragraphs={run} dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', fontWeight: 500 }} />)
out.push(<MagicTextGroup key={k} paragraphs={run} dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', textAlign: 'justify', fontWeight: 500 }} />)
run = []
}
blocks.forEach((b, i) => {
@ -250,7 +273,7 @@ export default function RadarPost() {
</article>
{/* RELATED SIDEBAR */}
<RelatedSidebar items={RADAR_FUTURE} currentId={item.id} lang={lang} />
<RelatedSidebar items={items} currentId={item.id} lang={lang} />
</div>
</div>
</div>

View File

@ -11,7 +11,20 @@ import { events } from '@/data/events'
const GOLD = '#CD9E53'
const NAVY = '#032340'
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string }
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string; tags: string[] }
function contentTags(value: unknown, isFa: boolean): string[] {
const tags = (value as { tags?: unknown })?.tags
if (!Array.isArray(tags)) return []
return tags.map(tag => {
if (typeof tag === 'string') return tag
if (tag && typeof tag === 'object') {
const localized = tag as { fa?: unknown; en?: unknown }
return String(isFa ? localized.fa ?? localized.en ?? '' : localized.en ?? localized.fa ?? '')
}
return ''
}).map(tag => tag.replace(/^#/, '').trim()).filter(Boolean)
}
/* Aggregate every searchable content source into one flat, normalized list.
All of these are already bootstrapped from the panel into memory at startup,
@ -21,30 +34,30 @@ function buildIndex(isFa: boolean): Result[] {
const L = (o: { fa: string; en: string }) => (isFa ? o.fa : o.en)
for (const r of reports) {
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}` })
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}`, tags: contentTags(r, isFa) })
}
for (const p of POSTS) {
const t = isFa ? p.fa : p.en
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}` })
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}`, tags: contentTags(p, isFa) })
}
for (const it of RADAR_ITEMS) {
const t = isFa ? it.fa : it.en
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}` })
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}`, tags: contentTags(it, isFa) })
}
for (const it of RADAR_FUTURE) {
const t = isFa ? it.fa : it.en
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}` })
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}`, tags: contentTags(it, isFa) })
}
for (const v of videos) {
const t = isFa ? v.fa : v.en
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast' })
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast', tags: contentTags(v, isFa) })
}
for (const p of podcasts) {
const t = isFa ? p.fa : p.en
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast' })
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast', tags: contentTags(p, isFa) })
}
for (const e of events) {
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events' })
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events', tags: contentTags(e, isFa) })
}
return out
}
@ -54,13 +67,15 @@ export default function SearchResults() {
const isFa = lang === 'fa'
const [params] = useSearchParams()
const q = (params.get('q') || '').trim()
const tag = (params.get('tag') || '').replace(/^#/, '').trim()
const results = useMemo(() => {
if (!q) return []
const needle = q.toLowerCase()
if (!q && !tag) return []
const needle = (tag || q).toLowerCase()
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(needle)
return buildIndex(isFa).filter((r) => has(r.title) || has(r.summary) || has(r.meta))
}, [q, isFa])
const sameTag = (value: string) => value.replace(/^#/, '').trim().toLowerCase() === needle
return buildIndex(isFa).filter((r) => tag ? r.tags.some(sameTag) : has(r.title) || has(r.summary) || has(r.meta) || r.tags.some(has))
}, [q, tag, isFa])
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ maxWidth: 1000, margin: '0 auto', padding: 'clamp(24px,5vw,56px) clamp(16px,4vw,40px)', minHeight: '60vh' }}>
@ -68,16 +83,16 @@ export default function SearchResults() {
<h1 style={{ fontSize: 24, fontWeight: 900, color: NAVY, margin: 0 }}>
{isFa ? 'نتایج جستجو' : 'Search results'}
</h1>
{q && (
{(q || tag) && (
<p style={{ fontSize: 14, color: 'var(--ink-4)', margin: '8px 0 0' }}>
{isFa
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${q}»`
: `${results.length} results for “${q}`}
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${tag ? `#${tag}` : q}»`
: `${results.length} results for “${tag ? `#${tag}` : q}`}
</p>
)}
</div>
{!q ? (
{!q && !tag ? (
<p style={{ color: 'var(--ink-5)', fontSize: 15 }}>{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}</p>
) : results.length === 0 ? (
<div style={{ padding: '64px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
@ -99,6 +114,7 @@ export default function SearchResults() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff', background: r.color, borderRadius: 6, padding: '2px 8px' }}>{r.badge}</span>
{r.meta && <span style={{ fontSize: 11, color: 'var(--ink-5)' }}>{r.meta}</span>}
{r.tags.map(item => <span key={item} style={{ fontSize: 10.5, color: GOLD }}>#{item}</span>)}
</div>
<h3 style={{ fontSize: 16, fontWeight: 800, color: NAVY, margin: '0 0 4px' }}>{r.title}</h3>
{r.summary && (

View File

@ -1,76 +1,70 @@
import { useState } from 'react'
import { useState, type CSSProperties } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Eye, Globe, Factory, Target, type LucideIcon } from 'lucide-react'
import { Eye, Globe, Factory, type LucideIcon } from 'lucide-react'
import WorldSection from './sections/WorldSection'
import IndustrySection from './sections/IndustrySection'
import SelfSection from './sections/SelfSection'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const SECTIONS = [
{ id: 'world', titleFa: 'دیدن جهان', subtitleFa: 'روندها · سیاست‌ها · بازار', accentColor: '#1a6bcf' },
{ id: 'industry', titleFa: 'دیدن صنعت', subtitleFa: 'فولاد · رقبا · قیمت‌ها', accentColor: '#b8893d' },
{ id: 'self', titleFa: 'دیدن خود', subtitleFa: 'استراتژی · پروژه‌ها · عملکرد', accentColor: NAVY },
]
{ id: 'world', titleFa: 'دیدن جهان', subtitleFa: 'روندها · سیاست‌ها · بازار', accentColor: '#1a6bcf', Icon: Globe, component: WorldSection },
{ id: 'industry', titleFa: 'دیدن صنعت', subtitleFa: 'فولاد · رقبا · قیمت‌ها', accentColor: '#b8893d', Icon: Factory, component: IndustrySection },
] as const
const DOCK_ICONS: Record<string, LucideIcon> = { world: Globe, industry: Factory, self: Target }
type SectionId = (typeof SECTIONS)[number]['id']
const SECTION_COMPONENTS = [WorldSection, IndustrySection, SelfSection]
/* ── right-side dock button with a hover label ── */
function DockButton({ Icon, label, color, active, onClick }: { Icon: LucideIcon; label: string; color: string; active: boolean; onClick: () => void }) {
const [hover, setHover] = useState(false)
function SwitcherOption({
id,
option,
label,
Icon,
activeSectionId,
onChange,
}: {
id: SectionId
option: string
label: string
Icon: LucideIcon
activeSectionId: SectionId
onChange: (id: SectionId) => void
}) {
const customAttrs = { 'c-option': option } as Record<string, string>
return (
<div
style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<AnimatePresence>
{hover && (
<motion.span
initial={{ opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 6 }}
transition={{ duration: 0.15 }}
style={{ position: 'absolute', right: 'calc(100% + 10px)', whiteSpace: 'nowrap', background: '#032340', color: '#fff', fontSize: 12, fontWeight: 700, padding: '5px 11px', borderRadius: 8, boxShadow: '0 6px 18px rgba(3,35,64,0.3)', pointerEvents: 'none' }}
>
{label}
</motion.span>
)}
</AnimatePresence>
<button
onClick={onClick}
<label className="steel-switcher__option" title={label} aria-label={label}>
<input
{...customAttrs}
className="steel-switcher__input"
type="radio"
name="steel-dashboard-section"
checked={activeSectionId === id}
onChange={() => onChange(id)}
title={label}
aria-label={label}
style={{
width: 44, height: 44, borderRadius: 13, border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: active ? color : hover ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.1)',
color: active ? '#fff' : 'rgba(255,255,255,0.78)',
transition: 'background 180ms, transform 180ms, box-shadow 180ms',
transform: hover ? 'scale(1.08)' : 'scale(1)',
boxShadow: active ? `0 6px 18px ${color}66` : 'none',
}}
>
<Icon size={20} strokeWidth={2} />
</button>
</div>
/>
<Icon className="steel-switcher__icon" size={20} strokeWidth={2.2} />
</label>
)
}
/* ── Page ── */
export default function SteelDashboard() {
const [activeSectionId, setActiveSectionId] = useState('world')
const [activeSectionId, setActiveSectionId] = useState<SectionId>('world')
const [previousOption, setPreviousOption] = useState('1')
const activeIdx = SECTIONS.findIndex(s => s.id === activeSectionId)
const activeSection = SECTIONS.find(s => s.id === activeSectionId) ?? SECTIONS[0]
const ActiveComponent = activeSection.component
const switcherAttrs = { 'c-previous': previousOption } as Record<string, string>
const selectSection = (id: string) => {
const selectSection = (id: SectionId) => {
const currentOption = String(SECTIONS.findIndex(s => s.id === activeSectionId) + 1)
setPreviousOption(currentOption)
setActiveSectionId(id)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div dir="rtl" style={{ minHeight: '100vh', background: '#f3f4f6', fontFamily: 'Vazir, sans-serif' }}>
<div dir="rtl" className="steel-dashboard-page" style={{ minHeight: '100vh', background: '#f3f4f6', fontFamily: 'Vazir, sans-serif' }}>
{/* page header */}
<div style={{ background: NAVY, padding: '28px 0 24px' }}>
@ -88,7 +82,7 @@ export default function SteelDashboard() {
STEEL FORESIGHT · DASHBOARD
</div>
<h1 style={{ fontSize: 'clamp(16px,2.2vw,26px)', fontWeight: 900, color: '#fff', margin: 0 }}>
سامانه هوشمند رصد فولاد مبارکه
سامانه هوشمند رصد فولاد آینده
</h1>
</div>
<div style={{
@ -108,7 +102,7 @@ export default function SteelDashboard() {
{/* section content — animated on switch */}
<div
className="max-w-7xl mx-auto px-12 max-md:px-5"
style={{ paddingTop: 40, paddingBottom: 60 }}
style={{ paddingTop: 40, paddingBottom: 60, paddingRight: 'max(3rem, 72px)' }}
>
<AnimatePresence mode="wait">
<motion.div
@ -118,32 +112,29 @@ export default function SteelDashboard() {
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
{(() => { const C = SECTION_COMPONENTS[activeIdx]; return C ? <C /> : null })()}
<ActiveComponent />
</motion.div>
</AnimatePresence>
</div>
{/* right-side dock */}
<div style={{
position: 'fixed', right: 16, top: '50%', transform: 'translateY(-50%)',
zIndex: 100, display: 'flex', flexDirection: 'column', gap: 22,
padding: '18px 8px', borderRadius: 20,
background: 'rgba(3,35,64,0.5)',
backdropFilter: 'blur(18px) saturate(160%)', WebkitBackdropFilter: 'blur(18px) saturate(160%)',
border: '1px solid rgba(255,255,255,0.22)',
boxShadow: '0 18px 50px rgba(3,35,64,0.35), inset 0 1px 0 rgba(255,255,255,0.18)',
}}>
{SECTIONS.map((s) => (
<DockButton
<fieldset
{...switcherAttrs}
className="steel-switcher"
style={{ '--steel-active': activeSection.accentColor } as CSSProperties}
>
<legend className="steel-switcher__legend">بخشهای داشبورد</legend>
{SECTIONS.map((s, index) => (
<SwitcherOption
key={s.id}
Icon={DOCK_ICONS[s.id]}
id={s.id}
option={String(index + 1)}
label={s.titleFa}
color={s.accentColor}
active={activeSectionId === s.id}
onClick={() => selectSection(s.id)}
Icon={s.Icon}
activeSectionId={activeSectionId}
onChange={selectSection}
/>
))}
</div>
</fieldset>
</div>
)
}

View File

@ -18,6 +18,51 @@ const injectedCss = `
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
@media (max-width: 560px) {
.industry-section { padding: 20px 0 !important; }
.industry-masthead {
align-items: flex-start !important;
flex-direction: column !important;
gap: 8px !important;
margin-bottom: 14px !important;
}
.industry-masthead-main { width: 100%; align-items: center !important; gap: 9px !important; }
.industry-masthead-kicker { display: none; }
.industry-masthead-title { font-size: 18px !important; }
.industry-masthead-source { width: 100%; font-size: 10px !important; line-height: 1.8; }
.industry-price-table { display: none; }
.industry-price-mobile { display: grid !important; gap: 10px; padding: 10px; background: #f8fafc; }
.industry-market-context { display: grid !important; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.industry-market-context > div { padding: 9px 10px !important; border-left: 0 !important; border-bottom: 1px solid #e2e6eb; }
.industry-competitor-columns { display: none !important; }
.industry-competitor-row { flex-wrap: wrap !important; gap: 8px !important; padding: 12px !important; }
.industry-competitor-rank { display: none !important; }
.industry-competitor-company { flex: 1 1 150px !important; }
.industry-competitor-output { flex: 1 1 100% !important; order: 4; }
.industry-competitor-growth,
.industry-competitor-tech,
.industry-competitor-use { flex: 1 1 auto !important; }
.risk-grid { grid-template-columns: 1fr !important; }
.industry-risk-scatter { border-left: 0 !important; border-bottom: 1px solid #eef2f6; padding: 8px 10px 14px !important; overflow: visible; }
.industry-risk-chart {
display: none !important;
}
.industry-risk-mobile-map { display: grid !important; }
.industry-risk-mobile-axes { display: flex !important; }
.industry-risk-tooltip {
position: static !important;
inset: auto !important;
width: 100% !important;
max-width: none !important;
margin-top: 12px;
box-sizing: border-box;
overflow-wrap: anywhere;
word-break: normal;
white-space: normal !important;
text-align: right;
}
.industry-risk-tooltip * { max-width: 100%; white-space: normal !important; }
}
`;
if (typeof document !== 'undefined' && !document.getElementById('industry-css')) {
const tag = document.createElement('style');
@ -28,10 +73,12 @@ if (typeof document !== 'undefined' && !document.getElementById('industry-css'))
// ─── Sparkline data ────────────────────────────────────────────────────────────
const sparkData: Record<string, number[]> = {
'HRC (آمریکا)': [950, 920, 900, 875, 862],
'سنگ آهن ۶۲٪ Fe': [114, 108, 103, 98, 101],
'قراضه HMS': [365, 370, 378, 382, 385],
'زغال کُک': [238, 220, 205, 195, 200],
'HRC (آمریکا)': [830, 950, 1040, 1100, 1100],
'HRC/فولاد چین': [466, 462, 456, 452, 450],
'سنگ آهن ۶۲٪ Fe': [105, 102, 100, 98.57, 98.72],
'قراضه HMS': [386, 390, 396, 396, 396],
'زغال‌سنگ Newcastle': [151, 145, 136, 129.95, 128.6],
'نفت خام (Brent)': [75.5, 76.0, 76.1, 79.3, 79.45],
};
function Spark({ data, color }: { data: number[]; color: string }) {
@ -72,46 +119,46 @@ interface PriceRow {
const prices: PriceRow[] = [
{
product: 'HRC (آمریکا)', price: '$862/t',
daily: null, monthly: '+12٪', yearly: '+8٪',
source: 'Platts', upM: true, upY: true,
weekLow: 780, weekHigh: 970, current: 862,
note: 'تقاضای ساختمانی آمریکا قوی؛ تعرفه ۲۵٪ از واردات حمایت می‌کند',
product: 'HRC (آمریکا)', price: '~$1,100/t',
daily: null, monthly: null, yearly: '+۳۰٪',
source: 'Barrons / CRU proxy', upY: true,
weekLow: 700, weekHigh: 1200, current: 1100,
note: 'شاخص HRC آمریکا در گزارش‌های عمومی بازار حوالی ۱۱۰۰ دلار/تن گزارش شده؛ تعرفه‌ها و افت واردات از قیمت داخلی حمایت می‌کند',
},
{
product: 'HRC (چین)', price: '$517/t',
daily: '-0.4٪', monthly: '-6٪', yearly: '-14٪',
source: 'MySteel', upD: false, upM: false, upY: false,
weekLow: 490, weekHigh: 620, current: 517,
note: 'اضافه‌عرضه داخلی؛ صادرات رکورد ۱۱۷Mt فشار نزولی دارد',
product: 'HRC/فولاد چین', price: '~$450/t',
daily: '-۰.۴۶٪', monthly: '-۲.۸۶٪', yearly: '-۰.۹۷٪',
source: 'Trading Economics / SHFE', upD: false, upM: false, upY: false,
weekLow: 420, weekHigh: 540, current: 450,
note: 'شاخص عمومی فولاد چین ۳۰۵۵ یوان/تن است؛ به‌عنوان proxy برای فشار قیمت فولاد تخت چین استفاده شده، نه quote رسمی Platts HRC',
},
{
product: 'سنگ آهن ۶۲٪ Fe', price: '$101.1/t',
daily: '-0.1٪', monthly: '-8.3٪', yearly: '+6.7٪',
source: 'SGX', upD: false, upM: false, upY: true,
weekLow: 92, weekHigh: 140, current: 101,
note: 'کاهش تولید چدن در چین؛ بازیابی تقاضا هند',
product: 'سنگ آهن ۶۲٪ Fe', price: '$98.72/t',
daily: '+۰.۱۵٪', monthly: '-۲.۸۳٪', yearly: '+۲.۰۸٪',
source: 'Trading Economics', upD: true, upM: false, upY: true,
weekLow: 92, weekHigh: 140, current: 98.72,
note: 'سنگ‌آهن ۶۲٪ CFR چین حوالی ۹۸.۷۲ دلار/تن؛ افت ماهانه ادامه دارد اما نسبت به سال قبل کمی بالاتر است',
},
{
product: 'قراضه HMS', price: '$385/t',
daily: null, monthly: '-5٪', yearly: '+12.6٪',
source: 'Metaloop', upM: false, upY: true,
weekLow: 340, weekHigh: 410, current: 385,
note: 'تقاضای EAF اروپا و ترکیه؛ محدودیت صادرات آمریکا',
product: 'قراضه HMS', price: '~$396/t',
daily: null, monthly: null, yearly: '+۳٪',
source: 'SDI / market proxy', upY: true,
weekLow: 340, weekHigh: 430, current: 396,
note: 'عدد قراضه به‌صورت proxy از هزینه متوسط قراضه گزارش‌شده توسط Steel Dynamics استفاده شده؛ برای HMS ترکیه quote آزاد قابل اتکا در دسترس عمومی نبود',
},
{
product: 'زغال کُک', price: '$200/t',
daily: null, monthly: null, yearly: null,
source: 'IEA',
weekLow: 175, weekHigh: 260, current: 200,
note: 'ذخایر استرالیا کافی؛ تقاضای آسیا تثبیت شده',
product: 'زغال‌سنگ Newcastle', price: '$128.60/t',
daily: '-۱.۰۴٪', monthly: '-۱۵.۲۶٪', yearly: '+۱۴.۸۲٪',
source: 'Trading Economics / ICE', upD: false, upM: false, upY: true,
weekLow: 110, weekHigh: 180, current: 128.6,
note: 'این ردیف به شاخص زغال‌سنگ حرارتی Newcastle تغییر داده شد؛ قیمت زغال کک‌شو premium معمولاً پشت Fastmarkets/Platts است',
},
{
product: 'نفت خام (Brent)', price: '$80/bbl',
daily: null, monthly: null, yearly: null,
source: 'ICE',
weekLow: 70, weekHigh: 95, current: 80,
note: 'تاثیر غیرمستقیم از طریق هزینه انرژی و حمل‌ونقل',
product: 'نفت خام (Brent)', price: '$79.45/bbl',
daily: '+۴.۵۲٪', monthly: '-۴.۴۷٪', yearly: '+۱۴.۷۹٪',
source: 'Trading Economics / ICE', upD: true, upM: false, upY: true,
weekLow: 65, weekHigh: 105, current: 79.45,
note: 'برنت با ریسک تنگه هرمز و تنش آمریکا و ایران جهش روزانه داشته؛ اثر آن روی هزینه انرژی، حمل و نرخ‌های منطقه‌ای غیرمستقیم است',
},
];
@ -308,7 +355,7 @@ function PricesModule() {
useEffect(() => {
const tick = () => {
const now = new Date();
setTimestamp(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`);
setTimestamp(faDigits(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`));
};
const id = setInterval(tick, 60_000);
return () => clearInterval(id);
@ -353,7 +400,7 @@ function PricesModule() {
</span>
<span style={{ color: '#22c55e', fontSize: 14, animation: 'livePulse 2s ease-in-out infinite' }}></span>
<span style={{ color: '#64748b', fontSize: 11, fontFamily: "Vazir, sans-serif" }}>
زنده · ۲۰ ژوئن ۲۰۲۵
زنده · ۲۳ تیر ۱۴۰۵ / ۱۳ ژوئیه ۲۰۲۶
</span>
</div>
</div>
@ -395,7 +442,7 @@ function PricesModule() {
</div>
{/* Price table */}
<motion.div style={{ overflowX: 'auto' }} viewport={{ once: true, margin: '-40px' }} onViewportEnter={revealRows}>
<motion.div className="industry-price-table" style={{ overflowX: 'auto' }} viewport={{ once: true, margin: '-40px' }} onViewportEnter={revealRows}>
<table style={{ width: '100%', borderCollapse: 'collapse', direction: 'rtl' }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
@ -534,8 +581,38 @@ function PricesModule() {
</table>
</motion.div>
<div className="industry-price-mobile" style={{ display: 'none' }}>
{prices.map((row) => {
const isExpanded = expandedPrice.has(row.product)
return (
<article
key={`mobile-${row.product}`}
onClick={() => setExpandedPrice((prev) => { const next = new Set(prev); if (next.has(row.product)) next.delete(row.product); else next.add(row.product); return next })}
style={{ background: isExpanded ? '#eff6ff' : '#fff', border: `1px solid ${isExpanded ? '#bfdbfe' : '#e2e8f0'}`, borderRadius: 12, padding: 12, cursor: 'pointer' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: NAVY, fontSize: 13, fontWeight: 900, lineHeight: 1.7 }}>{row.product}</div>
<div style={{ color: '#94a3b8', fontSize: 10, marginTop: 3 }}>{row.source}</div>
</div>
<div style={{ fontFamily: 'Vazir, sans-serif', fontSize: 15, fontWeight: 900, color: NAVY, whiteSpace: 'nowrap' }}>{faDigits(row.price)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 7, marginTop: 12 }}>
{[['روزانه', row.daily, row.upD], ['ماهانه', row.monthly, row.upM], ['سالانه', row.yearly, row.upY]].map(([label, value, up]) => (
<div key={String(label)} style={{ background: '#f8fafc', borderRadius: 8, padding: 7, textAlign: 'center' }}>
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>{label}</div>
<ChangeCell val={value as string | null} up={up as boolean | undefined} />
</div>
))}
</div>
{isExpanded && <p style={{ margin: '12px 0 0', paddingTop: 10, borderTop: '1px solid #bfdbfe', color: '#334155', fontSize: 11, lineHeight: 1.85 }}>{faDigits(row.note)}</p>}
</article>
)
})}
</div>
{/* Market context rows */}
<div style={{ background: '#f8fafc', borderTop: '1px solid #e2e6eb', display: 'flex', direction: 'rtl' }}>
<div className="industry-market-context" style={{ background: '#f8fafc', borderTop: '1px solid #e2e6eb', display: 'flex', direction: 'rtl' }}>
{marketContext.map((ctx, i) => (
<div
key={ctx.label}
@ -670,7 +747,7 @@ function CompetitorsModule() {
</div>
{/* column hints */}
<div style={{ padding: '7px 16px', display: 'flex', gap: 12, direction: 'rtl', fontSize: 9, fontWeight: 700, letterSpacing: 0.5, color: '#94a3b8', borderBottom: '1px solid #eef2f6', alignItems: 'center' }}>
<div className="industry-competitor-columns" style={{ padding: '7px 16px', display: 'flex', gap: 12, direction: 'rtl', fontSize: 9, fontWeight: 700, letterSpacing: 0.5, color: '#94a3b8', borderBottom: '1px solid #eef2f6', alignItems: 'center' }}>
<span style={{ width: 26, textAlign: 'center' }}>#</span>
<span style={{ flex: '0 0 172px' }}>شرکت</span>
<span style={{ flex: 1 }}>تولید فولاد خام ۲۰۲۴ (Mt)</span>
@ -708,9 +785,9 @@ function CompetitorsModule() {
}}
>
{c.isUs && <BorderBeam duration={7} size={120} borderWidth={2} colorFrom="#CD9E53" colorTo="#032340" />}
<div style={{ padding: '11px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 26, textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 12, color: c.isUs ? '#CD9E53' : '#94a3b8' }}>{faN(c.rank)}</span>
<div style={{ flex: '0 0 172px', display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div className="industry-competitor-row" style={{ padding: '11px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="industry-competitor-rank" style={{ width: 26, textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 12, color: c.isUs ? '#CD9E53' : '#94a3b8' }}>{faN(c.rank)}</span>
<div className="industry-competitor-company" style={{ flex: '0 0 172px', display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<CompanyLogo name={c.name} flag={c.flag} size={36} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 800, fontSize: 15, color: NAVY, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.2px', lineHeight: 1.25 }}>
@ -719,24 +796,24 @@ function CompetitorsModule() {
<div style={{ fontSize: 10, color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}><span style={{ fontSize: 11 }}>{c.flag}</span>{c.country}</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8, direction: 'ltr' }}>
<div className="industry-competitor-output" style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8, direction: 'ltr' }}>
<div style={{ flex: 1, height: 10, borderRadius: 99, background: '#eef2f6', overflow: 'hidden' }}>
<motion.div initial={{ width: 0 }} animate={{ width: `${barW}%` }} transition={{ duration: 0.8, delay: 0.1 + i * 0.025, ease: [0.22, 1, 0.36, 1] }}
style={{ height: '100%', borderRadius: 99, background: c.isUs ? 'linear-gradient(90deg,#CD9E53,#b8893d)' : `linear-gradient(90deg, ${REGION_COLOR[c.region]}, ${REGION_COLOR[c.region]}bb)` }} />
</div>
<span style={{ fontFamily: 'Vazir, sans-serif', fontWeight: 800, fontSize: 13, color: NAVY, minWidth: 50, textAlign: 'left' }}>{faN(c.outputMt)}</span>
</div>
<span style={{ flex: '0 0 64px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: c.growthPct > 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}>
<span className="industry-competitor-growth" style={{ flex: '0 0 64px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: c.growthPct > 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}>
{c.growthPct > 0 ? '▲ ' : c.growthPct < 0 ? '▼ ' : ''}{faN(Math.abs(c.growthPct))}٪
</span>
<div style={{ flex: '0 0 86px' }}>
<div className="industry-competitor-tech" style={{ flex: '0 0 86px' }}>
<div style={{ height: 8, borderRadius: 99, overflow: 'hidden', display: 'flex', direction: 'ltr', border: '1px solid #e2e8f0' }}>
<div style={{ width: `${c.techMix.eafDri}%`, background: '#CD9E53' }} />
<div style={{ width: `${c.techMix.bfBof}%`, background: '#475569' }} />
</div>
<div style={{ fontSize: 8, color: '#94a3b8', textAlign: 'center', marginTop: 2 }}>{faN(c.techMix.eafDri)}٪ کمکربن</div>
</div>
<span style={{ flex: '0 0 56px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: '#475569' }}>{faN(c.utilizationPct)}٪</span>
<span className="industry-competitor-use" style={{ flex: '0 0 56px', textAlign: 'center', fontFamily: 'Vazir, sans-serif', fontWeight: 700, fontSize: 12, color: '#475569' }}>{faN(c.utilizationPct)}٪</span>
<span style={{ width: 14, color: '#cbd5e1', fontSize: 11 }}>{isOpen ? '▲' : '▼'}</span>
</div>
@ -838,8 +915,8 @@ function RisksModule() {
<div className="risk-grid" style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.7fr) minmax(0,1fr)' }}>
{/* scatter */}
<div style={{ padding: '8px 16px 16px', position: 'relative', borderLeft: '1px solid #eef2f6' }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', overflow: 'visible' }} onMouseLeave={() => setHovered(null)}>
<div className="industry-risk-scatter" style={{ padding: '8px 16px 16px', position: 'relative', borderLeft: '1px solid #eef2f6' }}>
<svg className="industry-risk-chart" viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', overflow: 'visible' }} onMouseLeave={() => setHovered(null)}>
{/* quadrant zones */}
<rect x={px(50)} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fee2e2" opacity={0.7} />
<rect x={PAD} y={py(100)} width={innerW / 2} height={innerH / 2} fill="#fef3c7" opacity={0.5} />
@ -887,9 +964,58 @@ function RisksModule() {
})}
</svg>
<div className="industry-risk-mobile-map" style={{
display: 'none',
position: 'relative',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
minHeight: 250,
border: '1px solid #e2e8f0',
overflow: 'hidden',
borderRadius: 10,
direction: 'ltr',
}}>
<div style={{ background: '#dcfce7', borderRight: '1px dashed #d1d5db', borderBottom: '1px dashed #d1d5db', padding: 8, color: '#15803d', fontSize: 9, direction: 'rtl' }}>اثر کم<br />احتمال کم</div>
<div style={{ background: '#fef9c3', borderBottom: '1px dashed #d1d5db', padding: 8, color: '#a16207', fontSize: 9, textAlign: 'right', direction: 'rtl' }}>اثر کم<br />احتمال بالا</div>
<div style={{ background: '#fef3c7', borderRight: '1px dashed #d1d5db', padding: 8, color: '#a16207', fontSize: 9, direction: 'rtl' }}>اثر بالا<br />احتمال کم</div>
<div style={{ background: '#fee2e2', padding: 8, color: '#dc2626', fontSize: 9, textAlign: 'right', direction: 'rtl' }}>اثر بالا<br />احتمال بالا</div>
{shown.map((risk) => {
const color = RISK_CAT[risk.category].color
const isActive = active?.id === risk.id
return (
<button
key={`mobile-${risk.id}`}
type="button"
title={risk.nameFa}
onClick={() => setPinned(pinned?.id === risk.id ? null : risk)}
style={{
position: 'absolute',
left: `clamp(16px, ${risk.probabilityPct}%, calc(100% - 16px))`,
bottom: `clamp(16px, ${risk.impactPct}%, calc(100% - 16px))`,
width: isActive ? 20 : 16,
height: isActive ? 20 : 16,
borderRadius: '50%',
border: '2px solid #fff',
background: color,
boxShadow: isActive ? `0 0 0 3px ${color}55` : '0 2px 5px rgba(3,35,64,0.18)',
cursor: 'pointer',
zIndex: isActive ? 2 : 1,
padding: 0,
transform: 'translate(-50%, 50%)',
}}
/>
)
})}
</div>
<div className="industry-risk-mobile-axes" style={{ display: 'none', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '8px 4px 0', color: '#64748b', fontSize: 9, direction: 'rtl' }}>
<span>شدت اثر: کم زیاد</span>
<span>احتمال وقوع: کم زیاد</span>
</div>
{/* hover/pin tooltip */}
{active && (
<div style={{ position: 'absolute', top: 14, left: 14, maxWidth: 290, background: NAVY, color: '#fff', borderRadius: 10, padding: '12px 14px', boxShadow: '0 14px 36px rgba(0,0,0,0.28)', direction: 'rtl', zIndex: 5, borderTop: `3px solid ${RISK_CAT[active.category].color}` }}>
<div className="industry-risk-tooltip" style={{ position: 'absolute', top: 14, left: 14, maxWidth: 290, background: NAVY, color: '#fff', borderRadius: 10, padding: '12px 14px', boxShadow: '0 14px 36px rgba(0,0,0,0.28)', direction: 'rtl', zIndex: 5, borderTop: `3px solid ${RISK_CAT[active.category].color}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 9, fontWeight: 700, background: RISK_CAT[active.category].color, padding: '2px 8px', borderRadius: 99 }}>{RISK_CAT[active.category].fa}</span>
<span style={{ fontSize: 9, color: TREND_FA[active.trend].color, fontWeight: 700 }}>{TREND_FA[active.trend].icon} {TREND_FA[active.trend].fa}</span>
@ -1075,36 +1201,137 @@ export default function IndustrySection() {
const steelScores = [
{
title: 'قدرت بازار',
description: 'شاخص ترکیبی قیمت HRC، حجم معاملات و سنتیمنت. بالاتر = فرصت قیمت‌گذاری بهتر برای فولاد مبارکه.',
initialScore: 67,
description: 'قدرت قیمت‌گذاری ورق تخت در بازارهای غیرچینی. عدد بالا یعنی امکان فروش با premium بهتر، اما نه بدون ریسک.',
initialScore: 76,
meaning:
'این شاخص می‌سنجد فولاد مبارکه در قیمت‌گذاری HRC، ورق پوشش‌دار و گریدهای صنعتی چقدر فضای مانور دارد. عدد ۷۶ یعنی بازار هنوز برای فروش ارزش‌افزوده جذاب است، ولی فشار صادرات چین اجازه قیمت‌گذاری بی‌محابا نمی‌دهد.',
basis: [
'هند در فوریه ۲۰۲۶ شاهد رشد HRC تا حدود ₹۵۴٬۰۰۰/t بود؛ این رشد به هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی نسبت داده شده است.',
'اروپا از ۲۰۲۶ واردات فولاد را سخت‌تر کرده: سهمیه بدون تعرفه ۱۸.۳ Mt و تعرفه ۵۰٪ بالاتر از سهمیه، که می‌تواند premium بازارهای محافظت‌شده را بالا نگه دارد.',
'آمریکا تعرفه فولاد و آلومینیوم را از ژوئن ۲۰۲۵ به ۵۰٪ رسانده؛ این سیاست قیمت داخلی آمریکا را از فولاد ارزان وارداتی جدا می‌کند.',
'در سمت منفی، صادرات چین در سطح بسیار بالا و ضعف تقاضای داخلی چین فشار نزولی روی بازارهای خاورمیانه و آسیا ایجاد می‌کند.',
],
implication:
'برای فولاد مبارکه، فرصت اصلی در فروش HRC و ورق‌های پوشش‌دار به بازارهای منطقه‌ای با قرارداد کوتاه‌مدت و تعدیل قیمت است. فروش commodity با قیمت ثابت بلندمدت خطرناک است، چون چین می‌تواند قیمت منطقه را سریع پایین بکشد.',
actions: [
'قیمت‌گذاری هفتگی بر اساس HRC چین، هند و ترکیه انجام شود، نه فقط قیمت داخلی.',
'در GCC و عراق بسته محصولی HRC + گالوانیزه + ورق رنگی پیشنهاد شود تا رقابت از قیمت خام به کیفیت و تحویل منتقل شود.',
'برای مشتری‌های پروژه‌ای، clause تعدیل قیمت و زمان تحویل کوتاه‌تر از ۶۰ روز استفاده شود.',
],
sources: [
{
label: 'Economic Times: HRC India at ₹54,000/t, 2026',
url: 'https://m.economictimes.com/markets/expert-view/steel-hrc-prices-hit-2-year-high-at-54000-how-long-will-the-rally-last/articleshow/128847391.cms',
note: 'رشد قیمت HRC هند به دو ساله‌ترین سطح، به‌دلیل هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی.',
},
{
label: 'AP: EU steel quota 18.3 Mt and 50% tariff',
url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a',
note: 'کاهش سهمیه واردات فولاد اروپا و تعرفه ۵۰٪ بالاتر از سهمیه.',
},
{
label: 'Politico: U.S. steel tariff increased to 50%',
url: 'https://www.politico.com/news/2025/05/30/trump-steel-tariff-pennsylvania-00378480',
note: 'افزایش تعرفه فولاد آمریکا از ۲۵٪ به ۵۰٪ در ۲۰۲۵، عامل premium بازارهای محافظت‌شده.',
},
],
},
{
title: 'ریسک زنجیره تامین',
description: 'ریسک تامین سنگ‌آهن ($۱۰۱/t)، زغال‌کک ($۲۰۰/t) و قطعات یدکی. عدد بالا = ریسک پایین‌تر.',
initialScore: 45,
description: 'تاب‌آوری تأمین سنگ‌آهن، انرژی، قطعات و مواد مصرفی. عدد ۵۴ یعنی وضعیت متوسط و نیازمند کنترل فعال.',
initialScore: 54,
meaning:
'این شاخص ریسک مواد اولیه و نهاده‌های عملیاتی فولاد مبارکه را می‌سنجد. چون مسیر اصلی مبارکه DRI-EAF است، وابستگی به زغال‌کک کمتر از BF/BOF است؛ اما ریسک سنگ‌آهن، گاز، برق، آب، قطعات یدکی و تحریم هنوز جدی است.',
basis: [
'طبق پروفایل تولید فولاد مبارکه، سنگ‌آهن از گل‌گهر و چادرملو به گندله، سپس DRI و بعد EAF/اسلب/کویل تبدیل می‌شود؛ پس کیفیت و پایداری تأمین داخلی حیاتی است.',
'Moodys برای سنگ‌آهن بازه ۸۰ تا ۱۰۰ دلار در ۱۸ ماه آینده را محتمل دانسته؛ یعنی شوک شدید مثبت کمتر محتمل است، اما ضعف تقاضا و عرضه مازاد روی قیمت اثر دارد.',
'رشد HRC هند در ۲۰۲۶ بخشی به افزایش هزینه مواد اولیه به‌ویژه زغال‌کک نسبت داده شده؛ هرچند مبارکه کمتر از BF به زغال‌کک وابسته است، ولی بازار جهانی نهاده‌ها روی قیمت منطقه اثر می‌گذارد.',
'ریسک قطعات یدکی و تجهیزات برای تولیدکننده ایرانی به‌خاطر تحریم و محدودیت واردات باید جدا از قیمت مواد اولیه سنجیده شود.',
],
implication:
'امتیاز ۵۴ یعنی زنجیره تأمین شکننده نیست، ولی برای برنامه صادراتی تهاجمی هم کافی نیست. مزیت DRI-EAF مبارکه باید با قراردادهای پایدار سنگ‌آهن/گندله، ذخیره قطعات بحرانی و مدیریت انرژی تکمیل شود.',
actions: [
'لیست قطعات بحرانی خطوط احیا، EAF، ریخته‌گری و نورد گرم به سطح minimum stock عملیاتی وصل شود.',
'برای سنگ‌آهن/گندله سناریوی قیمت ۸۰، ۹۰ و ۱۰۰ دلار در margin صادراتی لحاظ شود.',
'مصرف گاز و برق هر تن فولاد برای محصولات صادراتی جداگانه محاسبه شود تا در مذاکرات قیمت و CBAM آینده قابل دفاع باشد.',
],
sources: [
{
label: 'Mobarakeh Steel Company production process',
url: 'https://en.wikipedia.org/wiki/Mobarakeh_Steel_Company',
note: 'فرایند گندله، DRI، EAF، اسلب، نورد گرم و محصولات پوشش‌دار فولاد مبارکه.',
},
{
label: 'Economic Times: iron ore $80-100 outlook',
url: 'https://economictimes.indiatimes.com/industry/indl-goods/svs/metals-mining/iron-ore-price-to-remain-between-80-100-amid-demand-slump-supply-growth/articleshow/121156956.cms',
note: 'Moodys بازه ۸۰ تا ۱۰۰ دلار برای سنگ‌آهن را در ۱۸ ماه آینده مطرح کرده است.',
},
{
label: 'Economic Times: HRC rally and raw material costs',
url: 'https://m.economictimes.com/markets/expert-view/steel-hrc-prices-hit-2-year-high-at-54000-how-long-will-the-rally-last/articleshow/128847391.cms',
note: 'افزایش قیمت HRC هند به هزینه مواد اولیه، safeguard duty و تقاضای زیرساختی نسبت داده شده است.',
},
],
},
{
title: 'مومنتوم صادراتی',
description: 'رشد صادرات ۳ ماهه، نفوذ به بازارهای هدف عراق/هند، و سرعت عقد قرارداد جدید.',
initialScore: 31,
description: 'شتاب واقعی صادرات در برابر فشار چین، تعرفه‌ها و محدودیت بازار مقصد. عدد ۱۳ یعنی بسیار ضعیف.',
initialScore: 13,
meaning:
'این شاخص نشان می‌دهد شرایط بیرونی برای رشد صادرات فولاد مبارکه چقدر مساعد است. عدد ۱۳ یعنی بازار جهانی در ظاهر پرتقاضا نیست؛ مسیرهای فروش با فشار چین، دیوارهای تعرفه‌ای و محدودیت compliance روبه‌رو هستند.',
basis: [
'چین در ۲۰۲۵ حدود ۹۶۰.۸ Mt فولاد خام تولید کرده و صادرات فولاد آن حدود ۱۳۱ Mt گزارش شده؛ این حجم صادرات فشار مستقیم روی خاورمیانه و آسیا می‌آورد.',
'هند رشد تقاضا دارد، اما خودش در ۲۰۲۵ به ۱۶۴.۹ Mt تولید فولاد خام رسیده و رقیب ظرفیت‌ساز است، نه فقط بازار هدف.',
'اروپا با CBAM و سهمیه/تعرفه جدید، صادرات مستقیم بدون داده CO₂ را سخت کرده است.',
'آمریکا با تعرفه ۵۰٪ فولاد عملاً بازار مستقیم را برای بسیاری از عرضه‌کنندگان خارجی بسته و باعث انحراف جریان تجارت به بازارهای منطقه‌ای می‌شود.',
],
implication:
'برای فولاد مبارکه، صادرات باید دفاعی و انتخابی باشد: عراق، آسیای مرکزی و GCC با قرارداد کوتاه‌مدت قابل بررسی‌اند؛ اروپا بدون بسته کربن و مسیر compliance اولویت مستقیم ندارد؛ هند و ترکیه فقط در گرید خاص یا شکاف عرضه مناسب‌اند.',
actions: [
'بازارهای هدف به سه سطح تقسیم شوند: ورود فعال عراق/آسیای مرکزی، ورود پریمیوم GCC، رصد هند/ترکیه/اروپا.',
'برای هر قرارداد صادراتی، قیمت چین و هزینه حمل همان هفته در فرم approval بیاید.',
'پکیج مستندات CO₂، انرژی و traceability برای صادرات غیرمستقیم اروپا آماده شود.',
],
sources: [
{
label: 'worldsteel annual production data, 2025',
url: 'https://worldsteel.org/steel-topics/statistics/annual-production-steel-data/',
note: 'داده تولید ۲۰۲۵: چین ۹۶۰.۸ Mt، هند ۱۶۴.۹ Mt، ترکیه ۳۸.۱ Mt، ایران ۳۱.۸ Mt.',
},
{
label: 'Times of India / worldsteel: India 164.9 Mt and +9% demand outlook',
url: 'https://timesofindia.indiatimes.com/city/kolkata/indias-crude-steel-output-grows-10/articleshow/127499102.cms',
note: 'رشد تولید هند و پیش‌بینی رشد تقاضای فولاد هند در ۲۰۲۶.',
},
{
label: 'European Commission: CBAM',
url: 'https://taxation-customs.ec.europa.eu/carbon-border-adjustment-mechanism_en',
note: 'CBAM از ۲۰۲۶ وارد فاز قطعی شده و داده کربن برای صادرات به زنجیره اروپا تعیین‌کننده است.',
},
{
label: 'AP: EU steel import quota and 50% tariff',
url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a',
note: 'سهمیه ۱۸.۳ Mt و تعرفه ۵۰٪ اروپا، عامل محدودکننده جریان صادرات فولاد.',
},
],
},
];
return (
<section dir="rtl" style={{ padding: '32px 0', fontFamily: "'Vazir', 'Segoe UI', sans-serif" }}>
<section dir="rtl" className="industry-section" style={{ padding: '32px 0', fontFamily: "'Vazir', 'Segoe UI', sans-serif" }}>
{/* Section header */}
<div style={{
<div className="industry-masthead" style={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
marginBottom: 16, direction: 'rtl',
borderBottom: `2px solid ${ACCENT}`, paddingBottom: 10,
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: ACCENT }}>
<div className="industry-masthead-main" style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
<span className="industry-masthead-kicker" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: ACCENT }}>
INDUSTRY INTELLIGENCE
</span>
<span style={{ fontSize: 18, fontWeight: 800, color: NAVY }}>پایش صنعت جهانی فولاد</span>
<span className="industry-masthead-title" style={{ fontSize: 18, fontWeight: 800, color: NAVY }}>پایش صنعت جهانی فولاد</span>
</div>
<span style={{ fontFamily: "Vazir, sans-serif", fontSize: 11, color: '#94a3b8' }}>
<span className="industry-masthead-source" style={{ fontFamily: "Vazir, sans-serif", fontSize: 11, color: '#94a3b8' }}>
۱۴۰۴/۰۳/۳۰ · World Steel Association · Platts · MySteel
</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
const faDigits = '۰۱۲۳۴۵۶۷۸۹'
const arDigits = '٠١٢٣٤٥٦٧٨٩'
const faMonths: Record<string, number> = {
فروردین: 1,
اردیبهشت: 2,
خرداد: 3,
تیر: 4,
مرداد: 5,
شهریور: 6,
مهر: 7,
آبان: 8,
آذر: 9,
دی: 10,
بهمن: 11,
اسفند: 12,
}
type DateLike = {
date?: string | null
dateFa?: string | null
dateEn?: string | null
dateIso?: string | null
createdAt?: string | null
updatedAt?: string | null
}
const normalizeDigits = (value: string) =>
value.replace(/[۰-۹٠-٩]/g, (d) => {
const fa = faDigits.indexOf(d)
if (fa >= 0) return String(fa)
const ar = arDigits.indexOf(d)
return ar >= 0 ? String(ar) : d
})
const currentPersianYear = () => {
try {
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).formatToParts(new Date())
const year = Number(parts.find((part) => part.type === 'year')?.value)
if (Number.isFinite(year) && year > 1300) return year
} catch {
/* keep deterministic fallback for browsers without Persian calendar support */
}
return 1405
}
const parsePersianDateKey = (value?: string | null) => {
if (!value) return 0
const normalized = normalizeDigits(value)
const day = Number((normalized.match(/\d{1,2}/) || ['0'])[0])
const explicitYear = Number((normalized.match(/\d{4}/) || ['0'])[0])
const year = explicitYear || currentPersianYear()
const month = Object.entries(faMonths).find(([name]) => value.includes(name))?.[1] ?? 0
return year && month && day ? year * 10000 + month * 100 + day : 0
}
const parseGregorianDateKey = (value?: string | null) => {
if (!value) return 0
const parsed = Date.parse(value)
if (!Number.isFinite(parsed)) return 0
const date = new Date(parsed)
return date.getUTCFullYear() * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate()
}
export const getDateSortKey = (item: DateLike) => {
const persianKey = parsePersianDateKey(item.dateFa || item.date)
if (persianKey) return persianKey
return (
parseGregorianDateKey(item.dateIso) ||
parseGregorianDateKey(item.dateEn) ||
parseGregorianDateKey(item.updatedAt) ||
parseGregorianDateKey(item.createdAt)
)
}
export const sortByDateDesc = <T extends DateLike>(items: T[]) =>
[...items].sort((a, b) => getDateSortKey(b) - getDateSortKey(a))
const getPublishedSortKey = (item: DateLike) => {
const published = Date.parse(item.createdAt || '')
if (Number.isFinite(published)) return published
const updated = Date.parse(item.updatedAt || '')
if (Number.isFinite(updated)) return updated
return getDateSortKey(item)
}
export const sortByPublishedDesc = <T extends DateLike>(items: T[]) =>
[...items].sort((a, b) => getPublishedSortKey(b) - getPublishedSortKey(a))

View File

@ -0,0 +1,11 @@
export const isUsableSourceUrl = (value?: string | null) =>
typeof value === 'string' && (/^https?:\/\//i.test(value) || value.startsWith('/'))
export const resolveSourceUrl = (source?: string | null, explicitUrl?: string | null) => {
if (isUsableSourceUrl(explicitUrl)) return explicitUrl || undefined
if (isUsableSourceUrl(source)) return source || undefined
const sourceText = typeof source === 'string' ? source.trim() : ''
const isBareDomain = /^[a-z0-9.-]+\.[a-z]{2,}(\/.*)?$/i.test(sourceText) && !/\s/.test(sourceText)
if (isBareDomain) return `https://${sourceText}`
return undefined
}

View File

@ -5,6 +5,7 @@ import path from 'path'
// hosts allowed through Vite's host check (for tunnels: tunnelmole, localtunnel, cloudflare)
const ALLOWED_TUNNEL_HOSTS = ['.tunnelmole.net', '.loca.lt', '.trycloudflare.com']
const CMS_TARGET = 'https://cms-steelforesight.liara.run'
export default defineConfig({
plugins: [react(), tailwindcss()],
@ -17,12 +18,19 @@ export default defineConfig({
allowedHosts: ALLOWED_TUNNEL_HOSTS,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: CMS_TARGET,
changeOrigin: true,
secure: true,
},
'/uploads': {
target: 'http://localhost:3001',
target: CMS_TARGET,
changeOrigin: true,
secure: true,
},
'/media': {
target: CMS_TARGET,
changeOrigin: true,
secure: true,
},
},
},

View File

@ -455,7 +455,7 @@ async function generateAtAGlance(job, panelBaseUrl, session) {
: news.map((n, i) => `${i + 1}. ${n.title}\nsource: ${n.link}`).join('\n\n');
const ai = await aiJson([
{ role: 'system', content: buildSystemPrompt('glance', cat.label) },
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material.\n\n${newsText}` },
{ role: 'user', content: `Write short "At a Glance" items for category: ${cat.label}. Return JSON only: {"items":[{"titleFa":"","excerptFa":"","bodyFa":"","analysisFa":"","tags":[],"sourceTitle":"","sourceUrl":""}]}. Use the saved source material/news below. Prefer exact source URLs from the material. sourceTitle is the publisher/site name, for example Reuters.\n\n${newsText}` },
], 7000);
const items = Array.isArray(ai?.items) ? ai.items : [];
updateJob(job, { step: 'saving', progress: 70, message: `drafting ${items.length} items` });
@ -469,7 +469,8 @@ async function generateAtAGlance(job, panelBaseUrl, session) {
bodyFa: item.bodyFa || null,
analysisFa: item.analysisFa || null,
tags: Array.isArray(item.tags) ? item.tags : [],
source: item.sourceUrl || null,
source: item.sourceTitle || item.source || null,
sourceUrl: item.sourceUrl || null,
dateFa,
status: 'draft',
});

View File

@ -110,6 +110,7 @@ await pool.query(`
author_role TEXT,
author_initial TEXT,
publish_date TEXT,
episode_number TEXT,
pages INTEGER DEFAULT 0,
price INTEGER DEFAULT 0,
is_free INTEGER DEFAULT 1,
@ -255,6 +256,7 @@ await pool.query(`
excerpt_fa TEXT,
body_fa TEXT,
analysis_fa TEXT,
source_url TEXT,
title_en TEXT,
date_en TEXT,
excerpt_en TEXT,
@ -431,8 +433,10 @@ for (const sql of [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'",
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS blocks TEXT',
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS episode_number TEXT',
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source_url TEXT',
"ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
@ -455,6 +459,23 @@ await pool.query(`
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
`);
// per-member bookmarks for articles, podcasts and other site content
await pool.query(`
CREATE TABLE IF NOT EXISTS member_bookmarks (
id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
content_id TEXT NOT NULL,
content_type TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT,
cover_image TEXT,
path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(member_id, content_type, content_id)
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_member ON member_bookmarks(member_id, created_at DESC);
`);
// newsletter subscribers (footer signup) — email is unique
await pool.query(`
CREATE TABLE IF NOT EXISTS subscribers (
@ -721,6 +742,7 @@ export function rowToRadarItem(row) {
analysisFa: row.analysis_fa || null,
tags: row.tags ? JSON.parse(row.tags) : [],
source: row.source || null,
sourceUrl: row.source_url || null,
titleEn: row.title_en,
dateEn: row.date_en,
excerptEn: row.excerpt_en,
@ -793,6 +815,7 @@ export function rowToArticle(row) {
authorRole: row.author_role,
authorInitial: row.author_initial,
publishDate: row.publish_date,
episodeNumber: row.episode_number,
pages: row.pages,
price: row.price,
isFree: !!row.is_free,

View File

@ -520,7 +520,7 @@ function renderEditor(article, draft = {}) {
title: '', category: draft.category || ctInfo.defaultCategory,
type: draft.type || ctInfo.defaultType,
author: '', authorRole: '', authorInitial: '',
publishDate: '', pages: 0, price: 0, isFree: true,
publishDate: '', episodeNumber: '', pages: 0, price: 0, isFree: true,
summary: '', body: '', coverImage: '', tags: [], featured: false,
};
@ -581,6 +581,11 @@ function renderEditor(article, draft = {}) {
<input name="publishDate" value="${escapeAttr(a.publishDate)}" placeholder="بهمن ۱۴۰۳" />
</label>
${contentType === 'podcast' ? `
<label>شماره اپیزود
<input name="episodeNumber" value="${escapeAttr(a.episodeNumber || '')}" placeholder="مثلاً ۷۲" />
</label>` : `<input type="hidden" name="episodeNumber" value="" />`}
${hasAuthor ? `
<label>نویسنده / مجری
<input name="author" value="${escapeAttr(a.author)}" />
@ -795,6 +800,7 @@ function renderEditor(article, draft = {}) {
authorRole: fd.get('authorRole') || null,
authorInitial:fd.get('authorInitial') || null,
publishDate: fd.get('publishDate') || null,
episodeNumber:fd.get('episodeNumber') || null,
pages: Number(fd.get('pages') || 0),
price: Number(fd.get('price') || 0),
isFree: isFreeVal === 'on' || isFreeVal === 'true',
@ -1672,7 +1678,7 @@ async function renderRadarList() {
function renderRadarEditor(item) {
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'',
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', tags:[], source:'', sortOrder:0 };
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', tags:[], source:'', sourceUrl:'', sortOrder:0 };
const isEdit = !!item;
function sectionBadges(cat) {
@ -1725,7 +1731,8 @@ function renderRadarEditor(item) {
</div>
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="۱۴ خرداد ۱۴۰۴" /></label>
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
<label>منبع <input name="source" value="${escapeAttr(r.source||'')}" placeholder="مثلاً Financial Times" /></label>
<label>نام منبع <input name="source" value="${escapeAttr(r.source||'')}" placeholder="مثلاً Reuters" /></label>
<label>لینک منبع <input name="sourceUrl" value="${escapeAttr(r.sourceUrl||'')}" placeholder="https://www.reuters.com/..." /></label>
<div class="col span-2">
<label>هشتگها (با ویرگول جدا کنید) <input name="tags" value="${escapeAttr((r.tags||[]).join('، '))}" placeholder="فولاد سبز، هیدروژن، اروپا" /></label>
</div>
@ -1799,6 +1806,7 @@ function renderRadarEditor(item) {
bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null,
analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null,
source: fd.get('source')||null,
sourceUrl: fd.get('sourceUrl')||null,
tags: String(fd.get('tags')||'').split(/[,،]/).map(s=>s.trim()).filter(Boolean),
sortOrder: Number(fd.get('sortOrder')||0),
};

View File

@ -13,6 +13,7 @@ import jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { randomInt, createHmac, randomUUID } from 'node:crypto';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
@ -50,16 +51,17 @@ try {
// buffer the upload in memory so we can transcode it to WebP before writing to disk
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
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)'));
if (/^image\/(jpe?g|png|webp|gif|avif|heic|heif|tiff?)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif, avif, heic)'));
},
});
// raw (non-image) uploads — e.g. videos — streamed straight to object storage.
// Raw media is staged on disk instead of RAM. A 200 MB memoryStorage upload can
// exhaust a small container with only a few concurrent authenticated requests.
const uploadRaw = multer({
storage: multer.memoryStorage(),
dest: process.env.UPLOAD_TMP_DIR || path.join(os.tmpdir(), 'steelforesight-uploads'),
limits: { fileSize: 200 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true);
@ -89,12 +91,13 @@ const s3 = S3_ENABLED
: null;
// upload a buffer to the bucket and return its public URL (bucket must allow public reads)
async function putObject(key, body, contentType) {
async function putObject(key, body, contentType, contentLength) {
await s3.send(new PutObjectCommand({
Bucket: process.env.LIARA_BUCKET,
Key: key,
Body: body,
ContentType: contentType,
...(Number.isFinite(contentLength) ? { ContentLength: contentLength } : {}),
}));
return `${MEDIA_BASE}/media/${key}`;
}
@ -111,7 +114,11 @@ function sniffKind(buf) {
if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF
if (b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP') return 'image';
if (hex(4) === '49492a00' || hex(4) === '4d4d002a') return 'image'; // TIFF
if (b.subarray(4, 8).toString('ascii') === 'ftyp') return 'video'; // MP4/MOV/m4a
if (b.subarray(4, 8).toString('ascii') === 'ftyp') {
const brand = b.subarray(8, 12).toString('ascii').toLowerCase();
if (/^(avif|avis|heic|heix|hevc|hevx|mif1|msf1)$/.test(brand)) return 'image';
return 'video'; // MP4/MOV/m4a
}
if (hex(4) === '1a45dfa3') return 'video'; // WebM/MKV
if (hex(2) === 'fffb' || hex(2) === 'fff3' || b.subarray(0, 3).toString('ascii') === 'ID3') return 'audio'; // MP3
if (b.subarray(0, 4).toString('ascii') === 'OggS') return 'audio'; // Ogg
@ -227,6 +234,43 @@ const contactLimiter = rateLimit({
legacyHeaders: false,
});
// Broad abuse protection for API reads/writes. Authentication and contact
// endpoints keep their stricter limiters below; media streaming is excluded.
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 600,
message: { error: 'too_many_requests' },
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.method === 'OPTIONS',
});
app.use('/api', apiLimiter);
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'too_many_uploads' },
standardHeaders: true,
legacyHeaders: false,
});
// Bound temporary-disk and outbound-bandwidth pressure independently of the
// request rate. The slot is always released on response completion/disconnect.
let activeUploads = 0;
function uploadSlot(req, res, next) {
if (activeUploads >= 2) return res.status(503).json({ error: 'upload_capacity_reached' });
activeUploads += 1;
let released = false;
const release = () => {
if (released) return;
released = true;
activeUploads = Math.max(0, activeUploads - 1);
};
res.once('finish', release);
res.once('close', release);
next();
}
// Per-account login lockout — complements the per-IP loginLimiter by bounding a
// distributed / credential-stuffing attack that rotates IPs against ONE account.
// ponytail: in-memory Map (resets on restart, single-process); fine for this panel.
@ -364,7 +408,7 @@ app.get('/api/auth/me', authRequired, (req, res) => {
const PUBLIC_FIELDS = `
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
publish_date, episode_number, pages, price, is_free, summary, body, cover_image,
blocks, sources, tags, featured, status, created_at, updated_at
`;
@ -393,7 +437,7 @@ app.get('/api/articles/:id', async (req, res) => {
res.json(rowToArticle(row));
});
app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) => {
app.post('/api/uploads', authRequired, uploadLimiter, uploadSlot, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
// validate by content (magic bytes), not the client-supplied mimetype
if (sniffKind(req.file.buffer) !== 'image') return res.status(415).json({ error: 'invalid_image' });
@ -402,22 +446,30 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) =
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 })
// Keep enough detail for full-width article heroes while enforcing a practical
// absolute size budget. The old "25% of the original" target could push WebP
// quality as low as 32 and visibly damage already-compressed photographs.
const targetBytes = 700 * 1024;
let finalBuf = null;
for (const quality of [84, 81, 78, 76, 74]) {
const buf = await sharp(original, { animated: isAnimated, limitInputPixels: 40_000_000 })
.rotate() // respect EXIF orientation
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
.webp({ quality, effort: 4 })
.resize({ width: 2400, height: 2400, fit: 'inside', withoutEnlargement: true })
.webp({ quality, effort: 5, smartSubsample: true })
.toBuffer();
finalBuf = buf;
if (buf.length <= targetBytes) break;
}
// Highly detailed images can remain above the budget even at quality 74.
// Reduce dimensions slightly instead of destroying detail with very low quality.
if (finalBuf.length > targetBytes) {
finalBuf = await sharp(original, { animated: isAnimated, limitInputPixels: 40_000_000 })
.rotate()
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 76, effort: 5, smartSubsample: true })
.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`;
@ -445,20 +497,43 @@ app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) =
});
// raw file upload (videos / pdf) → object storage. Requires S3 to be configured.
app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => {
app.post('/api/uploads/raw', authRequired, uploadLimiter, uploadSlot, uploadRaw.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' });
// validate by content (magic bytes); only real video/audio/pdf is accepted
const kind = sniffKind(req.file.buffer);
if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') return res.status(415).json({ error: 'invalid_media' });
if (!s3) {
fs.promises.unlink(req.file.path).catch(() => {});
return res.status(503).json({ error: 'object_storage_not_configured' });
}
try {
const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
// Validate a small header from disk by magic bytes; never trust MIME/extension.
const handle = await fs.promises.open(req.file.path, 'r');
const header = Buffer.alloc(32);
try { await handle.read(header, 0, header.length, 0); }
finally { await handle.close(); }
const kind = sniffKind(header);
if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') {
return res.status(415).json({ error: 'invalid_media' });
}
const declaredKindMatches =
(kind === 'pdf' && req.file.mimetype === 'application/pdf') ||
(kind === 'audio' && req.file.mimetype.startsWith('audio/')) ||
(kind === 'video' && (req.file.mimetype.startsWith('video/') ||
['audio/mp4', 'audio/x-m4a'].includes(req.file.mimetype)));
if (!declaredKindMatches) return res.status(415).json({ error: 'media_type_mismatch' });
const mimeExtensions = {
'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/aac': '.aac', 'audio/x-m4a': '.m4a',
'audio/mp4': '.m4a', 'video/mp4': '.mp4', 'video/webm': '.webm',
'video/quicktime': '.mov', 'video/x-matroska': '.mkv', 'application/pdf': '.pdf',
};
const ext = (mimeExtensions[req.file.mimetype] || '.bin').toLowerCase();
const key = `${nanoid(12)}${ext}`;
const url = await putObject(key, req.file.buffer, req.file.mimetype);
const url = await putObject(key, fs.createReadStream(req.file.path), req.file.mimetype, req.file.size);
res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype });
} catch (err) {
console.error('[upload:raw] failed:', err.message);
res.status(500).json({ error: 'upload_failed' });
} finally {
fs.promises.unlink(req.file.path).catch(() => {});
}
});
@ -471,6 +546,7 @@ function normalizeArticleBody(b) {
author_role: b.authorRole || null,
author_initial: b.authorInitial || null,
publish_date: b.publishDate || null,
episode_number: String(b.episodeNumber || '').trim() || null,
pages: Number(b.pages) || 0,
price: Number(b.price) || 0,
is_free: b.isFree ? 1 : 0,
@ -492,11 +568,11 @@ app.post('/api/articles', authRequired, async (req, res) => {
await db.prepare(`
INSERT INTO articles (
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
publish_date, episode_number, pages, price, is_free, summary, body, cover_image,
blocks, sources, tags, featured, status
) VALUES (
@id, @title, @category, @type, @author, @author_role, @author_initial,
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
@publish_date, @episode_number, @pages, @price, @is_free, @summary, @body, @cover_image,
@blocks, @sources, @tags, @featured, @status
)
`).run({ id, ...a });
@ -513,7 +589,7 @@ app.put('/api/articles/:id', authRequired, async (req, res) => {
UPDATE articles SET
title = @title, category = @category, type = @type,
author = @author, author_role = @author_role, author_initial = @author_initial,
publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
publish_date = @publish_date, episode_number = @episode_number, pages = @pages, price = @price, is_free = @is_free,
summary = @summary, body = @body, cover_image = @cover_image,
blocks = @blocks, sources = @sources,
tags = @tags, featured = @featured, status = @status,
@ -827,7 +903,7 @@ app.delete('/api/events/:id', authRequired, async (req, res) => {
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
title_en, date_en, excerpt_en, body_en, analysis_en,
tags, source, status, sort_order, created_at, updated_at`;
tags, source, source_url, status, sort_order, created_at, updated_at`;
// public: only published items
app.get('/api/radar', async (req, res) => {
@ -869,6 +945,7 @@ function normalizeRadarBody(b) {
analysis_en: b.analysisEn || null,
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
source: b.source || null,
source_url: b.sourceUrl || b.source_url || null,
sort_order: Number(b.sortOrder) || 0,
};
}
@ -880,10 +957,10 @@ app.post('/api/radar', authRequired, async (req, res) => {
const status = req.body?.status === 'draft' ? 'draft' : 'published';
await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
title_fa,date_fa,excerpt_fa,body_fa,analysis_fa,
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,status,sort_order)
title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,source_url,status,sort_order)
VALUES (@id,@category,@img,@author_name,@author_avatar,
@title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@status,@sort_order)
@title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@source_url,@status,@sort_order)
`).run({ id, status, ...r });
res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
});
@ -897,7 +974,7 @@ app.put('/api/radar/:id', authRequired, async (req, res) => {
body_fa=@body_fa,analysis_fa=@analysis_fa,
title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
body_en=@body_en,analysis_en=@analysis_en,
tags=@tags,source=@source,
tags=@tags,source=@source,source_url=@source_url,
sort_order=@sort_order,updated_at=now() WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
@ -2030,6 +2107,61 @@ app.post('/api/members/activity', memberRequired, jsonOnly, async (req, res) =>
res.status(204).end();
});
const BOOKMARK_TYPES = new Set(['article', 'podcast', 'video', 'radar', 'report', 'event']);
app.get('/api/members/me/bookmarks', memberRequired, async (req, res) => {
const rows = await db.prepare(`
SELECT id, content_id, content_type, title, summary, cover_image, path, created_at
FROM member_bookmarks
WHERE member_id = ?
ORDER BY created_at DESC
`).all(req.member.sub);
res.json(rows.map(row => ({
id: row.id,
contentId: row.content_id,
contentType: row.content_type,
title: row.title,
summary: row.summary,
coverImage: row.cover_image,
path: row.path,
createdAt: row.created_at,
})));
});
app.post('/api/members/me/bookmarks', memberRequired, jsonOnly, async (req, res) => {
const contentId = String(req.body?.contentId || '').trim().slice(0, 120);
const contentType = String(req.body?.contentType || '').trim().toLowerCase();
const title = String(req.body?.title || '').trim().slice(0, 300);
const summary = String(req.body?.summary || '').trim().slice(0, 1500) || null;
const coverImage = String(req.body?.coverImage || '').trim().slice(0, 1000) || null;
const contentPath = String(req.body?.path || '').trim().slice(0, 500);
if (!contentId || !title || !contentPath || !BOOKMARK_TYPES.has(contentType)) {
return res.status(400).json({ error: 'invalid_bookmark' });
}
const row = await db.prepare(`
INSERT INTO member_bookmarks (member_id, content_id, content_type, title, summary, cover_image, path)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (member_id, content_type, content_id)
DO UPDATE SET title = EXCLUDED.title, summary = EXCLUDED.summary,
cover_image = EXCLUDED.cover_image, path = EXCLUDED.path
RETURNING id, content_id, content_type, title, summary, cover_image, path, created_at
`).get(req.member.sub, contentId, contentType, title, summary, coverImage, contentPath);
res.status(201).json({
id: row.id, contentId: row.content_id, contentType: row.content_type,
title: row.title, summary: row.summary, coverImage: row.cover_image,
path: row.path, createdAt: row.created_at,
});
});
app.delete('/api/members/me/bookmarks/:contentType/:contentId', memberRequired, async (req, res) => {
const contentType = String(req.params.contentType || '').toLowerCase();
const contentId = String(req.params.contentId || '').slice(0, 120);
if (!BOOKMARK_TYPES.has(contentType) || !contentId) return res.status(400).json({ error: 'invalid_bookmark' });
await db.prepare('DELETE FROM member_bookmarks WHERE member_id = ? AND content_type = ? AND content_id = ?')
.run(req.member.sub, contentType, contentId);
res.status(204).end();
});
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);