mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 08:06:04 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user