326 lines
13 KiB
JavaScript
326 lines
13 KiB
JavaScript
// Dashboard / Home screen.
|
||
// Three composition variants controlled by the `variant` prop:
|
||
// "classic" — KPI row + activity + side rail
|
||
// "focused" — Today-first hero (next live session, top tasks) + KPIs below
|
||
// "studio" — left = courses, right = students/inbox; KPIs as compact strip
|
||
|
||
function Sparkline({ data, color = 'var(--accent)' }) {
|
||
const w = 120, h = 32, p = 2;
|
||
const min = Math.min(...data), max = Math.max(...data);
|
||
const span = Math.max(1, max - min);
|
||
const pts = data.map((v, i) => {
|
||
const x = p + (i * (w - p * 2)) / (data.length - 1);
|
||
const y = h - p - ((v - min) / span) * (h - p * 2);
|
||
return [x, y];
|
||
});
|
||
const d = pts.map((pt, i) => (i === 0 ? `M${pt[0]},${pt[1]}` : `L${pt[0]},${pt[1]}`)).join(' ');
|
||
const area = d + ` L${w - p},${h - p} L${p},${h - p} Z`;
|
||
return (
|
||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ display: 'block' }}>
|
||
<path d={area} fill={color} opacity="0.12" />
|
||
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function KPI({ k, compact }) {
|
||
return (
|
||
<div className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: compact ? 6 : 12 }}>
|
||
<div className="muted" style={{ fontSize: 12 }}>{k.label}</div>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
|
||
<span className="num" style={{ fontSize: compact ? 20 : 26, fontWeight: 700, letterSpacing: '-0.01em' }}>{k.value}</span>
|
||
<span className="muted" style={{ fontSize: 12 }}>{k.unit}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||
<span className={`pill ${k.good ? 'ok' : 'warn'} num`}>
|
||
<Icon name="trend" className="ic-sm" />
|
||
{k.delta}
|
||
</span>
|
||
<Sparkline data={k.spark} color={k.good ? 'oklch(0.70 0.14 155)' : 'oklch(0.78 0.14 75)'} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivityFeed() {
|
||
const { ACTIVITY } = window.DATA;
|
||
const iconFor = { lesson: 'check', chat: 'chat', cert: 'cert', sale: 'star', quiz: 'quiz' };
|
||
return (
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>فعالیت اخیر دانشجویان</h3>
|
||
<button className="btn ghost sm">نمایش همه</button>
|
||
</div>
|
||
<div style={{ padding: '6px 0' }}>
|
||
{ACTIVITY.map(a => (
|
||
<div key={a.id} style={{ display: 'flex', gap: 12, padding: '12px 20px', alignItems: 'flex-start' }}>
|
||
<div className="av av-sm" style={{ background: a.color }}>{a.who.split(' ').map(s=>s[0]).join('').slice(0,2)}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13.5, lineHeight: 1.5 }}>
|
||
<b style={{ fontWeight: 600 }}>{a.who}</b>
|
||
<span className="muted"> · {a.did}</span>
|
||
</div>
|
||
<div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>
|
||
{a.course} · {a.t}
|
||
</div>
|
||
</div>
|
||
<div className="iconbtn" style={{ width: 28, height: 28, color: a.color }}>
|
||
<Icon name={iconFor[a.kind]} className="ic-sm" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NextLiveCard({ hero }) {
|
||
const { LIVE } = window.DATA;
|
||
const next = LIVE[0];
|
||
return (
|
||
<div className="card card-pad" style={{
|
||
background: hero
|
||
? 'linear-gradient(135deg, var(--accent-soft), color-mix(in oklch, var(--accent-soft) 50%, var(--surface)))'
|
||
: 'var(--surface)',
|
||
borderColor: hero ? 'transparent' : 'var(--line)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||
<span className="pill accent"><span className="dot-i" />جلسه زندهی بعدی</span>
|
||
<span className="muted" style={{ fontSize: 12 }}>{next.at}</span>
|
||
</div>
|
||
<div style={{ fontSize: hero ? 22 : 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 4 }}>
|
||
{next.title}
|
||
</div>
|
||
<div className="muted" style={{ fontSize: 13, marginBottom: 16 }}>{next.course}</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<button className="btn primary"><Icon name="video" className="ic-sm" /> شروع جلسه</button>
|
||
<button className="btn"><Icon name="link" className="ic-sm" /> کپی لینک</button>
|
||
<div style={{ marginInlineStart: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<div style={{ display: 'flex' }}>
|
||
{['#e8a07a','#7aa6c4','#a8c47a'].map((c,i) => (
|
||
<div key={i} className="av av-sm" style={{ background: c, marginInlineStart: i ? -8 : 0, border: '2px solid var(--surface)' }} />
|
||
))}
|
||
</div>
|
||
<span className="muted num" style={{ fontSize: 12 }}>{next.registered} ثبتنام</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TodayCard() {
|
||
const items = [
|
||
{ ic: 'check', t: 'پاسخ به ۳ پرسش جدید در گفتوگوی دوره', done: false },
|
||
{ ic: 'edit', t: 'ویرایش درس «گرید و سیستم لیاوت»', done: false },
|
||
{ ic: 'send', t: 'ارسال پیام خوشآمد به ۱۲ دانشجوی جدید', done: true },
|
||
{ ic: 'star', t: 'مرور تمرینهای ارسالشده فصل دوم', done: false },
|
||
];
|
||
return (
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>کارهای امروز</h3>
|
||
<span className="muted" style={{ fontSize: 12 }}>۳ از ۴ باقیمانده</span>
|
||
</div>
|
||
<div style={{ padding: '6px 0' }}>
|
||
{items.map((it, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 20px' }}>
|
||
<input type="checkbox" className="chk" defaultChecked={it.done} />
|
||
<Icon name={it.ic} className="ic-sm" style={{ color: 'var(--ink-3)' }} />
|
||
<div style={{ flex: 1, fontSize: 13.5, textDecoration: it.done ? 'line-through' : 'none', color: it.done ? 'var(--ink-3)' : 'var(--ink)' }}>
|
||
{it.t}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CoursesMini() {
|
||
const { COURSES } = window.DATA;
|
||
return (
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>دورههای من</h3>
|
||
<button className="btn ghost sm">همه</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
{COURSES.map((c, i) => (
|
||
<div key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 20px', borderTop: i ? '1px solid var(--line-soft)' : 'none' }}>
|
||
<div style={{ width: 42, height: 42, borderRadius: 10, background: c.color, flexShrink: 0,
|
||
display: 'grid', placeItems: 'center', color: 'white', fontWeight: 700 }}>
|
||
<Icon name="book" />
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 4, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{c.title}
|
||
</div>
|
||
<div className="muted num" style={{ fontSize: 11.5 }}>
|
||
{c.status === 'draft'
|
||
? `پیشنویس · ${c.progress}٪ آماده`
|
||
: `${c.students} دانشجو · ⭐ ${c.rating}`}
|
||
</div>
|
||
</div>
|
||
{c.status === 'draft'
|
||
? <span className="pill warn">پیشنویس</span>
|
||
: <span className="pill ok">منتشر شده</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InboxMini() {
|
||
const { INBOX } = window.DATA;
|
||
return (
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<h3>پیامهای اخیر</h3>
|
||
<span className="pill accent num">۳ خواندهنشده</span>
|
||
</div>
|
||
<div>
|
||
{INBOX.map((m, i) => (
|
||
<div key={m.id} style={{ display: 'flex', gap: 12, padding: '12px 20px', borderTop: i ? '1px solid var(--line-soft)' : 'none', alignItems: 'flex-start', cursor: 'pointer' }}>
|
||
<div className="av av-sm" style={{ background: m.color }}>{m.name.split(' ').map(s=>s[0]).join('').slice(0,2)}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||
<b style={{ fontSize: 13, fontWeight: m.unread ? 600 : 500 }}>{m.name}</b>
|
||
<span className="muted num" style={{ fontSize: 11 }}>{m.t}</span>
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: m.unread ? 'var(--ink-2)' : 'var(--ink-3)',
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: 2 }}>
|
||
{m.msg}
|
||
</div>
|
||
</div>
|
||
{m.unread && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)', marginTop: 6 }} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RevenueChart() {
|
||
const months = ['فر', 'ار', 'خر', 'تی', 'مر', 'شه', 'مه', 'آب', 'آذ', 'دی', 'به', 'اس'];
|
||
const vals = [8, 11, 14, 12, 16, 18, 22, 20, 24, 26, 25, 28];
|
||
const max = Math.max(...vals);
|
||
return (
|
||
<div className="card">
|
||
<div className="card-h">
|
||
<div>
|
||
<h3>درآمد ماهانه</h3>
|
||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>۱۲ ماه گذشته · میلیون تومان</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button className="btn ghost sm">ماه</button>
|
||
<button className="btn sm" style={{ background: 'var(--surface-2)' }}>سال</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: '24px 20px 16px', display: 'flex', alignItems: 'flex-end', gap: 8, height: 180 }}>
|
||
{vals.map((v, i) => (
|
||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'flex-end' }}>
|
||
<div style={{
|
||
width: '100%',
|
||
height: `${(v / max) * 100}%`,
|
||
background: i === vals.length - 1
|
||
? 'linear-gradient(180deg, var(--accent), var(--accent-2))'
|
||
: 'var(--line)',
|
||
borderRadius: '6px 6px 0 0',
|
||
transition: 'all .2s',
|
||
}} />
|
||
</div>
|
||
<span className="muted" style={{ fontSize: 10.5 }}>{months[i]}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GreetingHero() {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<div className="muted" style={{ fontSize: 13, marginBottom: 6 }}>سلام آرش 👋</div>
|
||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.015em', margin: 0 }}>
|
||
امروز ۲۴ نفر در دورههایت در حال یادگیریاند
|
||
</h1>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn"><Icon name="upload" className="ic-sm" /> آپلود محتوا</button>
|
||
<button className="btn primary"><Icon name="plus" className="ic-sm" /> دورهی جدید</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HomeScreen({ variant = 'classic' }) {
|
||
const { KPIS } = window.DATA;
|
||
|
||
if (variant === 'focused') {
|
||
return (
|
||
<div className="page-inner">
|
||
<GreetingHero />
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.6fr 1fr', gap: 'var(--gap-lg)' }}>
|
||
<NextLiveCard hero />
|
||
<TodayCard />
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--gap-md)' }}>
|
||
{KPIS.map(k => <KPI k={k} compact key={k.id} />)}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 'var(--gap-lg)' }}>
|
||
<RevenueChart />
|
||
<ActivityFeed />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (variant === 'studio') {
|
||
return (
|
||
<div className="page-inner">
|
||
<GreetingHero />
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--gap-md)' }}>
|
||
{KPIS.map(k => <KPI k={k} compact key={k.id} />)}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 'var(--gap-lg)' }}>
|
||
<div className="col">
|
||
<CoursesMini />
|
||
<NextLiveCard />
|
||
</div>
|
||
<div className="col">
|
||
<InboxMini />
|
||
<TodayCard />
|
||
</div>
|
||
</div>
|
||
<ActivityFeed />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// classic
|
||
return (
|
||
<div className="page-inner">
|
||
<GreetingHero />
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--gap-md)' }}>
|
||
{KPIS.map(k => <KPI k={k} key={k.id} />)}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.6fr 1fr', gap: 'var(--gap-lg)' }}>
|
||
<RevenueChart />
|
||
<NextLiveCard />
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 'var(--gap-lg)' }}>
|
||
<ActivityFeed />
|
||
<div className="col">
|
||
<TodayCard />
|
||
<InboxMini />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
window.HomeScreen = HomeScreen;
|