feat: polish live activity indicators

Add distinct live activity indicators for warming mailboxes, animate dashboard counters, and tighten lead progress labels.
This commit is contained in:
Matthew Meszaros
2026-06-06 08:34:47 +02:00
parent 10ffc2ad67
commit 3f6935a3bb
5 changed files with 93 additions and 22 deletions
+29 -11
View File
@@ -29,6 +29,7 @@ import {
XIcon,
} from "lucide-react";
import { SearchInput } from "@/components/ui/field";
import AnimatedNumber from "@/components/ui/AnimatedNumber";
import {
PopoverMenu,
PopoverMenuContent,
@@ -60,12 +61,12 @@ const DefaultFolder = {
// can proactively toast the user (continuous health reporting).
const HEALTH_RANK: Record<string, number> = { healthy: 0, warning: 1, error: 2 };
function healthTone(status?: AccountStatus): { dot: string; text: string; label: string } {
function healthTone(status?: AccountStatus): { dot: string; text: string; label: string; pulse: boolean } {
const h = status?.health;
if (!h) return { dot: "bg-slate-300", text: "text-slate-500", label: "—" };
if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-600", label: `Healthy ${h.score}` };
if (h.status === "warning") return { dot: "bg-amber-500", text: "text-amber-600", label: `At risk ${h.score}` };
return { dot: "bg-rose-500", text: "text-rose-600", label: `Issue ${h.score}` };
if (!h) return { dot: "bg-slate-300", text: "text-slate-500", label: "—", pulse: false };
if (h.status === "healthy") return { dot: "bg-emerald-500", text: "text-emerald-600", label: `Healthy ${h.score}`, pulse: false };
if (h.status === "warning") return { dot: "bg-amber-500", text: "text-amber-600", label: `At risk ${h.score}`, pulse: true };
return { dot: "bg-rose-500", text: "text-rose-600", label: `Issue ${h.score}`, pulse: true };
}
export default function AddressesPage() {
@@ -199,10 +200,10 @@ export default function AddressesPage() {
</PageTopbar>
<StatStrip cols={4}>
<Stat label="Total" value={stats.total} sub="connected" />
<Stat label="Healthy" value={stats.healthy} sub="sending now" accent={stats.healthy > 0} />
<Stat label="Warming" value={stats.warming} sub="ramping up" />
<Stat label="Needs attention" value={stats.issues} sub="paused or failing" last />
<Stat label="Total" value={<AnimatedNumber value={stats.total} />} sub="connected" />
<Stat label="Healthy" value={<AnimatedNumber value={stats.healthy} />} sub="sending now" accent={stats.healthy > 0} />
<Stat label="Warming" value={<AnimatedNumber value={stats.warming} />} sub="ramping up" />
<Stat label="Needs attention" value={<AnimatedNumber value={stats.issues} />} sub="paused or failing" last />
</StatStrip>
<SectionBar label="Mailboxes" count={emailsData.emails?.length ?? 0}>
@@ -478,7 +479,19 @@ function MailboxRow({
)}
</button>
</td>
<td className={`px-3 text-[12px] tabular-nums text-right font-mono ${warmupTone}`}>{warmupLabel}</td>
<td className={`px-3 text-[12px] tabular-nums text-right font-mono ${warmupTone}`}>
{active ? (
<span className="inline-flex items-center justify-end gap-1.5">
<span className="campaign-grid shrink-0" aria-hidden />
<span>
<AnimatedNumber value={ws?.current_volume ?? 0} />/
{ws?.target_volume ?? box.warmup_base}
</span>
</span>
) : (
warmupLabel
)}
</td>
<td className="px-3">
<button
type="button"
@@ -486,7 +499,12 @@ function MailboxRow({
className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${tone.text}`}
title="View mailbox health"
>
<span className={`w-1.5 h-1.5 rounded-full ${tone.dot}`} />
<span className="relative flex w-1.5 h-1.5">
{tone.pulse && (
<span className={`absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping ${tone.dot}`} />
)}
<span className={`relative inline-flex w-1.5 h-1.5 rounded-full ${tone.dot}`} />
</span>
<span className="uppercase tracking-[0.08em]">{tone.label}</span>
</button>
</td>
@@ -723,13 +723,14 @@ function ContactsTableBody({
<td className="px-3">
{lead?.current_step ? (
<span
className={`inline-flex items-center h-5 px-1.5 rounded text-[11px] font-medium ${
title={lead.current_step}
className={`inline-flex items-center h-5 px-1.5 rounded text-[11px] font-medium max-w-[108px] ${
processed
? "bg-slate-100 text-slate-400"
: "bg-sky-50 text-sky-700"
: "bg-sky-100 text-sky-700"
}`}
>
{lead.current_step}
<span className="truncate">{lead.current_step}</span>
</span>
) : (
<span className="text-[11px] text-slate-300">Not started</span>
@@ -8,8 +8,9 @@
// pill always sits on the left edge so it stays reachable even when
// the rest of the strip scrolls horizontally.
import { ActivityIcon, LayoutGridIcon, XIcon } from "lucide-react";
import { LayoutGridIcon, XIcon } from "lucide-react";
import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview";
import AnimatedNumber from "@/components/ui/AnimatedNumber";
import { cn } from "@/lib/utils";
interface UniboxHeaderProps {
@@ -72,8 +73,11 @@ export function UniboxHeader({
<Stat label="mailboxes" value={data?.mailboxes.length ?? 0} muted />
</div>
<div className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-emerald-600 shrink-0">
<ActivityIcon className="w-3 h-3" />
<div className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-emerald-600 font-medium shrink-0">
<span className="relative flex size-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60 animate-ping" />
<span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
</span>
live
</div>
</header>
@@ -99,7 +103,7 @@ function Stat({
tone === "accent" ? "text-sky-600" : muted ? "text-slate-400" : "text-slate-900",
)}
>
{value.toLocaleString()}
<AnimatedNumber value={value} />
</span>
<span className="text-[10.5px] text-slate-500">{label}</span>
</div>
+35 -3
View File
@@ -13,6 +13,7 @@ import {
CheckSquareIcon,
CircleDollarSignIcon,
FileTextIcon,
FlameIcon,
GitBranchIcon,
InboxIcon,
KeyIcon,
@@ -28,6 +29,7 @@ import { useMemo } from "react";
import { useAppStore } from "@/stores";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useEmails from "@/lib/api/hooks/app/emails/useEmails";
import { UserNav } from "./UserNav";
import { Logo } from "@/components/svg";
import { cn } from "@/lib/utils";
@@ -41,8 +43,10 @@ interface NavItem {
requires?: "inbox" | "advanced";
/** Role gate — when set, sidebar hides the row entirely for non-matching roles. */
rolesAllowed?: "manage";
/** Live indicator key — renders an ambient, realtime activity cluster. */
indicator?: "campaigns";
/** Live indicator key — renders an ambient, realtime activity cluster.
* Each key has its OWN motif (campaigns = dot-grid, accounts = flame) so
* the rows stay visually distinct rather than a column of identical loaders. */
indicator?: "campaigns" | "accounts";
}
// Plan badge shown on locked sidebar rows. Plan names + colors come
@@ -75,7 +79,7 @@ const sections: NavSection[] = [
{
label: "Email",
items: [
{ title: "Accounts", url: "/app/emails", icon: MailIcon },
{ title: "Accounts", url: "/app/emails", icon: MailIcon, indicator: "accounts" },
{ title: "Campaigns", url: "/app/campaigns", icon: MegaphoneIcon, indicator: "campaigns" },
{ title: "Contacts", url: "/app/contacts", icon: UsersIcon },
{ title: "Analytics", url: "/app/analytics", icon: BarChart3Icon },
@@ -147,6 +151,7 @@ function NavRow({ item }: { item: NavItem }) {
/>
<span className="truncate flex-1">{item.title}</span>
{item.indicator === "campaigns" && !locked && <CampaignActivity />}
{item.indicator === "accounts" && !locked && <MailboxActivity />}
{planBadge ? (
<span
className={cn(
@@ -195,6 +200,33 @@ function CampaignActivity() {
);
}
// MailboxActivity is the Accounts-row indicator — deliberately a DIFFERENT
// motif than the campaigns dot-grid: a flickering flame + count of mailboxes
// warming up right now (warmup enabled and not paused). Hidden when none are
// warming. Counts come from the shared emails-list cache, which the realtime
// layer invalidates on account/warmup events, so it stays live.
function MailboxActivity() {
const { emails } = useEmails({ query: "", tag: "" });
const warming = useMemo(
() => emails.filter((e) => !!e.warmup && !e.warmup_paused_at).length,
[emails],
);
if (warming === 0) return null;
return (
<span
className="ml-auto inline-flex items-center gap-1.5 shrink-0 text-orange-500"
title={`${warming} mailbox${warming === 1 ? "" : "es"} warming up`}
>
<FlameIcon className="w-3.5 h-3.5 flame-flicker" strokeWidth={2.2} />
<span className="text-[10.5px] font-semibold tabular-nums leading-none text-orange-600">
{warming}
</span>
</span>
);
}
function Section({ section, first = false }: { section: NavSection; first?: boolean }) {
return (
<div className={first ? "" : "mt-4 pt-4 border-t border-slate-200/50"}>
+17 -1
View File
@@ -305,8 +305,24 @@ svg.loading circle {
19px 19px 0 5px, 38px 19px 0 0px, 57px 19px 0 0px; }
}
/* Flame flicker — the warmup ("heat") motif for the sidebar Accounts row.
Deliberately a DIFFERENT motion than .campaign-grid so each nav row reads
distinct. A small living-flame wobble: scale + tilt + opacity, anchored at
the base so it dances like a candle. Tint via currentColor (orange). */
.flame-flicker {
transform-origin: center bottom;
animation: flame-flicker 1.1s ease-in-out infinite;
}
@keyframes flame-flicker {
0%, 100% { transform: scale(1) rotate(-1.5deg); opacity: 0.82; }
25% { transform: scale(1.09) rotate(1.5deg); opacity: 1; }
50% { transform: scale(0.97) rotate(-1deg); opacity: 0.9; }
75% { transform: scale(1.05) rotate(1deg); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.sky-breathe, .sun-glow, .cloud-drift, .animate-card-float, .animate-fade-in, .campaign-grid::before { animation: none !important; }
.sky-breathe, .sun-glow, .cloud-drift, .animate-card-float, .animate-fade-in, .campaign-grid::before, .flame-flicker { animation: none !important; }
}
/* ── Utilities ─────────────────────── */