157 lines
7.9 KiB
TypeScript
157 lines
7.9 KiB
TypeScript
import { useState } from 'react'
|
||
|
||
const NAVY = '#032340'
|
||
const GOLD = '#CD9E53'
|
||
const NEON = '#d4ff2e' // neon yellow for conference-date markers
|
||
|
||
type EventItem = {
|
||
g: Date // Gregorian date of the event
|
||
title: string
|
||
city: string
|
||
time: string
|
||
type: string
|
||
color: string
|
||
}
|
||
|
||
/* steel conferences during اردیبهشت ۱۴۰۵ (anchor month below) */
|
||
const EVENTS: EventItem[] = [
|
||
{ g: new Date(2026, 3, 28), title: 'کنفرانس فولاد خاورمیانه', city: 'دبی، امارات', time: '۰۹:۰۰ – ۱۷:۰۰', type: 'کنفرانس بینالمللی', color: '#9b1c1c' },
|
||
{ g: new Date(2026, 4, 5), title: 'نمایشگاه متالکس ۱۴۰۵', city: 'تهران، ایران', time: '۱۰:۰۰ – ۱۸:۰۰', type: 'نمایشگاه', color: GOLD },
|
||
{ g: new Date(2026, 4, 12), title: 'نشست تخصصی زنجیره فولاد', city: 'اصفهان، ایران', time: '۱۴:۰۰ – ۱۸:۰۰', type: 'نشست تخصصی', color: '#1e8449' },
|
||
{ g: new Date(2026, 4, 18), title: 'وبینار فولاد سبز و کربنزدایی', city: 'آنلاین', time: '۱۶:۰۰ – ۱۸:۰۰', type: 'وبینار', color: '#2471a3' },
|
||
]
|
||
|
||
const ANCHOR = new Date(2026, 4, 10) // a day inside اردیبهشت ۱۴۰۵
|
||
const J_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] // شنبه … جمعه
|
||
const faDigits = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false })
|
||
|
||
/* Jalali year/month/day for a Gregorian Date, via the browser's Persian calendar */
|
||
function jParts(d: Date) {
|
||
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { day: 'numeric', month: 'numeric', year: 'numeric' }).formatToParts(d)
|
||
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value)
|
||
return { day: get('day'), month: get('month'), year: get('year') }
|
||
}
|
||
|
||
const sameDay = (a: Date, b: Date) =>
|
||
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||
|
||
function buildMonth(anchor: Date) {
|
||
const { month, year } = jParts(anchor)
|
||
// walk back to the 1st of this Jalali month
|
||
const first = new Date(anchor)
|
||
while (jParts(first).day !== 1) first.setDate(first.getDate() - 1)
|
||
// collect every day until the Jalali month rolls over
|
||
const days: Date[] = []
|
||
const cur = new Date(first)
|
||
while (jParts(cur).month === month && jParts(cur).year === year) {
|
||
days.push(new Date(cur))
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
const firstCol = (first.getDay() + 1) % 7 // Sat→0 … Fri→6
|
||
return { month, year, days, firstCol }
|
||
}
|
||
|
||
export default function EventCalendar() {
|
||
const { month, year, days, firstCol } = buildMonth(ANCHOR)
|
||
const [hover, setHover] = useState<number | null>(null)
|
||
|
||
const eventFor = (d: Date) => EVENTS.findIndex((e) => sameDay(e.g, d))
|
||
|
||
return (
|
||
<div style={{
|
||
background: '#fff', borderRadius: 18, padding: '22px 22px 18px',
|
||
boxShadow: '0 24px 60px -34px rgba(3,35,64,0.4)', border: '1px solid rgba(3,35,64,0.08)',
|
||
direction: 'rtl', maxWidth: 360, margin: '0 auto', width: '100%',
|
||
}}>
|
||
{/* header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '2px', color: GOLD }}>تقویم رویدادها</div>
|
||
<div style={{ fontSize: 16, fontWeight: 900, color: NAVY }}>{J_MONTHS[month - 1]} {faDigits(year)}</div>
|
||
</div>
|
||
|
||
{/* weekday row */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 6 }}>
|
||
{WEEKDAYS.map((w, i) => (
|
||
<div key={i} style={{ textAlign: 'center', fontSize: 10, fontWeight: 700, color: 'rgba(3,35,64,0.45)', padding: '4px 0' }}>{w}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* day grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
|
||
{Array.from({ length: firstCol }).map((_, i) => <div key={`b${i}`} />)}
|
||
{days.map((d, i) => {
|
||
const j = jParts(d)
|
||
const ev = eventFor(d)
|
||
const marked = ev >= 0
|
||
const isHover = hover === ev && marked
|
||
return (
|
||
<div key={i} style={{ position: 'relative', aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<button
|
||
className={marked ? 'ev-breathe' : undefined}
|
||
onMouseEnter={() => marked && setHover(ev)}
|
||
onMouseLeave={() => setHover(null)}
|
||
style={{
|
||
position: 'relative', zIndex: 2,
|
||
width: 34, height: 34, borderRadius: '50%', border: 'none', cursor: marked ? 'pointer' : 'default',
|
||
fontFamily: 'inherit', fontSize: 13, fontWeight: marked ? 900 : 500,
|
||
color: marked ? NAVY : 'rgba(3,35,64,0.7)',
|
||
background: marked ? NEON : 'transparent',
|
||
transition: 'transform 160ms', transform: isHover ? 'scale(1.15)' : 'none',
|
||
}}
|
||
>
|
||
{faDigits(j.day)}
|
||
</button>
|
||
|
||
{/* breathing neon ring on conference days */}
|
||
{marked && (
|
||
<span style={{
|
||
position: 'absolute', width: 34, height: 34, borderRadius: '50%', pointerEvents: 'none',
|
||
border: `2px solid ${NEON}`, opacity: 0.6,
|
||
animation: 'evPulse 2s ease-out infinite',
|
||
}} />
|
||
)}
|
||
|
||
{/* hover card */}
|
||
{isHover && (
|
||
<div style={{
|
||
position: 'absolute', bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)',
|
||
width: 220, background: NAVY, color: '#fff', borderRadius: 12, padding: '12px 14px', zIndex: 30,
|
||
boxShadow: '0 20px 50px -16px rgba(3,35,64,0.55)', textAlign: 'right',
|
||
}}>
|
||
<div style={{ display: 'inline-block', fontSize: 9, fontWeight: 700, color: NAVY, background: EVENTS[ev].color, borderRadius: 999, padding: '2px 8px', marginBottom: 8 }}>
|
||
{EVENTS[ev].type}
|
||
</div>
|
||
<div style={{ fontSize: 13, fontWeight: 800, lineHeight: 1.5, marginBottom: 8 }}>{EVENTS[ev].title}</div>
|
||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', lineHeight: 1.9 }}>
|
||
<div>📅 {faDigits(j.day)} {J_MONTHS[month - 1]} {faDigits(year)}</div>
|
||
<div>📍 {EVENTS[ev].city}</div>
|
||
<div>🕐 {EVENTS[ev].time}</div>
|
||
</div>
|
||
{/* arrow */}
|
||
<span style={{ position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)', width: 0, height: 0, borderLeft: '7px solid transparent', borderRight: '7px solid transparent', borderTop: `7px solid ${NAVY}` }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* legend */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 16, paddingTop: 12, borderTop: '1px solid rgba(3,35,64,0.08)', fontSize: 10, color: 'rgba(3,35,64,0.5)' }}>
|
||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: NEON, boxShadow: `0 0 8px 1px ${NEON}` }} />
|
||
روزهای دارای رویداد — نشانگر را نگه دارید
|
||
</div>
|
||
|
||
<style>{`
|
||
@keyframes evPulse { 0%{transform:scale(1);opacity:.6} 70%{transform:scale(1.7);opacity:0} 100%{opacity:0} }
|
||
.ev-breathe { animation: evBreathe 1.8s ease-in-out infinite; }
|
||
@keyframes evBreathe {
|
||
0%,100% { box-shadow: 0 0 0 0 ${NEON}00, 0 0 10px 1px ${NEON}cc; }
|
||
50% { box-shadow: 0 0 0 5px ${NEON}33, 0 0 18px 4px ${NEON}; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)
|
||
}
|