diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..c0acc44 --- /dev/null +++ b/PRODUCT.md @@ -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. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3fe63f8..432f654 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/public/hero-bg.mp4 b/frontend/public/hero-bg.mp4 index 188e0bc..61ce970 100644 --- a/frontend/public/hero-bg.mp4 +++ b/frontend/public/hero-bg.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6e2cb352e5bd25693baac943cc3aad44e6dc1595213bd64fc19f6a0230e57c8 -size 30295491 +oid sha256:5b42a2152713ac4d33a2bc3a17de92ad5ee741cb9632e56750c92e9789a9cc34 +size 28470516 diff --git a/frontend/src/app/layout/Footer.tsx b/frontend/src/app/layout/Footer.tsx index 87dcf67..dd4ef82 100644 --- a/frontend/src/app/layout/Footer.tsx +++ b/frontend/src/app/layout/Footer.tsx @@ -10,7 +10,25 @@ function scrollToTop() { /* curved top edge with a centered scroll-to-top button (matches the Figma) */ function FooterCurve() { return ( -
+
+ {/* scroll-to-top button sitting in the dip (left side) */}
+

{description}

- !hasScore && setScore(Utils.randomInt(0, max))}> - {hasScore ? 'جزئیات بیشتر' : 'محاسبه امتیاز'} + { + if (!hasScore) setScore(Utils.randomInt(0, max)) + if (hasDetails) setShowDetails((value) => !value) + }} + > + {hasScore ? (showDetails ? 'بستن جزئیات' : 'جزئیات بیشتر') : 'محاسبه امتیاز'} + {showDetails && hasDetails && ( +
+ {meaning && ( +
+
این شاخص چه چیزی را نشان می‌دهد؟
+

{meaning}

+
+ )} + + {basis && basis.length > 0 && ( +
+
داده‌های مبنا
+
    + {basis.map((item) =>
  • {item}
  • )} +
+
+ )} + + {implication && ( +
+
برداشت برای فولاد مبارکه
+

{implication}

+
+ )} + + {actions && actions.length > 0 && ( +
+
نیاز به اقدام
+
    + {actions.map((item) =>
  • {item}
  • )} +
+
+ )} + + {sources && sources.length > 0 && ( +
+
منابع و گزارش‌ها
+
+ {sources.map((source) => ( + +
{source.label}
+
{source.note}
+
+ ))} +
+
+ )} +
+ )} ) } diff --git a/frontend/src/components/ui/magic-text.tsx b/frontend/src/components/ui/magic-text.tsx index 5f8ed36..0b2f311 100644 --- a/frontend/src/components/ui/magic-text.tsx +++ b/frontend/src/components/ui/magic-text.tsx @@ -20,10 +20,12 @@ interface WordProps { const Word: React.FC = ({ children, progress, range }) => { const opacity = useTransform(progress, range, [0, 1]) return ( - - {children} - {children} - + <> + + {children} + {children} + {' '} + ) } @@ -45,7 +47,7 @@ export const MagicText: React.FC = ({ 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 = ({ 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 diff --git a/frontend/src/content/radarFuture.ts b/frontend/src/content/radarFuture.ts index 8c68444..395362d 100644 --- a/frontend/src/content/radarFuture.ts +++ b/frontend/src/content/radarFuture.ts @@ -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 = { technology: 'فناوری و نوآوری', } -const PANEL_API = (import.meta as ImportMeta & { env?: Record }).env?.VITE_PANEL_API || '' +const PANEL_API = import.meta.env.DEV + ? '' + : ((import.meta as ImportMeta & { env?: Record }).env?.VITE_PANEL_API || '') export async function bootstrapRadarFuture(): Promise { try { @@ -55,6 +59,8 @@ export async function bootstrapRadarFuture(): Promise { 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 }[]) : [], diff --git a/frontend/src/data/events.ts b/frontend/src/data/events.ts index 5b38103..8dc9af6 100644 --- a/frontend/src/data/events.ts +++ b/frontend/src/data/events.ts @@ -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 }).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 { try { const res = await fetch(`${PANEL_API}/api/events?limit=200`) @@ -103,11 +168,14 @@ export async function bootstrapEvents(): Promise { const fetched = await res.json() if (!Array.isArray(fetched) || fetched.length === 0) return events.length = 0 - events.push(...fetched.map((e: Record) => ({ - id: String(e.id), title: String(e.titleFa ?? ''), date: e.dateFa as number, - month: String(e.monthFa ?? ''), year: e.year as number, type: e.type as EventType, - location: String(e.locationFa ?? ''), city: String(e.cityFa ?? ''), - registrationOpen: Boolean(e.registrationOpen), description: String(e.descriptionFa ?? ''), - }))) + events.push(...fetched.map((e: Record) => { + 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 */ } } diff --git a/frontend/src/data/media.ts b/frontend/src/data/media.ts index 55b9686..7729c1e 100644 --- a/frontend/src/data/media.ts +++ b/frontend/src/data/media.ts @@ -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, 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, 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 }).env?.VITE_PANEL_API || '' export async function bootstrapMedia(): Promise { diff --git a/frontend/src/index.css b/frontend/src/index.css index a6a9844..7ee26b2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 247803c..f05bff1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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((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) { @@ -42,4 +64,4 @@ createRoot(document.getElementById('root')!).render( , -) \ No newline at end of file +) diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index 90468a6..74422f7 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -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() {
+ {/* ── THINKING FRAMEWORK ── */} + + {/* ── POLICY COUNCIL ── (hidden for now: whole section blurred + non-interactive) */}
@@ -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 (
- {source && {source}} + {source && } {date}
) } +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 && } + {source} + {!iconFirst && } + + ) + + if (!href) return {content} + + return ( + e.stopPropagation()} + > + {content} + + ) +} + /* ─── 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 }).env?.VITE_PANEL_API || '' + const PANEL_API = import.meta.env.DEV + ? '' + : ((import.meta as ImportMeta & { env?: Record }).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() {

{isFa ? hero.titleFa : hero.titleEn}

{(isFa ? hero.bodyFa : hero.bodyEn)[0] || ''}

-
+
)} @@ -483,7 +528,7 @@ export default function AtAGlance() {
- +
{isFa ? card.titleFa : card.titleEn}
@@ -520,7 +565,7 @@ export default function AtAGlance() {
- +

{isFa ? card.titleFa : card.titleEn}

{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}

diff --git a/frontend/src/pages/Events/Events.tsx b/frontend/src/pages/Events/Events.tsx index 19fca84..08636a1 100644 --- a/frontend/src/pages/Events/Events.tsx +++ b/frontend/src/pages/Events/Events.tsx @@ -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 = { + '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 = { } /* ── 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 */} -
- تقویم +
+ تقویم
{/* month nav */} -
- - - {J_MONTHS[month - 1]}ماه {faNum(year)} + + {J_MONTHS[month - 1]} ماه {faNum(year)} - ) })} @@ -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 = { - فروردین: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 */}
- استکهلم، سوئد + {nextEvent.location || nextEvent.city}

- اجلاس جهانی فولاد سبز + {nextEvent.title}

- مرجع نهایی برای تعیین استانداردهای کربن‌زدایی در زنجیره ارزش فولاد با حضور وزرای انرژی اتحادیه اروپا و مدیران ارشد ۵۰ شرکت برتر. + {nextEvent.description}

{/* countdown — inline below description */}
@@ -234,7 +339,15 @@ export default function Events() {
- { + 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() { { 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) */} -
- - "برای مشاهده جزئیات هر رویداد، روی تاریخ مورد نظر در تقویم کلیک کنید." - -
- {/* content row: text LEFT (flex 1) | image RIGHT (251px) — forced LTR */}
{/* text section */} @@ -297,7 +400,9 @@ export default function Events() { {/* date */}
- {selEvt.date} {selEvt.month} {selEvt.year} + + {selEvt.dateEnd && selEvt.dateEnd !== selEvt.date ? `${selEvt.date} تا ${selEvt.dateEnd}` : selEvt.date} {selEvt.month} {selEvt.year} +
@@ -332,9 +437,9 @@ export default function Events() {
{/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */}

{isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'} @@ -371,7 +476,7 @@ export default function Events() {

{/* NAVY PANEL */} -
+
{/* gold decorative bar */}
@@ -417,7 +522,7 @@ export default function Events() { ═══════════════════════════════════════════════ */}
{/* heading — gold right border */} -
+

{isFa ? 'سایر رویدادها' : 'Other Events'}

diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 0a35e5f..1a7f113 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -17,7 +17,6 @@ export default function Home() {
{/* هیرو */} {/* رادار آینده */} - {/* چارچوب اندیشه ما */} {/* آمار و داده — نبض صنعت */} {/* در یک نگاه */} {/* دیده‌بان رویدادها */} diff --git a/frontend/src/pages/Home/sections/AtAGlanceSection.tsx b/frontend/src/pages/Home/sections/AtAGlanceSection.tsx index 542dbb1..687fd39 100644 --- a/frontend/src/pages/Home/sections/AtAGlanceSection.tsx +++ b/frontend/src/pages/Home/sections/AtAGlanceSection.tsx @@ -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 Steel’s 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, 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(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 }).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 ( +
+ ) } 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'} {isFa ? '←' : '→'} @@ -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 ── */}
- {/* ── TEXT card side ── */}
{isFa ? it.date : it.dateEn} - {it.source} + {it.source && }
-

+

{isFa ? it.desc : it.descEn}

+ + + {isFa ? 'مطالعه بیشتر' : 'Read more'} + + @@ -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 {source} + + return ( + e.stopPropagation()} + > + {source} + + ) +} + function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClick: () => void; aria: string }) { return ( diff --git a/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx b/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx index af78371..d6f8493 100644 --- a/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx +++ b/frontend/src/pages/Home/sections/VideocastPodcastSection.tsx @@ -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>(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 */} -
+ {/* bookmark */} +
- -
-
{pod.epNum}
{/* title */} @@ -227,6 +303,11 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
{/* play + waveform */} + {pod.mediaUrl ? ( +
+ +
+ ) : (
+ )}
@@ -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 ( + + {fmt(current)} + {fmt(duration)} + +
+ + {failed &&
فایل صوتی قابل پخش نیست. آدرس فایل یا فرمت آن را بررسی کنید.
} +
+ ) +} + function WavePlayer({ episodeKey }: { episodeKey: string | number }) { const canvasRef = useRef(null) const animRef = useRef(0) @@ -308,67 +467,56 @@ function WavePlayer({ episodeKey }: { episodeKey: string | number }) { ) } -/* ─── active glow pill ─── */ -function ActivePill({ active }: { active: boolean }) { - return active ? ( - - ) : 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 ( - - ) -} - /* ══ 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>(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 (
@@ -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} -
-
-
- - {/* ── text column ── */} -
- - {/* ep badge */} -
- {pod.epNum} - - {pod.date} -
- - {/* title */} -

{pod[lang].title}

- - {/* guest */} -
-
- {pod[lang].guest} -
- - {/* body */} -

{pod[lang].body}

- - {/* ── AUDIO PLAYER ── */} -
- {pod.mediaUrl - ?
- - {/* bottom: rating only */} -
- - ★ {pod.rating} - -
- -
-
+
+ {pod[lang].title}
-
+
+ +
+
+
+ +

{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 ?
+ + + +
+

{fa ? 'پادکست‌ها' : 'Podcasts'}

{fa ? 'مشاهده همه' : 'View all'}
+
+
+
{podcast.epNum}

{podcast[lang].title}

{podcast.date}{(podcast.mediaUrl || podcast.duration) && <> · }
+
+
+
{podcast[lang].title}
+
+ +
+
{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 */} +
+ {title} +
@@ -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() {
{/* Price table */} - + @@ -534,8 +581,38 @@ function PricesModule() {
+
+ {prices.map((row) => { + const isExpanded = expandedPrice.has(row.product) + return ( +
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' }} + > +
+
+
{row.product}
+
{row.source}
+
+
{faDigits(row.price)}
+
+
+ {[['روزانه', row.daily, row.upD], ['ماهانه', row.monthly, row.upM], ['سالانه', row.yearly, row.upY]].map(([label, value, up]) => ( +
+
{label}
+ +
+ ))} +
+ {isExpanded &&

{faDigits(row.note)}

} +
+ ) + })} +
+ {/* Market context rows */} -
+
{marketContext.map((ctx, i) => (
{/* column hints */} -
+
# شرکت تولید فولاد خام ۲۰۲۴ (Mt) @@ -708,9 +785,9 @@ function CompetitorsModule() { }} > {c.isUs && } -
- {faN(c.rank)} -
+
+ {faN(c.rank)} +
@@ -719,24 +796,24 @@ function CompetitorsModule() {
{c.flag}{c.country}
-
+
{faN(c.outputMt)}
- 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}> + 0 ? '#16a34a' : c.growthPct < 0 ? '#dc2626' : '#64748b' }}> {c.growthPct > 0 ? '▲ ' : c.growthPct < 0 ? '▼ ' : ''}{faN(Math.abs(c.growthPct))}٪ -
+
{faN(c.techMix.eafDri)}٪ کم‌کربن
- {faN(c.utilizationPct)}٪ + {faN(c.utilizationPct)}٪ {isOpen ? '▲' : '▼'}
@@ -838,8 +915,8 @@ function RisksModule() {
{/* scatter */} -
- setHovered(null)}> +
+ setHovered(null)}> {/* quadrant zones */} @@ -887,9 +964,58 @@ function RisksModule() { })} +
+
اثر کم
احتمال کم
+
اثر کم
احتمال بالا
+
اثر بالا
احتمال کم
+
اثر بالا
احتمال بالا
+ {shown.map((risk) => { + const color = RISK_CAT[risk.category].color + const isActive = active?.id === risk.id + return ( +
+ +
+ شدت اثر: کم ← زیاد + احتمال وقوع: کم ← زیاد +
+ {/* hover/pin tooltip */} {active && ( -
+
{RISK_CAT[active.category].fa} {TREND_FA[active.trend].icon} {TREND_FA[active.trend].fa} @@ -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/اسلب/کویل تبدیل می‌شود؛ پس کیفیت و پایداری تأمین داخلی حیاتی است.', + 'Moody’s برای سنگ‌آهن بازه ۸۰ تا ۱۰۰ دلار در ۱۸ ماه آینده را محتمل دانسته؛ یعنی شوک شدید مثبت کمتر محتمل است، اما ضعف تقاضا و عرضه مازاد روی قیمت اثر دارد.', + 'رشد 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: 'Moody’s بازه ۸۰ تا ۱۰۰ دلار برای سنگ‌آهن را در ۱۸ ماه آینده مطرح کرده است.', + }, + { + 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 header */} -
-
- +
+ INDUSTRY INTELLIGENCE - پایش صنعت جهانی فولاد + پایش صنعت جهانی فولاد
- + ۱۴۰۴/۰۳/۳۰ · World Steel Association · Platts · MySteel
diff --git a/frontend/src/pages/SteelDashboard/sections/WorldSection.tsx b/frontend/src/pages/SteelDashboard/sections/WorldSection.tsx index 8a4f508..bce1b84 100644 --- a/frontend/src/pages/SteelDashboard/sections/WorldSection.tsx +++ b/frontend/src/pages/SteelDashboard/sections/WorldSection.tsx @@ -20,6 +20,63 @@ const SCANNER_CSS = ` 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +@media (max-width: 920px) { + .export-room-grid { grid-template-columns: 1fr !important; } + .export-room-detail-grid, + .export-room-evidence-grid { grid-template-columns: 1fr !important; } + .export-room-market-list { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +} +@media (max-width: 560px) { + .export-room-market-list { grid-template-columns: 1fr !important; } + .mega-trends-table { display: none; } + .mega-trends-mobile { + display: grid !important; + gap: 12px; + padding: 12px; + background: #f8fafc; + } + .mega-trends-summary { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px !important; + padding: 12px !important; + } + .mega-trends-summary .world-stat { + width: 100%; + min-width: 0 !important; + box-sizing: border-box; + padding: 9px 7px !important; + } + .mega-trends-filters { + justify-content: center !important; + padding: 12px !important; + } + .mega-trends-filters button { + min-width: 74px; + padding: 6px 12px !important; + } + .world-masthead { + align-items: center !important; + flex-direction: row !important; + flex-wrap: wrap !important; + gap: 7px !important; + } + .world-masthead-primary { + width: 100%; + justify-content: space-between !important; + gap: 10px !important; + } + .world-masthead-title { font-size: 18px !important; } + .world-masthead-en { display: none !important; } + .world-masthead-live { padding: 4px 9px !important; } + .world-masthead-updated { + width: 100%; + align-self: auto; + font-size: 10px !important; + line-height: 1.5; + text-align: right; + } +} `; // ─── Data ──────────────────────────────────────────────────────────────────── @@ -39,84 +96,418 @@ interface TrendRow { history: string[]; } +type ExportMarketTone = 'enter' | 'defend' | 'watch' | 'avoid'; + +interface ExportMarket { + id: string; + market: string; + region: string; + productFocus: string; + logisticsHint: string; +} + +interface DynamicExportSignal { + marketId: string; + score: number; + risk: number; + tone: ExportMarketTone; + decision: string; + pricePressure: string; + evidence: string; + action: string; + fitDrivers: { label: string; value: number; color: string }[]; + reports: { label: string; url: string; insight: string }[]; + updatedAt: string; + articleCount: number; +} + +const exportMarkets: ExportMarket[] = [ + { + id: 'iraq', + market: 'عراق', + region: 'بازار همسایه، پروژه‌محور', + productFocus: 'HRC، ورق گالوانیزه، ورق رنگی', + logisticsHint: 'بازار زمینی نزدیک؛ خبرهای TIR، بندر ام‌قصر و پروژه‌های عمرانی در امتیاز اثر می‌گذارند.', + }, + { + id: 'cis', + market: 'آسیای مرکزی', + region: 'کریدور زمینی، کمبود ورق تخت', + productFocus: 'HRC، CRC، ورق پوشش‌دار', + logisticsHint: 'مسیر زمینی شمال ایران و وابستگی وارداتی بازارهای کوچک‌تر در امتیاز اثر می‌گذارد.', + }, + { + id: 'gcc', + market: 'امارات و GCC', + region: 'پروژه، لجستیک دریایی، کیفیت مهم', + productFocus: 'ورق پوشش‌دار، گالوانیزه، HRC با گرید مشخص', + logisticsHint: 'مسیر دریایی کوتاه است، اما فشار قیمت چین/هند/ترکیه و هزینه حمل در امتیاز اثر دارد.', + }, + { + id: 'turkey', + market: 'ترکیه', + region: 'رقیب صنعتی نزدیک', + productFocus: 'اسلب و HRC فقط در شکاف عرضه', + logisticsHint: 'بازار نزدیک است، اما تولید داخلی و صادرات فعال ترکیه ریسک رقابت را بالا می‌برد.', + }, + { + id: 'india', + market: 'هند', + region: 'رشد سریع، اما رقیب ظرفیت‌ساز', + productFocus: 'گرید خاص، همکاری صنعتی، نه صادرات حجمی', + logisticsHint: 'رشد تقاضا مثبت است، اما ظرفیت‌سازی داخلی هند می‌تواند صادرات commodity را سخت کند.', + }, + { + id: 'eu', + market: 'اروپا', + region: 'کیفیت بالا، مانع کربن و تحریم', + productFocus: 'گرید با داده CO₂ قابل راستی‌آزمایی', + logisticsHint: 'کیفیت مهم است، اما CBAM، سهمیه واردات، تحریم و traceability تعیین‌کننده‌اند.', + }, +]; + +const exportDecisionMethod = [ + { label: 'تناسب محصول فولاد مبارکه', weight: 30, note: 'HRC، CRC، گالوانیزه، رنگی و اسلب با نیاز بازار مقصد تطبیق داده شده است.' }, + { label: 'دسترسی و هزینه لجستیک', weight: 20, note: 'فاصله زمینی/دریایی، مسیرهای فعال و ریسک حمل بررسی شده است.' }, + { label: 'فشار رقابت و قیمت', weight: 20, note: 'اثر صادرات چین، ظرفیت ترکیه/هند و ورود فولاد ارزان به بازارهای جایگزین لحاظ شده است.' }, + { label: 'ریسک مقررات و پرداخت', weight: 20, note: 'تعرفه، CBAM، سهمیه واردات، تحریم، ریسک وصول و ثبات مقررات وارد امتیاز شده است.' }, + { label: 'رشد تقاضای واقعی', weight: 10, note: 'پروژه‌های زیرساختی، ساخت‌وساز، تقاضای صنعتی و روند مصرف فولاد سنجیده شده است.' }, +]; + +const staticExportSignals: Record = { + iraq: { + marketId: 'iraq', + score: 84, + risk: 55, + tone: 'enter', + decision: 'ورود فعال، قرارداد کوتاه‌مدت و کنترل‌شده', + pricePressure: 'متوسط؛ فشار صادرات چین و ترکیه وجود دارد، اما مزیت فاصله و مسیر زمینی ایران بخشی از فشار قیمت را جبران می‌کند.', + evidence: + 'عراق برای فولاد مبارکه بازار نزدیک و پروژه‌محور است. فعال‌شدن TIR در عراق از ۲۰۲۵ مزیت مسیر زمینی را بهتر می‌کند. از طرف دیگر، طبق داده‌های OECD و روند صادرات چین، بازارهای خاورمیانه زیر فشار فولاد ارزان‌تر قرار گرفته‌اند؛ بنابراین ورود فعال باید با قرارداد کوتاه‌مدت، مشتری معتبر و قیمت‌گذاری روزانه انجام شود.', + action: 'تمرکز روی HRC، گالوانیزه و ورق رنگی برای مشتری‌های مصرف‌کننده مستقیم؛ فروش حجمی باز بدون کنترل اعتبار پیشنهاد نمی‌شود.', + fitDrivers: [ + { label: 'نزدیکی لجستیک', value: 92, color: '#22c55e' }, + { label: 'تناسب محصول', value: 84, color: '#1a6bcf' }, + { label: 'ریسک پرداخت/قیمت', value: 58, color: '#CD9E53' }, + ], + reports: [ + { + label: 'IRU: TIR goes live in Iraq, 2025', + url: 'https://www.iru.org/news-resources/newsroom/tir-goes-live-iraq-breakthrough-middle-east-trade-and-development', + insight: 'TIR از آوریل ۲۰۲۵ در عراق فعال شده و برای تجارت زمینی منطقه‌ای سیگنال مثبت لجستیکی است.', + }, + { + label: 'Mobarakeh Steel product portfolio', + url: 'https://www.msc.ir/', + insight: 'سبد ورق گرم، سرد، گالوانیزه و رنگی با نیاز بازار پروژه‌ای عراق همخوان است.', + }, + { + label: 'OECD Steel Outlook 2026', + url: 'https://www.oecd.org/industry/steel/', + insight: 'ظرفیت مازاد و صادرات بالای چین فشار قیمت را به بازارهای منطقه‌ای منتقل می‌کند.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, + cis: { + marketId: 'cis', + score: 78, + risk: 57, + tone: 'enter', + decision: 'ورود انتخابی، شبکه توزیع سبک', + pricePressure: 'متوسط؛ رقابت روسیه و چین جدی است، اما کمبود نسبی ورق تخت در بازارهای کوچک‌تر فرصت ایجاد می‌کند.', + evidence: + 'آسیای مرکزی برای ورق تخت، بازار وارداتی و کریدوری است. مزیت فولاد مبارکه، سبد flat products و دسترسی زمینی از شمال ایران است. ریسک از سمت روسیه، چین و نوسان مسیرهای ترانزیتی می‌آید؛ بنابراین استراتژی مناسب، موجودی سبک و فروش بر پایه قیمت HRC منطقه‌ای است.', + action: 'ایجاد نماینده/انبار سبک منطقه‌ای؛ تمرکز روی HRC، CRC و پوشش‌دار، نه فروش spot پراکنده.', + fitDrivers: [ + { label: 'تناسب ورق تخت', value: 88, color: '#22c55e' }, + { label: 'مزیت مسیر', value: 76, color: '#1a6bcf' }, + { label: 'ریسک رقبا', value: 61, color: '#CD9E53' }, + ], + reports: [ + { + label: 'Mobarakeh Steel products', + url: 'https://www.msc.ir/', + insight: 'تمرکز فولاد مبارکه روی ورق تخت برای بازارهای وارداتی منطقه مناسب است.', + }, + { + label: 'worldsteel annual production data', + url: 'https://worldsteel.org/steel-topics/statistics/annual-production-steel-data/', + insight: 'کشورهای کوچک‌تر منطقه نسبت به چین، روسیه، ترکیه و ایران ظرفیت فولادسازی محدودتری دارند.', + }, + { + label: 'OECD Steel Outlook 2026', + url: 'https://www.oecd.org/industry/steel/', + insight: 'ظرفیت مازاد جهانی، قیمت‌گذاری در بازارهای وارداتی را حساس‌تر کرده است.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, + gcc: { + marketId: 'gcc', + score: 76, + risk: 64, + tone: 'defend', + decision: 'ورود پریمیوم، نه جنگ قیمت', + pricePressure: 'بالا؛ چین، هند و ترکیه در بازار خلیج فارس فشار قیمت می‌آورند، پس فروش commodity ریسک دارد.', + evidence: + 'GCC بازار پروژه، انرژی، ساخت‌وساز و زیرساخت است و برای ورق پوشش‌دار و گرید قابل اتکا جذاب می‌ماند. اما تعرفه‌های آمریکا و اروپا باعث انحراف جریان فولاد به بازارهای جایگزین می‌شود. بنابراین فولاد مبارکه باید روی بسته محصولی، کیفیت و تحویل قابل اتکا رقابت کند، نه ارزان‌ترین قیمت.', + action: 'پیشنهاد بسته HRC + گالوانیزه + ورق رنگی برای پروژه‌های مشخص؛ قرارداد با clause تعدیل قیمت.', + fitDrivers: [ + { label: 'تقاضای پروژه', value: 83, color: '#22c55e' }, + { label: 'تناسب پوشش‌دار', value: 80, color: '#1a6bcf' }, + { label: 'فشار قیمت', value: 72, color: '#ef4444' }, + ], + reports: [ + { + label: 'AP: EU steel quota 18.3 Mt and 50% tariff', + url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a', + insight: 'سخت‌تر شدن اروپا می‌تواند فولاد صادراتی را به بازارهای جایگزین مثل GCC منحرف کند.', + }, + { + label: 'OECD Steel Outlook 2026', + url: 'https://www.oecd.org/industry/steel/', + insight: 'صادرات بالای چین و overcapacity جهانی فشار قیمت منطقه‌ای ایجاد می‌کند.', + }, + { + label: 'Mobarakeh Steel coated products', + url: 'https://www.msc.ir/', + insight: 'گالوانیزه و ورق رنگی برای پروژه‌های ساختمانی و صنعتی GCC fit بالاتری از فروش خام HRC دارد.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, + turkey: { + marketId: 'turkey', + score: 61, + risk: 70, + tone: 'watch', + decision: 'فروش فرصت‌محور، نه بازار حجمی', + pricePressure: 'بالا؛ ترکیه خودش تولیدکننده و صادرکننده بزرگ است و فقط در شکاف عرضه یا گرید خاص فرصت می‌دهد.', + evidence: + 'ترکیه در ۲۰۲۵ حدود ۳۸.۱ میلیون تن فولاد خام تولید کرده، در حالی که ایران حدود ۳۱.۸ میلیون تن تولید داشته است. نزدیکی جغرافیایی مزیت است، اما رقابت داخلی ترکیه و صادرات فعال این کشور ریسک را بالا می‌برد. بنابراین ترکیه نباید مقصد حجمی فولاد مبارکه باشد.', + action: 'فروش فقط در زمان spread مثبت منطقه‌ای، کمبود مقطعی یا سفارش گرید مشخص؛ پایش هفتگی قیمت HRC ترکیه لازم است.', + fitDrivers: [ + { label: 'نزدیکی بازار', value: 76, color: '#1a6bcf' }, + { label: 'رقابت داخلی', value: 82, color: '#ef4444' }, + { label: 'فرصت spot', value: 55, color: '#CD9E53' }, + ], + reports: [ + { + label: 'worldsteel annual production data, 2025', + url: 'https://worldsteel.org/steel-topics/statistics/annual-production-steel-data/', + insight: 'ترکیه ۳۸.۱ Mt و ایران ۳۱.۸ Mt فولاد خام در ۲۰۲۵ تولید کرده‌اند.', + }, + { + label: 'OECD Steel Outlook 2026', + url: 'https://www.oecd.org/industry/steel/', + insight: 'رقابت صادراتی در بازارهای جایگزین به‌خاطر overcapacity جهانی شدیدتر شده است.', + }, + { + label: 'Mobarakeh Steel flat products', + url: 'https://www.msc.ir/', + insight: 'اسلب و HRC برای ترکیه فقط در شکاف عرضه یا گرید/شرایط خاص منطقی است.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, + india: { + marketId: 'india', + score: 58, + risk: 74, + tone: 'watch', + decision: 'رصد و همکاری انتخابی', + pricePressure: 'بالا؛ رشد تقاضای هند مثبت است، اما رشد ظرفیت داخلی و زغال‌محور بودن بخشی از توسعه، رقابت صادراتی را سخت می‌کند.', + evidence: + 'هند در ۲۰۲۵ حدود ۱۶۴.۹ میلیون تن فولاد خام تولید کرده، ۱۰.۴٪ بالاتر از ۲۰۲۴. worldsteel برای ۲۰۲۶ رشد تقاضای هند را حدود ۹٪ پیش‌بینی کرده است. این رشد فرصت صنعتی ایجاد می‌کند، اما هند هم‌زمان رقیب بزرگ ظرفیت‌ساز آسیاست؛ پس صادرات commodity به هند برای فولاد مبارکه اولویت ندارد.', + action: 'رصد مشتری‌های گرید خاص، خودرویی یا صنعتی؛ عدم ورود به رقابت حجمی HRC مگر با حاشیه سود روشن.', + fitDrivers: [ + { label: 'رشد تقاضا', value: 90, color: '#22c55e' }, + { label: 'رشد ظرفیت داخلی', value: 86, color: '#ef4444' }, + { label: 'fit صادراتی مبارکه', value: 50, color: '#CD9E53' }, + ], + reports: [ + { + label: 'Times of India / worldsteel provisional 2025', + url: 'https://timesofindia.indiatimes.com/city/kolkata/indias-crude-steel-output-grows-10/articleshow/127499102.cms', + insight: 'تولید فولاد خام هند در ۲۰۲۵ به ۱۶۴.۹ Mt رسید و تقاضای ۲۰۲۶ حدود ۹٪ رشد پیش‌بینی شده است.', + }, + { + label: 'Global Energy Monitor steel tracker', + url: 'https://globalenergymonitor.org/projects/global-steel-plant-tracker/', + insight: 'رشد ظرفیت فولاد هند، فرصت تقاضا را با ریسک رقابت عرضه همراه می‌کند.', + }, + { + label: 'Mobarakeh Steel product portfolio', + url: 'https://www.msc.ir/', + insight: 'برای هند، گرید خاص یا همکاری صنعتی منطقی‌تر از فروش commodity است.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, + eu: { + marketId: 'eu', + score: 43, + risk: 91, + tone: 'avoid', + decision: 'احتیاط؛ فقط مسیر غیرمستقیم با داده کربن', + pricePressure: 'قیمت پریمیوم ممکن است، اما مانع قانونی بسیار بالا است: CBAM، سهمیه واردات، تعرفه و traceability.', + evidence: + 'اروپا از ۱ ژانویه ۲۰۲۶ وارد فاز قطعی CBAM شده و واردکننده‌های فولاد باید الزامات گزارش و گواهی کربن را رعایت کنند. هم‌زمان اتحادیه اروپا سهمیه بدون تعرفه فولاد را به ۱۸.۳ میلیون تن کاهش داده و بالاتر از آن تعرفه ۵۰٪ پیشنهاد/اعمال کرده است. برای فولاد مبارکه، بدون بسته داده CO₂ قابل‌راستی‌آزمایی و مسیر compliance، اروپا مقصد مستقیم پرریسک است.', + action: 'ساخت بسته carbon data، محاسبه CO₂ محصول، و فروش غیرمستقیم فقط با خریدار دارای مسیر compliance.', + fitDrivers: [ + { label: 'قیمت پریمیوم', value: 70, color: '#1a6bcf' }, + { label: 'ریسک CBAM/تعرفه', value: 92, color: '#ef4444' }, + { label: 'آمادگی داده CO₂', value: 42, color: '#CD9E53' }, + ], + reports: [ + { + label: 'European Commission: CBAM', + url: 'https://taxation-customs.ec.europa.eu/carbon-border-adjustment-mechanism_en', + insight: 'CBAM از ۱ ژانویه ۲۰۲۶ وارد دوره قطعی شده و گزارش/گواهی کربن برای واردات فولاد تعیین‌کننده است.', + }, + { + label: 'AP: EU steel quota 18.3 Mt and 50% tariff', + url: 'https://apnews.com/article/fe0c6ff0063a1679a2ed36b0f06da96a', + insight: 'اتحادیه اروپا سهمیه بدون تعرفه را به ۱۸.۳ Mt کاهش داده و تعرفه بالاتر از سهمیه را ۵۰٪ قرار داده است.', + }, + { + label: 'Guardian: EU steel measures from July 2026', + url: 'https://www.theguardian.com/business/2026/apr/14/uk-steel-exports-eu-risk-tariffs-quotas', + insight: 'از ژوئیه ۲۰۲۶ سخت‌گیری واردات فولاد اروپا، تجارت فولاد منطقه‌ای را محدودتر می‌کند.', + }, + ], + updatedAt: '2026-07-13T00:00:00+03:30', + articleCount: 3, + }, +}; + +const globalMarketPulse = [ + { + label: 'فشار مازاد ظرفیت', + value: 40, + stat: '۷۴۵ Mt ظرفیت مازاد تا ۲۰۲۸', + gaugeLabel: 'معادل حدود ۴۰٪ تولید جهانی ۲۰۲۵', + source: 'OECD Steel Outlook 2026', + meaning: 'بازار جهانی فولاد بیش از تقاضای واقعی ظرفیت دارد؛ یعنی رقابت قیمتی و دامپینگ صادراتی شدیدتر می‌شود.', + impact: 'برای ایران: حاشیه سود صادرات کم می‌شود و فروش در بازارهای منطقه‌ای نیاز به مزیت قیمت/کیفیت دقیق‌تر دارد.', + dur: 1.6, + }, + { + label: 'CBAM اروپا', + value: 50, + stat: 'بیش از ۵۰٪ انتشار بخش‌های ETS', + gaugeLabel: 'پوشش انتشار در فاز کامل CBAM', + source: 'European Commission, 2026', + meaning: 'واردکننده‌های اروپایی آهن و فولاد باید کربن نهفته کالا را گزارش کنند و گواهی CBAM بخرند.', + impact: 'برای ایران: صادرات مستقیم یا غیرمستقیم به زنجیره اروپا بدون داده معتبر CO₂ پرریسک‌تر و گران‌تر می‌شود.', + dur: 1.8, + }, + { + label: 'قفل زغال‌سنگ', + value: 84, + stat: '۸۴٪ طرح‌های جدید هند کوره بلند', + gaugeLabel: 'سهم کوره بلند در ظرفیت جدید هند', + source: 'Global Energy Monitor / AP, 2025', + meaning: 'بخش بزرگی از ظرفیت جدید فولاد آسیا هنوز زغال‌محور است؛ گذار به فولاد کم‌کربن کندتر از هدف‌های اقلیمی پیش می‌رود.', + impact: 'برای ایران: مسیر DRI گازی مزیت نسبی دارد، اما باید با برق پاک و گزارش کربن پشتیبانی شود.', + dur: 2.0, + }, + { + label: 'حمایت‌گرایی تجاری', + value: 50, + stat: 'آمریکا ۵۰٪ تعرفه؛ اروپا سهمیه ۱۸٫۳ Mt', + gaugeLabel: 'تعرفه Section 232 آمریکا', + source: 'AP / U.S. Section 232 / EU proposal', + meaning: 'آمریکا تعرفه فولاد و آلومینیوم را به ۵۰٪ رسانده و اروپا هم رژیم سخت‌گیرانه‌تری برای واردات فولاد پیشنهاد کرده است.', + impact: 'برای ایران: جریان تجارت منحرف می‌شود؛ فولاد چین، هند و ترکیه فشار بیشتری روی بازارهای خاورمیانه می‌آورد.', + dur: 2.2, + }, +]; + const megaTrends: TrendRow[] = [ { - trend: 'هوش مصنوعی در فولادسازی', - signal: '↑', - signalLabel: 'صعودی', - change: '+42٪ سرمایه‌گذاری', - driver: 'اتوماسیون DRI و کنترل کیفی', + trend: 'مازاد عرضه و صادرات چین', + signal: '↓', + signalLabel: 'هشدار', + change: 'صادرات ۲۰۲۵: ۱۳۱ Mt', + driver: 'رکود ساخت‌وساز چین و فشار ظرفیت', importance: 94, - sparkData: [55, 62, 71, 80, 94], + sparkData: [1019, 1005, 961, 120, 131], analysis: - 'سرمایه‌گذاری جهانی در هوش مصنوعی فولادسازی از ۲۰۲۲ رشد شتابانی داشته. سیستم‌های بینایی ماشین در کنترل کیفی نورد، کاهش ضایعات ۱۵٪ را ثابت کرده‌اند.', - links: ['worldsteel.org/AI-report', 'mckinsey.com/steel-AI-2025'], - history: ['Q1 2025: +12٪', 'Q2 2025: +24٪', 'Q3 2025: +35٪', 'Q4 2025: +42٪'], + 'تولید فولاد خام چین در ۲۰۲۵ به حدود ۹۶۰.۸ میلیون تن کاهش یافته، اما صادرات فولاد این کشور به حدود ۱۳۱ میلیون تن رسیده است. افت تقاضای داخلی چین به بازارهای صادراتی منتقل شده و فشار قیمتی روی خاورمیانه، آسیای میانه و شمال آفریقا بیشتر می‌شود.', + links: ['OECD Steel Outlook 2026', 'worldsteel crude steel production 2025', 'AP/Economic Times coverage'], + history: ['تولید چین ۲۰۲۳: ۱۰۲۸.۹ Mt', 'تولید چین ۲۰۲۴: ۱۰۰۵.۱ Mt', 'تولید چین ۲۰۲۵: ۹۶۰.۸ Mt', 'صادرات چین ۲۰۲۵: حدود ۱۳۱ Mt'], }, { - trend: 'انرژی‌های تجدیدپذیر', + trend: 'CBAM و قیمت کربن اروپا', signal: '↑', signalLabel: 'صعودی', - change: '+28٪ ظرفیت', - driver: 'هزینه برق EAF', - importance: 88, - sparkData: [48, 58, 67, 75, 88], - analysis: - 'پیوند انرژی خورشیدی و بادی به EAF در اروپا و هند کاهش هزینه تولید فولاد را ممکن کرده. در ایران ظرفیت خورشیدی سه برابر شده.', - links: ['iea.org/renewables-steel', 'irena.org/industry'], - history: ['Q1 2025: +8٪', 'Q2 2025: +15٪', 'Q3 2025: +21٪', 'Q4 2025: +28٪'], - }, - { - trend: 'فولاد سبز (H₂-DRI)', - signal: '↑', - signalLabel: 'صعودی', - change: '+65٪ پروژه جدید', - driver: 'CBAM اروپا ژانویه ۲۰۲۶', + change: 'فعال از ۱ ژانویه ۲۰۲۶', + driver: 'اظهارنامه کربن و خرید گواهی CBAM', importance: 91, - sparkData: [30, 45, 60, 78, 91], + sparkData: [0, 25, 50, 75, 100], analysis: - 'CBAM اروپا کربن‌زدایی را برای صادرکنندگان اجباری کرده. H2 Green Steel در سوئد اولین تولید تجاری را آغاز کرد. ایران فرصت ورود با گاز طبیعی دارد.', - links: ['h2greensteel.com', 'ec.europa.eu/CBAM'], - history: ['Q1 2025: +20٪', 'Q2 2025: +38٪', 'Q3 2025: +52٪', 'Q4 2025: +65٪'], + 'CBAM از ۱ ژانویه ۲۰۲۶ وارد دوره قطعی شده است. واردکنندگان اروپایی کالاهای فولادی بالای آستانه ۵۰ تن باید مجوز CBAM بگیرند، انتشار نهفته را اعلام کنند و از ۲۰۲۷ گواهی متناظر تسلیم کنند. برای فولادساز ایرانی، داده قابل‌راستی‌آزمایی CO₂ و مسیر کاهش کربن به شرط ورود به زنجیره اروپا تبدیل می‌شود.', + links: ['European Commission CBAM', 'DG TAXUD CBAM guidance 2026'], + history: ['اکتبر ۲۰۲۳: شروع دوره انتقالی', '۱ ژانویه ۲۰۲۶: شروع دوره قطعی', '۳۰ سپتامبر ۲۰۲۷: نخستین اظهارنامه سالانه'], }, { - trend: 'اقتصاد چرخشی', + trend: 'رشد فولاد هند', + signal: '↑', + signalLabel: 'صعودی', + change: 'تولید ۲۰۲۵: ۱۶۴.۹ Mt', + driver: 'زیرساخت، ساختمان و تقاضای داخلی هند', + importance: 88, + sparkData: [125, 141, 149, 165, 179], + analysis: + 'هند در ۲۰۲۵ حدود ۱۶۴.۹ میلیون تن فولاد خام تولید کرده، حدود ۱۰.۴٪ بیشتر از ۲۰۲۴. برآوردهای Short Range Outlook برای ۲۰۲۶ نیز رشد تقاضای هند را حدود ۹٪ نشان می‌دهد. نتیجه برای ایران: هند هم بازار مصرف بزرگ است، هم رقیب ظرفیت‌ساز در آسیا.', + links: ['worldsteel provisional 2025 data', 'worldsteel Short Range Outlook', 'Times of India steel output coverage'], + history: ['۲۰۲۲: ۱۲۵.۳ Mt', '۲۰۲۳: ۱۴۰.۸ Mt', '۲۰۲۴: ۱۴۹.۴ Mt', '۲۰۲۵: ۱۶۴.۹ Mt', 'تقاضای ۲۰۲۶: حدود +۹٪'], + }, + { + trend: 'گذار کند به EAF و فولاد کم‌کربن', signal: '→', signalLabel: 'ثابت', - change: '+8٪ بازیافت', - driver: 'کمبود قراضه در آسیا', - importance: 72, - sparkData: [60, 63, 68, 70, 72], - analysis: - 'رشد اقتصاد چرخشی محدود به عرضه قراضه شده. کمبود قراضه در آسیا قیمت را بالا نگه داشته. رصد بازارهای آفریقا برای منابع جدید توصیه می‌شود.', - links: ['scrapmonitor.com', 'BIR-world-report-2025'], - history: ['Q1 2025: +2٪', 'Q2 2025: +4٪', 'Q3 2025: +6٪', 'Q4 2025: +8٪'], - }, - { - trend: 'خودروهای الکتریکی', - signal: '↑', - signalLabel: 'صعودی', - change: '+18٪ تقاضا EV', - driver: 'AHSS و فولاد پیشرفته', + change: 'EAF جهانی: حدود ۳۴٪', + driver: 'قراضه، برق پاک و آهن اسفنجی کم‌کربن', importance: 76, - sparkData: [42, 52, 61, 69, 76], + sparkData: [30, 31, 32, 34, 37], analysis: - 'EV هر دستگاه به ۹۰۰ کیلو فولاد نیاز دارد. AHSS سبک‌تر و مقاوم‌تر تقاضای جدیدی برای فولاد پیشرفته ایجاد کرده که ایران می‌تواند وارد این بازار شود.', - links: ['IEA-EV-Outlook-2025', 'worldautosteel.org'], - history: ['Q1 2025: +5٪', 'Q2 2025: +10٪', 'Q3 2025: +14٪', 'Q4 2025: +18٪'], + 'مسیر کوره قوس الکتریکی در جهان رشد کرده، اما طبق پوشش Global Energy Monitor هنوز حدود ۳۴٪ ظرفیت/تولید جهانی را تشکیل می‌دهد؛ هدف سناریوهای اقلیمی برای ۲۰۳۰ نزدیک ۳۷٪ است. مزیت ایران در DRI گازی زمانی قابل دفاع می‌ماند که برق پاک، بهره‌وری انرژی و گزارش کربن هم اضافه شود.', + links: ['Global Energy Monitor steel decarbonisation 2025/2026', 'IEA iron and steel pathway'], + history: ['۲۰۲۴/۲۰۲۵: EAF حدود ۳۴٪', 'هدف مسیر اقلیمی ۲۰۳۰: حدود ۳۷٪', 'ریسک اصلی: کمبود قراضه باکیفیت و برق ارزان پاک'], }, { - trend: 'ژئوپلیتیک عرضه', + trend: 'قفل زغال‌سنگ در توسعه آسیا', signal: '↓', - signalLabel: 'نزولی', - change: '۲۰۷ اقدام ضد‌دامپینگ', - driver: 'صادرات ۱۱۷Mt چین', + signalLabel: 'هشدار', + change: 'هند: ۸۴٪ طرح‌ها کوره بلند', + driver: 'ظرفیت جدید زغال‌محور در هند', importance: 85, - sparkData: [90, 88, 87, 86, 85], + sparkData: [60, 70, 80, 84, 84], analysis: - 'صادرات بی‌سابقه چین فشار قیمتی را در تمام بازارها ایجاد کرده. اقدامات ضد‌دامپینگ در ۵۰ کشور فعال شده. ایران در موقعیت حساسی قرار دارد.', - links: ['WTO-antidumping-monitor', 'steelorbis.com/china-exports'], - history: ['Q1 2025: 150 اقدام', 'Q2 2025: 170', 'Q3 2025: 190', 'Q4 2025: 207'], + 'گزارش Global Energy Monitor نشان می‌دهد هند با برنامه افزایش ظرفیت از حدود ۲۰۰ به بیش از ۳۳۰ میلیون تن تا ۲۰۳۰، بخش عمده توسعه آهن‌سازی را زغال‌محور پیش می‌برد؛ در برخی پوشش‌ها ۸۴٪ آهن‌سازی اولیه طرح‌های جدید بر پایه کوره بلند ذکر شده است. این روند هم ریسک اقلیمی دارد، هم احتمال رقابت قیمتی با فولاد پرکربن را بالا می‌برد.', + links: ['Global Energy Monitor', 'AP India steel expansion report', 'Financial Times GEM coverage'], + history: ['ظرفیت فعلی هند: حدود ۲۰۰ Mt', 'هدف ۲۰۳۰: بیش از ۳۳۰ Mt', 'سهم کوره بلند در طرح‌های جدید: حدود ۸۴٪'], + }, + { + trend: 'دیوارهای تجاری فولاد', + signal: '↓', + signalLabel: 'هشدار', + change: 'آمریکا: ۵۰٪ تعرفه', + driver: 'Section 232 و پیشنهاد سهمیه فولاد اروپا', + importance: 83, + sparkData: [25, 25, 50, 50, 50], + analysis: + 'آمریکا در ژوئن ۲۰۲۵ تعرفه فولاد و آلومینیوم را به ۵۰٪ رساند. اتحادیه اروپا نیز پیشنهاد کرده سهمیه بدون تعرفه فولاد به ۱۸.۳ میلیون تن کاهش یابد و بالاتر از آن تعرفه ۵۰٪ اعمال شود. این سیاست‌ها تجارت را منحرف می‌کند و فولاد ارزان‌تر چین، هند و ترکیه را به بازارهای جایگزین از جمله خاورمیانه هل می‌دهد.', + links: ['U.S. Section 232 steel tariff coverage', 'AP EU steel quota proposal', 'European Commission steel safeguards'], + history: ['مارس ۲۰۲۵: تعرفه آمریکا ۲۵٪', 'ژوئن ۲۰۲۵: تعرفه آمریکا ۵۰٪', 'پیشنهاد اروپا: سهمیه ۱۸.۳ Mt و تعرفه ۵۰٪ بالاتر از سهمیه'], }, ]; @@ -421,7 +812,7 @@ function MegaTrendsModule() { return ( {/* summary strip */} -
+
@@ -429,7 +820,7 @@ function MegaTrendsModule() {
{/* Filter pills */} -
- +
@@ -581,6 +972,63 @@ function MegaTrendsModule() { })}
ترند
+ +
+ {filtered.map((row) => { + const origIdx = megaTrends.indexOf(row) + const isExpanded = expandedRow === origIdx + return ( +
setExpandedRow(isExpanded ? null : origIdx)} + style={{ + background: isExpanded ? '#eff6ff' : '#fff', + border: `1px solid ${isExpanded ? '#bfdbfe' : '#e2e8f0'}`, + borderRadius: 12, + padding: 12, + cursor: 'pointer', + }} + > +
+
+
{row.trend}
+
{row.signal} {row.signalLabel}
+
+ {isExpanded ? '▲' : '▼'} +
+ +
+
+
تغییر ۱۲ماهه
+
{faDigits(row.change)}
+
+
+
محرک اصلی
+
{faDigits(row.driver)}
+
+
+ +
+
+ اهمیت{faNum(row.importance)} +
+ +
+ + {isExpanded && ( +
+

{faDigits(row.analysis)}

+
+ {row.history.map((history) => ( + ‹ {faDigits(history)} + ))} +
+
+ )} +
+ ) + })} +
); } @@ -714,6 +1162,7 @@ const tchip = (on: boolean, color: string): React.CSSProperties => ({ function WStat({ label, value, color, i = 0 }: { label: string; value: string; color: string; i?: number }) { return ( market.id === selectedId) ?? exportMarkets[0]; + const selectedSignal = signals[selected.id]; + const signalList = Object.values(signals); + const enterCount = signalList.filter((signal) => signal.tone === 'enter').length; + const avgScore = Math.round(signalList.reduce((sum, signal) => sum + signal.score, 0) / signalList.length); + const highRiskCount = signalList.filter((signal) => signal.risk >= 70).length; + + const toneStyle: Record = { + enter: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', dot: '#22c55e', label: 'ورود' }, + defend: { bg: '#eff6ff', fg: '#1d4ed8', border: '#bfdbfe', dot: '#1a6bcf', label: 'دفاع' }, + watch: { bg: '#fffbeb', fg: '#92400e', border: '#fde68a', dot: '#CD9E53', label: 'رصد' }, + avoid: { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', dot: '#ef4444', label: 'احتیاط' }, + }; + + const selectedTone = toneStyle[selectedSignal?.tone ?? 'watch']; + const displayScore = selectedSignal ? faDigits(String(selectedSignal.score)) : 'N/A'; + const displayRisk = selectedSignal ? faDigits(String(selectedSignal.risk)) : 'N/A'; + + return ( + + +
+
+ +
+
+
+ تصمیم بازار، نه نمایش خبر +
+

+ کدام بازار برای سبد ورق فولاد مبارکه ارزش ورود دارد؟ +

+

+ این نسخه برای شبکه داخلی، snapshot منبع‌دار دارد: هر امتیاز با گزارش مشخص، عدد واقعی و لینک منبع نمایش داده می‌شود. +

+
+ + Snapshot تأییدشده: {faDigits(String(signalList.length))} بازار + + + آخرین بازبینی: ۲۳ تیر ۱۴۰۵، ۱۳ ژوئیه ۲۰۲۶ + +
+
+ {exportDecisionMethod.map((method) => ( + + {method.label}: {faDigits(String(method.weight))}٪ + + ))} +
+
+ +
+ {[ + { label: 'بازار قابل ورود', value: signalList.length ? faDigits(String(enterCount)) : '...', color: '#22c55e' }, + { label: 'میانگین جذابیت', value: avgScore === null ? '...' : faDigits(String(avgScore)), color: '#93c5fd' }, + { label: 'ریسک بالا', value: signalList.length ? faDigits(String(highRiskCount)) : '...', color: '#fca5a5' }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+
+
+ +
+
+
+ انتخاب بازار هدف +
+
+ {exportMarkets.map((market) => { + const active = market.id === selected.id; + const marketSignal = signals[market.id]; + const tone = toneStyle[marketSignal?.tone ?? 'watch']; + + return ( + + ); + })} +
+
+ +
+ + +
+
+
+ + + {selectedSignal ? selectedTone.label : 'بدون داده'} + +
{selected.market}
+
{selected.region}
+
+ +
+
+
جذابیت بازار
+
{displayScore}
+
+
+
ریسک اجرایی
+
= 70 ? '#b91c1c' : '#92400e', fontSize: 26, fontWeight: 900 }}>{displayRisk}
+
+
+
تصمیم
+
+ {selectedSignal?.decision ?? 'داده کافی نیست'} +
+
+
+ +
+ {(selectedSignal?.fitDrivers ?? []).map((driver) => ( +
+
+ {driver.label} + {faDigits(String(driver.value))} +
+
+
+
+
+ ))} +
+
+ +
+
نسخه عملیاتی
+
+ {selected.productFocus} +
+
+
+
فشار قیمت
+
{selectedSignal?.pricePressure ?? 'پس از دریافت خبرها محاسبه می‌شود.'}
+
+
+
حمل و مسیر
+
{selected.logisticsHint}
+
+
+
+
+ +
+
+
+ چرا این تصمیم؟ +
+
+ {selectedSignal?.evidence ?? 'برای این بازار snapshot منبع‌دار ثبت نشده است.'} +
+
+
+
+ اقدام پیشنهادی +
+
+ {selectedSignal?.action ?? 'پس از دریافت داده زنده پیشنهاد ساخته می‌شود.'} +
+
+
+ +
+
+
خبرها و گزارش‌هایی که امتیاز از آن ساخته شد
+
+ {selectedSignal ? `تعداد خبر: ${faDigits(String(selectedSignal.articleCount))}` : 'در انتظار داده'} +
+
+
+ {(selectedSignal?.reports ?? []).length > 0 ? selectedSignal!.reports.map((report) => ( + +
{report.label}
+
{report.insight}
+
+ )) : ( +
+ برای این بازار منبع قابل استفاده ثبت نشده است. +
+ )} +
+
+ +
+ منبع مدل: + گزارش‌های رسمی/خبری معتبر + وزن‌دهی داخلی قابل مشاهده + + {selectedSignal ? `آخرین بازبینی: ${faDigits(new Date(selectedSignal.updatedAt).toLocaleDateString('fa-IR'))}` : 'بازبینی نشده'} + +
+ + +
+
+ + + ); +} + function th(): React.CSSProperties { return { background: '#f1f5f9', @@ -1159,14 +1981,14 @@ export default function WorldSection() { paddingBottom: 10, borderBottom: '2px solid #1e40af', direction: 'rtl', - }}> -
+ }} className="world-masthead"> +
+ }} className="world-masthead-title"> دیدن جهان + }} className="world-masthead-en"> GLOBAL STEEL INTELLIGENCE @@ -1191,7 +2013,7 @@ export default function WorldSection() { background: '#f0fdf4', padding: '2px 8px', borderRadius: 20, - }}> + }} className="world-masthead-live"> داده‌ها به‌روز @@ -1202,12 +2024,12 @@ export default function WorldSection() { fontFamily: 'Vazir, sans-serif', color: '#94a3b8', letterSpacing: '0.5px', - }}> - بروزرسانی: ۳۰ خرداد ۱۴۰۵  |  Q2 2026 + }} className="world-masthead-updated"> + بروزرسانی: ۲۰ تیر ۱۴۰۵  |  ۱۱ ژوئیه ۲۰۲۶
- {/* Global market pulse — AnimatedRadialChart gauges */} + {/* Global market pulse — sourced risk cards */} - {[ - { label: 'حمایت‌گرایی تجاری', value: 68, sub: '۲۰۷ اقدام ضددامپینگ فعال', dur: 1.6 }, - { label: 'پوشش CBAM اروپا', value: 45, sub: 'اجرا از ژانویه ۲۰۲۶', dur: 1.8 }, - { label: 'پذیرش فولاد سبز', value: 12, sub: 'هدف ۳۰٪ تا ۲۰۳۰', dur: 2.0 }, - { label: 'ظرفیت مازاد چین', value: 82, sub: '۱۱۷ Mt صادرات ۲۰۲۴', dur: 2.2 }, - ].map((item, i) => ( + {globalMarketPulse.map((item, i) => ( -
{item.label}
- -
{item.sub}
+
+
+
{item.label}
+
{item.gaugeLabel}
+
+
+ +
+ +
+ +
+ {item.stat} +
+ +
+ {item.meaning} +
+ +
+ {item.impact} +
+ +
+ منبع: {item.source} +
))}
@@ -1263,6 +2129,7 @@ export default function WorldSection() { + diff --git a/frontend/src/utils/dateSort.ts b/frontend/src/utils/dateSort.ts new file mode 100644 index 0000000..9a4fd43 --- /dev/null +++ b/frontend/src/utils/dateSort.ts @@ -0,0 +1,90 @@ +const faDigits = '۰۱۲۳۴۵۶۷۸۹' +const arDigits = '٠١٢٣٤٥٦٧٨٩' + +const faMonths: Record = { + فروردین: 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 = (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 = (items: T[]) => + [...items].sort((a, b) => getPublishedSortKey(b) - getPublishedSortKey(a)) diff --git a/frontend/src/utils/sourceLinks.ts b/frontend/src/utils/sourceLinks.ts new file mode 100644 index 0000000..9ebb9dc --- /dev/null +++ b/frontend/src/utils/sourceLinks.ts @@ -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 +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5cd4727..182fd80 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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, }, }, }, diff --git a/panel/content-generator.js b/panel/content-generator.js index 32ea999..ec663c4 100644 --- a/panel/content-generator.js +++ b/panel/content-generator.js @@ -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', }); diff --git a/panel/db.js b/panel/db.js index 3981170..622e237 100644 --- a/panel/db.js +++ b/panel/db.js @@ -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, diff --git a/panel/public/app.js b/panel/public/app.js index 2827e4d..f952f18 100644 --- a/panel/public/app.js +++ b/panel/public/app.js @@ -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 = {}) { + ${contentType === 'podcast' ? ` + ` : ``} + ${hasAuthor ? `
- + +
@@ -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), }; diff --git a/panel/server.js b/panel/server.js index 037e53e..3d39da5 100644 --- a/panel/server.js +++ b/panel/server.js @@ -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);