Event calendar next to globe: neon-yellow breathing date markers

- Native Jalali calendar (Intl Persian calendar, no deps) replacing the events
  list; conference days marked with neon-yellow breathing glow + hover detail card
- Remove events-column background

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
alireza 2026-06-10 16:44:47 +03:30
parent 6f8409b8f0
commit 775dbbf78d
2 changed files with 160 additions and 56 deletions

View File

@ -0,0 +1,156 @@
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>
)
}

View File

@ -1,18 +1,8 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import Globe from '@/components/ui/globe' import Globe from '@/components/ui/globe'
import { events } from '@/data/events' import EventCalendar from '@/components/ui/EventCalendar'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
const TYPE_COLOR: Record<string, string> = {
conference: 'var(--ink)', exhibition: '#6b4a00',
seminar: '#1a4a2a', international: 'var(--red)',
}
const TYPE_LABEL = {
fa: { conference: 'کنگره', exhibition: 'نمایشگاه', seminar: 'سمینار', international: 'جهانی' },
en: { conference: 'Conference', exhibition: 'Exhibition', seminar: 'Seminar', international: 'International' },
}
const T = { const T = {
fa: { fa: {
mapOverline: 'اسکنر جهانی', mapOverline: 'اسکنر جهانی',
@ -33,8 +23,6 @@ const T = {
export default function MapEventsSection() { export default function MapEventsSection() {
const { lang } = useLang() const { lang } = useLang()
const t = T[lang] const t = T[lang]
const typeLabel = TYPE_LABEL[lang]
const displayed = events.slice(0, 5)
return ( return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}> <section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}>
@ -58,7 +46,7 @@ export default function MapEventsSection() {
</div> </div>
{/* ── 30% · Events ── */} {/* ── 30% · Events ── */}
<div style={{ flex: '3 3 0', minWidth: 0, background: 'var(--paper-2)', display: 'flex', flexDirection: 'column' }}> <div style={{ flex: '3 3 0', minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '48px 20px 16px', borderBottom: '1px solid var(--rule-thin)' }}> <div style={{ padding: '48px 20px 16px', borderBottom: '1px solid var(--rule-thin)' }}>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.eventsOverline}</div> <div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.eventsOverline}</div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
@ -67,48 +55,8 @@ export default function MapEventsSection() {
</div> </div>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '28px 20px' }}>
{displayed.map((ev, i) => ( <EventCalendar />
<motion.div
key={ev.id}
initial={{ opacity: 0, x: 8 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.06 }}
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--rule-thin)',
display: 'flex', gap: 14, alignItems: 'flex-start',
}}
>
{/* Date */}
<div style={{ textAlign: 'center', flexShrink: 0, minWidth: 40 }}>
<div style={{ fontSize: 26, fontWeight: 900, lineHeight: 1, color: 'var(--ink)' }}>
{ev.date.toLocaleString('fa-IR')}
</div>
<div style={{ fontSize: 9, color: 'var(--ink-4)', marginTop: 3 }}>{ev.month}</div>
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: 'inline-block', marginBottom: 5,
border: `1px solid ${TYPE_COLOR[ev.type]}`,
color: TYPE_COLOR[ev.type],
fontSize: 8, fontWeight: 700, textTransform: 'uppercase',
padding: '2px 5px', letterSpacing: '1px',
}}>
{typeLabel[ev.type]}
</span>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.45, marginBottom: 3 }}>{ev.title}</div>
<div style={{ fontSize: 10, color: 'var(--ink-5)', marginBottom: 4 }}>{ev.city}</div>
{ev.registrationOpen
? <Link to="#" style={{ fontSize: 10, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.register}</Link>
: <span style={{ fontSize: 10, color: 'var(--ink-5)', fontWeight: 600 }}>{t.soon}</span>
}
</div>
</motion.div>
))}
</div> </div>
</div> </div>