steelforesight/frontend/src/pages/PostDetail/PostDetail.tsx

386 lines
21 KiB
TypeScript

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