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:
parent
6f8409b8f0
commit
775dbbf78d
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue