@@ -376,167 +524,70 @@ export default function Podcast() {
{/* ══ PODCAST SECTION ══ */}
-
-
- {/* label */}
-
-
-
{t.podcast}
+
+
+ {isFa ? 'مشاهده همه' : 'View all'}
+
+ {isFa ? 'پادکستها' : 'Podcasts'}
+
- {/* main grid */}
-
-
- {/* sidebar */}
-
-
-
{t.episodes}
+
+
+
+
+
+
{pod.epNum}
+
{pod[lang].title}
+
+
+ {pod.date}
+
+
-
- {PODCASTS.map((p, i) => (
-
setActive(i)} lang={lang} />
- ))}
+
+
+ {pod.mediaUrl
+ ?
+ : }
- {/* hero */}
-
-
- {/* blurred bg */}
-
-
-
- {/* inner grid: text | cover-image */}
-
-
- {/* ── COVER image column ── */}
- {/* outer: spinning gradient border */}
-
-
- {/* inner: clips image to rounded rect */}
-
-
![{pod[lang].title}]({pod.cover})
-
-
-
-
- {/* ── text column ── */}
-
-
- {/* ep badge */}
-
- {pod.epNum}
-
- {pod.date}
-
-
- {/* title */}
-
{pod[lang].title}
-
- {/* guest */}
-
-
- {/* body */}
-
{pod[lang].body}
-
- {/* ── AUDIO PLAYER ── */}
-
- {pod.mediaUrl
- ?
- :
}
-
-
- {/* bottom: rating only */}
-
-
- ★ {pod.rating}
-
-
-
-
-
+
+
-
+
+
+
+
+
+
+
{pod[lang].title}
+
+
{pod[lang].body}
+ {showPodGuest && (
+
+ {isFa ? 'مهمان برنامه: ' : 'Guest: '}{pod[lang].guest}
+
+ )}
+
+
+ {PODCASTS.filter((_, index) => index !== active).slice(0, 2).map((item) => {
+ const sourceIndex = PODCASTS.indexOf(item)
+ return (
+
+ )
+ })}
+
+
@@ -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}
-
{p[lang].guest}
+ {Boolean(p[lang].guest?.trim()) && (
+
{p[lang].guest}
+ )}
★ {p.rating}
diff --git a/frontend/src/pages/Podcast/PodcastFigma.tsx b/frontend/src/pages/Podcast/PodcastFigma.tsx
new file mode 100644
index 0000000..22bc815
--- /dev/null
+++ b/frontend/src/pages/Podcast/PodcastFigma.tsx
@@ -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
(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) => {
+ 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
+
+}
+
+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>(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 محتوایی برای نمایش وجود ندارد.
+
+ return
+
+
+
+ {fa ? 'ویدیوکستها' : 'Videocasts'}
{fa ? 'مشاهده همه' : 'View all'}
+
+ {videoPlaying && video.mediaUrl ?
+
+
+
{video[lang].title}
{video[lang].body && {video[lang].body}
}{Boolean(video.tags?.length) && {video.tags!.map(tag => )}
}
+
+
{fa ? 'پلیلیست' : 'Playlist'}
+
{VIDEOS.slice(0, 3).map((item, index) =>
)}
+
+
+
+
+
+
+
+ {fa ? 'پادکستها' : 'Podcasts'}
{fa ? 'مشاهده همه' : 'View all'}
+
+
+
{podcast.epNum}
{podcast[lang].title}
{podcast.date}{(podcast.mediaUrl || podcast.duration) && <> · >}
+
+
+
+
+
+
+
{podcast[lang].title}
{podcast[lang].body && {podcast[lang].body}
}{Boolean(podcast[lang].guest?.trim()) && {fa ? 'مهمان برنامه:' : 'Guest:'} {podcast[lang].guest}
}{Boolean(podcast.tags?.length) && {podcast.tags!.map(tag => )}
}
+
{PODCASTS.filter((_, i) => i !== activePodcast).slice(0, 2).map(item =>
)}
+
+
+
+
+}
diff --git a/frontend/src/pages/Profile/ProfilePage.tsx b/frontend/src/pages/Profile/ProfilePage.tsx
index 83d85a2..7884c65 100644
--- a/frontend/src/pages/Profile/ProfilePage.tsx
+++ b/frontend/src/pages/Profile/ProfilePage.tsx
@@ -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' ? (
+ ) : section === 'saved' ? (
+
) : (
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([])
+ 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 (
+
+
+ {loading ? (
+
+ {fa ? 'در حال دریافت علاقهمندیها…' : 'Loading saved content…'}
+
+ ) : error ? (
+
+ {fa ? 'دریافت مطالب ذخیرهشده ناموفق بود.' : 'Could not load saved content.'}
+
+ ) : items.length === 0 ? (
+
+
+
{fa ? 'هنوز مطلبی ذخیره نکردهاید' : 'No saved content yet'}
+
{fa ? 'از دکمه نشانک کنار مطالب استفاده کنید.' : 'Use the bookmark button beside content.'}
+
+ ) : (
+
+ {items.map(item => (
+
+ {item.coverImage ? (
+
+ ) : (
+
+
+
+ )}
+
+
{item.contentType === 'podcast' ? (fa ? 'پادکست' : 'Podcast') : item.contentType}
+
+ {item.title}
+
+ {item.summary &&
{item.summary}
}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
/* ═══ Identity card (read ↔ inline edit) ═══ */
function IdentityCard({ member, fa }: { member: { name: string; email: string; phone?: string }; fa: boolean }) {
const { updateMember } = useAuth()
diff --git a/frontend/src/pages/RadarCategory/RadarCategory.tsx b/frontend/src/pages/RadarCategory/RadarCategory.tsx
index 8d78ff8..8276eb4 100644
--- a/frontend/src/pages/RadarCategory/RadarCategory.tsx
+++ b/frontend/src/pages/RadarCategory/RadarCategory.tsx
@@ -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
- {/* RIGHT SIDE — CMS cover image or fallback mockup, keeping background and tilt */}
-
-
-

-
+ {/* BOOK — matches the homepage banner sizing and responsive behavior */}
+
+

+
@@ -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() {
- {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.')}
)
}
- 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() {
{/* RIGHT (first in RTL): big featured */}
-
navigate(`/radar/post/${seed[0]?.id ?? 'r1'}`)} />
+ navigate(`/radar/post/${featured.id}`)}
+ />
{/* LEFT (second in RTL): latest box */}
{c.latestHeading}
- {latest.map((item, idx) => (
- navigate(`/radar/post/${seed[idx]?.id ?? seed[0]?.id ?? 'r1'}`)} />
+ {latest.map((item) => (
+ navigate(`/radar/post/${item.id}`)} />
))}
@@ -321,29 +371,31 @@ export default function RadarCategory() {
{/* ── FIRST GRID (4) ── */}
- {visible.slice(0, 4).map((item, idx) => (
+ {visible.slice(0, 4).map((item) => (
- navigate(`/radar/post/${seed[idx % seed.length].id}`)} />
+ navigate(`/radar/post/${item.id}`)} />
))}
- {/* ── SPECIAL BANNER (CMS banner for this radar page, else page default) ── */}
-
- {(() => {
- const cms = bannerFor('radar-' + category)
- 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
- })()}
-
+ {/* The special-analysis banner is intentionally limited to sustainability. */}
+ {category === 'sustainability' && (
+
+ {(() => {
+ 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
+ })()}
+
+ )}
{/* ── SECOND GRID (rest) ── */}
- {visible.slice(4).map((item, idx) => (
+ {visible.slice(4).map((item) => (
- navigate(`/radar/post/${seed[(idx + 4) % seed.length].id}`)} />
+ navigate(`/radar/post/${item.id}`)} />
))}
diff --git a/frontend/src/pages/RadarPost/RadarPost.tsx b/frontend/src/pages/RadarPost/RadarPost.tsx
index 753699f..cc0ed67 100644
--- a/frontend/src/pages/RadarPost/RadarPost.tsx
+++ b/frontend/src/pages/RadarPost/RadarPost.tsx
@@ -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 (
+
+ {isFa ? 'در حال بارگذاری مطلب…' : 'Loading article…'}
+
+ )
+ }
+
if (!item) return
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()
+ out.push()
run = []
}
blocks.forEach((b, i) => {
@@ -250,7 +273,7 @@ export default function RadarPost() {
{/* RELATED SIDEBAR */}
-
+
diff --git a/frontend/src/pages/Search/SearchResults.tsx b/frontend/src/pages/Search/SearchResults.tsx
index e8f8306..9578090 100644
--- a/frontend/src/pages/Search/SearchResults.tsx
+++ b/frontend/src/pages/Search/SearchResults.tsx
@@ -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 (
@@ -68,16 +83,16 @@ export default function SearchResults() {
{isFa ? 'نتایج جستجو' : 'Search results'}
- {q && (
+ {(q || tag) && (
{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}”`}
)}
- {!q ? (
+ {!q && !tag ? (
{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}
) : results.length === 0 ? (
@@ -99,6 +114,7 @@ export default function SearchResults() {
{r.badge}
{r.meta && {r.meta}}
+ {r.tags.map(item => #{item})}
{r.title}
{r.summary && (
diff --git a/frontend/src/pages/SteelDashboard/SteelDashboard.tsx b/frontend/src/pages/SteelDashboard/SteelDashboard.tsx
index 64b48de..93566ce 100644
--- a/frontend/src/pages/SteelDashboard/SteelDashboard.tsx
+++ b/frontend/src/pages/SteelDashboard/SteelDashboard.tsx
@@ -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
= { 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
return (
- setHover(true)}
- onMouseLeave={() => setHover(false)}
- >
-
- {hover && (
-
- {label}
-
- )}
-
-
-
+ />
+
+
)
}
/* ── Page ── */
export default function SteelDashboard() {
- const [activeSectionId, setActiveSectionId] = useState('world')
+ const [activeSectionId, setActiveSectionId] = useState('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
- 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 (
-
+
{/* page header */}
@@ -88,7 +82,7 @@ export default function SteelDashboard() {
STEEL FORESIGHT · DASHBOARD
- سامانه هوشمند رصد فولاد مبارکه
+ سامانه هوشمند رصد فولاد آینده
- {(() => { const C = SECTION_COMPONENTS[activeIdx]; return C ? : null })()}
+
- {/* right-side dock */}
-
- {SECTIONS.map((s) => (
-
+
+ {SECTIONS.map((s, index) => (
+ selectSection(s.id)}
+ Icon={s.Icon}
+ activeSectionId={activeSectionId}
+ onChange={selectSection}
/>
))}
-
+
)
}
diff --git a/frontend/src/pages/SteelDashboard/sections/IndustrySection.tsx b/frontend/src/pages/SteelDashboard/sections/IndustrySection.tsx
index 14ae31b..5a9f73a 100644
--- a/frontend/src/pages/SteelDashboard/sections/IndustrySection.tsx
+++ b/frontend/src/pages/SteelDashboard/sections/IndustrySection.tsx
@@ -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 = {
- '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: 'Barron’s / 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() {
●
- زنده · ۲۰ ژوئن ۲۰۲۵
+ زنده · ۲۳ تیر ۱۴۰۵ / ۱۳ ژوئیه ۲۰۲۶
@@ -395,7 +442,7 @@ function PricesModule() {