JSX
Copy
// Activity feed, notification list with unread state and "Mark all as read", and an ops alert feed
// with severity badges (icon + word, never colour alone). Each section is labelled by its heading.
// Usage: <ActivityWidgets activity={[…]} notifications={[…]} alerts={[…]} onMarkAllRead={() => …} />
import { useId, useState } from 'react';
const icon = (children, size = 'size-[18px]') => (
<svg className={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{children}</svg>
);
const CARD_ICON = <><rect x="2" y="5" width="20" height="14" rx="2" /><path d="M2 10h20" /></>;
const defaultActivity = [
{ id: 1, avatar: 'MO', color: 'bg-[#1f5fae]', actor: 'Maya Okafor', before: 'upgraded', link: 'Fernhill Goods', after: 'to the Scale plan', time: '2026-10-16T09:28', ago: '12 min ago', area: 'Billing' },
{ id: 2, avatar: 'JB', color: 'bg-[#0f766e]', actor: 'Jonas Berg', before: 'merged', link: '#482 Faster CSV export', time: '2026-10-16T09:02', ago: '38 min ago', area: 'Deploys' },
{ id: 3, avatar: 'PN', color: 'bg-[#9333ea]', actor: 'Priya Nair', before: 'invited 4 teammates to', link: 'Kestrel Logistics', time: '2026-10-16T08:35', ago: '1 h ago', area: 'Team' },
{ id: 4, avatar: 'LM', color: 'bg-[#c2410c]', actor: 'Leo Martins', before: 'commented on', link: 'Q4 pricing test', quote: 'Let’s extend it to the EU stores.', time: '2026-10-16T07:51', ago: '2 h ago', area: 'Comments' },
{ id: 5, avatar: CARD_ICON, color: 'bg-[#475569]', actor: 'Automation', before: 'refunded $84.00 on', link: 'order #10482', time: '2026-10-16T06:40', ago: '3 h ago', area: 'Payments' },
];
const defaultNotifications = [
{ id: 'n1', unread: true, tone: 'bg-[#fee2e2] text-[#b91c1c]', icon: <><rect x="2" y="5" width="20" height="14" rx="2" /><path d="M2 10h20M6 15h4" /></>, title: 'Payment failed for Larkspur Studio', text: 'Card ending 4242 was declined. We will retry on Oct 18.', time: '2026-10-16T09:35', ago: '5 min ago' },
{ id: 'n2', unread: true, tone: 'bg-[#ede9fe] text-[#6d28d9]', icon: <><circle cx="12" cy="12" r="4" /><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" /></>, title: 'Ana Petrova mentioned you', text: '“Can you check the churn numbers before Friday?”', time: '2026-10-16T09:18', ago: '22 min ago' },
{ id: 'n3', unread: true, tone: 'bg-[#dbeafe] text-[#1d4ed8]', icon: <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />, title: 'Your export is ready', text: 'orders-sept-2026.csv (2.4 MB) is ready to download.', time: '2026-10-16T08:40', ago: '1 h ago' },
{ id: 'n4', tone: 'bg-[#dcfce7] text-[#15803d]', icon: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-10 6L2 7" /></>, title: 'Weekly report sent', text: 'Your Oct 5–11 summary went to 6 recipients.', time: '2026-10-15T08:00', ago: 'Yesterday' },
{ id: 'n5', tone: 'bg-[#f1f5f9] text-[#334155]', icon: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>, title: 'New sign-in from Lisbon', text: 'Chrome on macOS. Not you? Reset your password.', time: '2026-10-15T19:12', ago: 'Yesterday' },
];
const SEVERITY = {
critical: { label: 'Critical', cls: 'text-[#991b1b] bg-[#fee2e2]', icon: <><circle cx="12" cy="12" r="9" /><path d="M15 9l-6 6M9 9l6 6" /></> },
warning: { label: 'Warning', cls: 'text-[#92400e] bg-[#fef3c7]', icon: <><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /></> },
resolved: { label: 'Resolved', cls: 'text-[#166534] bg-[#dcfce7]', icon: <path d="M20 6 9 17l-5-5" /> },
info: { label: 'Info', cls: 'text-[#1e40af] bg-[#dbeafe]', icon: <><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></> },
};
const defaultAlerts = [
{ id: 'a1', severity: 'critical', text: 'p95 latency above 800 ms on checkout-api', where: 'eu-west-1', time: '2026-10-16T09:41', at: '09:41' },
{ id: 'a2', severity: 'warning', text: 'Disk 85% full on db-replica-2', where: 'us-east-1', time: '2026-10-16T09:12', at: '09:12' },
{ id: 'a3', severity: 'resolved', text: 'Webhook delivery errors back to normal', where: 'All regions', time: '2026-10-16T08:55', at: '08:55' },
{ id: 'a4', severity: 'info', text: 'Deploy v4.18.2 finished in 6 min 12 s', where: 'All regions', time: '2026-10-16T08:30', at: '08:30' },
];
const panel = 'min-w-0 rounded-2xl border border-[#e2e8f0] bg-white text-[#0f172a] shadow-[0_1px_2px_rgba(15,23,42,.04),0_8px_24px_rgba(15,23,42,.05)]';
const head = 'flex items-center justify-between gap-3 border-b border-[#e2e8f0] px-[18px] py-4';
const link = 'rounded font-semibold text-[#1f5fae] no-underline hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1f5fae]';
const label = 'mb-4 block text-[.72rem] tracking-[.12em] text-[#4b5768] uppercase';
function ActivityFeed({ items }) {
const titleId = useId();
return (
<section className={panel} aria-labelledby={titleId}>
<div className={head}><h3 className="text-base font-bold" id={titleId}>Recent activity</h3><span className="text-[.78rem] font-medium text-[#4b5768]">Today</span></div>
<ol className="px-[18px] py-2">
{items.map((a) => (
<li key={a.id} className="relative flex gap-3 py-3 not-last:before:absolute not-last:before:top-12 not-last:before:-bottom-3 not-last:before:left-[17px] not-last:before:w-0.5 not-last:before:bg-[#e2e8f0]">
<span className={`relative z-[1] grid size-9 flex-none place-items-center rounded-full text-[.78rem] font-bold text-white shadow-[0_0_0_3px_#fff] ${a.color}`} aria-hidden="true">
{typeof a.avatar === 'string' ? a.avatar : <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">{a.avatar}</svg>}
</span>
<div>
<p className="text-[.88rem]">
<b>{a.actor}</b> {a.before} <a href={a.href ?? '#'} className={link}>{a.link}</a>
{a.after && ` ${a.after}`}
{a.quote && <>: <q className="text-[#4b5768]">{a.quote}</q></>}
</p>
<p className="text-[.76rem] text-[#4b5768]"><time dateTime={a.time}>{a.ago}</time> · {a.area}</p>
</div>
</li>
))}
</ol>
<div className="border-t border-[#e2e8f0] px-[18px] py-3 text-[.84rem]"><a href="#" className={link}>View all activity</a></div>
</section>
);
}
function NotificationList({ initial, onMarkAllRead }) {
const titleId = useId();
const [items, setItems] = useState(initial);
const unread = items.filter((n) => n.unread).length;
const markAllRead = () => {
setItems(items.map((n) => ({ ...n, unread: false })));
onMarkAllRead?.();
};
return (
<section className={`${panel} overflow-hidden`} aria-labelledby={titleId}>
<div className={head}>
<div>
<h3 className="text-base font-bold" id={titleId}>Notifications</h3>
<p className="text-[.78rem] font-medium text-[#4b5768]" aria-live="polite">{unread ? `${unread} unread` : 'All caught up'}</p>
</div>
<button type="button" onClick={markAllRead} disabled={!unread} className="cursor-pointer rounded-md px-1.5 py-1 text-[.78rem] font-semibold text-[#1f5fae] enabled:hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1f5fae] disabled:cursor-default disabled:text-[#4b5768]">
Mark all as read
</button>
</div>
<ul>
{items.map((n) => (
<li key={n.id} data-unread={n.unread || undefined} className="group relative flex gap-3 border-b border-[#e2e8f0] px-[18px] py-3.5 last:border-b-0 data-[unread]:bg-[#f3f8fe] data-[unread]:after:absolute data-[unread]:after:top-5 data-[unread]:after:right-[18px] data-[unread]:after:size-[9px] data-[unread]:after:rounded-full data-[unread]:after:bg-[#1f5fae]">
<span className={`grid size-[34px] flex-none place-items-center rounded-[10px] ${n.tone}`} aria-hidden="true">{icon(n.icon)}</span>
<div className="flex min-w-0 flex-col gap-px pr-[18px]">
<a href={n.href ?? '#'} className="rounded font-medium text-[#0f172a] no-underline group-data-[unread]:font-bold hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1f5fae]">
{n.unread && <span className="sr-only">Unread: </span>}{n.title}
</a>
<p className="text-[.84rem] text-[#4b5768]">{n.text}</p>
<p className="text-[.76rem] text-[#4b5768]"><time dateTime={n.time}>{n.ago}</time></p>
</div>
</li>
))}
</ul>
</section>
);
}
function AlertFeed({ alerts }) {
const titleId = useId();
return (
<section className={panel} aria-labelledby={titleId}>
<div className={head}><h3 className="text-base font-bold" id={titleId}>Ops alerts</h3><span className="text-[.78rem] font-medium text-[#4b5768]">Last 2 hours</span></div>
<ul className="py-1.5">
{alerts.map((a) => {
const s = SEVERITY[a.severity];
return (
<li key={a.id} className="grid grid-cols-1 items-start gap-x-3 gap-y-1 px-[18px] py-3 not-first:border-t not-first:border-[#e2e8f0] min-[421px]:grid-cols-[auto_1fr]">
<span className={`inline-flex min-w-0 items-center gap-[5px] justify-self-start rounded-md py-0.5 pr-2 pl-1.5 text-[.72rem] font-bold tracking-[.04em] whitespace-nowrap uppercase min-[421px]:min-w-24 ${s.cls}`}>
<svg className="size-[13px] flex-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{s.icon}</svg>
{s.label}
</span>
<p className="text-[.88rem] font-semibold">{a.text}</p>
<p className="text-[.76rem] text-[#4b5768] min-[421px]:col-start-2">{a.where} · <time dateTime={a.time}>{a.at}</time></p>
</li>
);
})}
</ul>
<div className="border-t border-[#e2e8f0] px-[18px] py-3 text-[.84rem]"><a href="#" className={link}>Open incident log</a></div>
</section>
);
}
export default function ActivityWidgets({ activity = defaultActivity, notifications = defaultNotifications, alerts = defaultAlerts, onMarkAllRead }) {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,420px),1fr))] items-start gap-x-[22px] gap-y-[46px]">
<div><span className={label}>Activity feed</span><ActivityFeed items={activity} /></div>
<div><span className={label}>Notification list with unread state</span><NotificationList initial={notifications} onMarkAllRead={onMarkAllRead} /></div>
<div><span className={label}>Alert feed with severity</span><AlertFeed alerts={alerts} /></div>
</div>
);
}