feat: make the Accounts page readable on phones with container-sized mailbox columns, a labelled warmup, inbox and health line under each address, touch-sized row actions, one-row search and filter, banners that stack their buttons under the copy, and tighter mobile stat tiles

This commit is contained in:
Matthew Meszaros
2026-10-09 13:44:39 +02:00
parent d3256ae647
commit fa6797e12b
6 changed files with 249 additions and 178 deletions
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from "../lib/proof.ts";
test("the accounts list reads on a phone as well as on a desktop", async ({ page, proof }) => {
await page.goto("/app/emails");
const row = page.getByRole("row", { name: /dev\.outbound@warmbly\.test/ });
await expect(row).toBeVisible();
await proof.chapter("Accounts", "The same list on a desktop, then on a phone");
await proof.shot("accounts-desktop", { caption: "Desktop: every metric has its own column" });
// The list's columns follow its own width, so the phone layout is a viewport change away.
await page.setViewportSize({ width: 390, height: 844 });
await expect(row.getByText("dev.outbound@warmbly.test")).toBeVisible();
await expect(row.getByText("Health", { exact: true })).toBeVisible();
await proof.dwell();
await proof.shot("accounts-phone", { caption: "Phone: address, sender and connection, then labelled warmup, inbox and health readings" });
await page.getByRole("textbox", { name: /Search by email/ }).pressSequentially("outbound", { delay: 60 });
await expect(page.getByRole("row", { name: /dev\.send@warmbly\.test/ })).toBeHidden();
await expect(row).toBeVisible();
await proof.shot("accounts-phone-search", { caption: "Search and the tag filter share one row" });
// From the keyboard: on a phone-sized page the dev-only query devtools sit over the last row's actions.
await row.getByRole("button", { name: "Mailbox actions" }).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitem", { name: /Disconnect mailbox/ })).toBeVisible();
await proof.shot("accounts-phone-actions", { caption: "Row actions stay reachable at touch size" });
});
+179 -140
View File
@@ -401,7 +401,7 @@ export default function AddressesPage() {
>
<MailboxImportsMenu />
<TopbarAction variant="ghost" href="/app/emails/domains" icon={<GlobeIcon className="w-3 h-3" />}>
Sending domains
<span className="sr-only sm:not-sr-only">Sending domains</span>
</TopbarAction>
<TopbarAction
onClick={() => p?.setAddEmail(true)}
@@ -423,7 +423,7 @@ export default function AddressesPage() {
value={query}
onChange={setQuery}
placeholder="Search by email…"
className="w-full sm:w-56"
className="flex-1 sm:flex-none sm:w-56"
/>
<PopoverMenu align="end">
<PopoverMenuTrigger asChild>
@@ -511,58 +511,61 @@ export default function AddressesPage() {
) : (
// table-fixed like the Leads list: every column but Mailbox
// carries a width, so one long address never widens the table.
<table className="w-full table-fixed text-left">
<thead className="sticky top-0 bg-white z-[1]">
<tr className="border-b border-slate-200">
<th className="pl-5 pr-2 py-2 w-11">
<Checkbox
checked={isSelectedAll()}
onChange={() => {
if (isSelectedAll()) {
setSelected((bef) =>
bef.filter((e) => !emailsData.emails.map((em) => em.id).includes(e)),
);
} else {
setSelected((bef) => [
...bef,
...emailsData.emails
.filter((em) => !selected.includes(em.id))
.map((em) => em.id),
]);
}
}}
// Columns key off the list's own width, not the viewport, since the sidebar takes a share of it.
<div className="@container">
<table className="w-full table-fixed text-left">
<thead className="sticky top-0 bg-white z-[1]">
<tr className="border-b border-slate-200">
<th className="pl-5 pr-2 py-2 w-11">
<Checkbox
checked={isSelectedAll()}
onChange={() => {
if (isSelectedAll()) {
setSelected((bef) =>
bef.filter((e) => !emailsData.emails.map((em) => em.id).includes(e)),
);
} else {
setSelected((bef) => [
...bef,
...emailsData.emails
.filter((em) => !selected.includes(em.id))
.map((em) => em.id),
]);
}
}}
/>
</th>
{MAILBOX_COLUMNS.map((col) => (
<MailboxTh key={col.id} col={col} sort={sort} onSort={sortBy} />
))}
<th className="px-3 py-2 w-[84px] @2xl:w-[76px]"></th>
</tr>
</thead>
<tbody>
{sortedEmails.map((box) => (
<MailboxRow
key={box.id}
box={box}
tags={p?.user.tags ?? []}
status={statusById.get(box.id)}
findings={advisor.get(box.id)}
canWarmup={canWarmup}
cloud={cloud.connected ? cloud.rowFor(box.id) : undefined}
cloudConnected={cloud.workspaceConnected}
retiring={retiringDomain.has(box.id)}
onRetiring={() => openMigration(retiringDomain.get(box.id) ?? null)}
checked={selected.includes(box.id)}
onToggleSelect={() =>
selected.includes(box.id)
? setSelected((bef) => bef.filter((i) => i !== box.id))
: setSelected((bef) => [...bef, box.id])
}
onOpen={openDetail}
/>
</th>
{MAILBOX_COLUMNS.map((col) => (
<MailboxTh key={col.id} col={col} sort={sort} onSort={sortBy} />
))}
<th className="px-3 py-2 w-[76px]"></th>
</tr>
</thead>
<tbody>
{sortedEmails.map((box) => (
<MailboxRow
key={box.id}
box={box}
tags={p?.user.tags ?? []}
status={statusById.get(box.id)}
findings={advisor.get(box.id)}
canWarmup={canWarmup}
cloud={cloud.connected ? cloud.rowFor(box.id) : undefined}
cloudConnected={cloud.workspaceConnected}
retiring={retiringDomain.has(box.id)}
onRetiring={() => openMigration(retiringDomain.get(box.id) ?? null)}
checked={selected.includes(box.id)}
onToggleSelect={() =>
selected.includes(box.id)
? setSelected((bef) => bef.filter((i) => i !== box.id))
: setSelected((bef) => [...bef, box.id])
}
onOpen={openDetail}
/>
))}
</tbody>
</table>
</tbody>
</table>
</div>
)}
{emailsData.isLoadingRest && !emailsData.isPending && (
<div className="h-11 px-5 flex items-center gap-2 text-[12px] text-slate-400 border-b border-slate-200/60">
@@ -866,6 +869,68 @@ function MailboxRow({
const upsell = () => toast("Warmup is available on paid plans", { icon: "✨" });
const warmupCell =
inCloud && cloud?.cloud ? (
<span
className="inline-flex items-center gap-1.5"
title={
sendFailure
? `Warmbly Cloud could not send from this mailbox: ${sendFailure.message}`
: `${cloudPaused ? "Paused in Warmbly Cloud. " : ""}${cloud.cloud.sent_today} of ${cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base} warmup emails sent today by Warmbly Cloud`
}
>
{sendFailure ? (
<AlertTriangleIcon className="w-3 h-3 shrink-0" />
) : cloudPaused ? (
<PauseIcon className="w-3 h-3 shrink-0" />
) : (
<span className="campaign-grid shrink-0" aria-hidden />
)}
<span>
<AnimatedNumber value={cloud.cloud.sent_today} />
<span className="opacity-60">/{cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base}</span>
</span>
</span>
) : inCloud ? (
<span className="inline-flex items-center gap-1.5" title="Waiting for Warmbly Cloud to report">
<CloudIcon className="w-3 h-3 shrink-0" />
<span>{warmupLabel}</span>
</span>
) : warming ? (
<span
className="inline-flex items-center gap-1.5"
title={sendFailure ? `The last warmup email was not sent: ${sendFailure.message}` : `${ws?.current_volume ?? 0} of ${ws?.target_volume ?? box.warmup_base} warmup emails sent today`}
>
{sendFailure ? <AlertTriangleIcon className="w-3 h-3 shrink-0" /> : <span className="campaign-grid shrink-0" aria-hidden />}
<span>
<AnimatedNumber value={ws?.current_volume ?? 0} />
<span className="opacity-60">/{ws?.target_volume ?? box.warmup_base}</span>
</span>
</span>
) : (
<span
className="inline-flex items-center gap-1.5 font-sans text-[11.5px] font-medium"
title={
active
? box.status === "revoked"
? "Warmup is on, but the mailbox's access was revoked, so nothing is sent. Reconnect it to resume."
: "Warmup is on, but the mailbox is not, so nothing is sent. Switch the mailbox back on to resume."
: undefined
}
>
{active ? (
<PowerOffIcon className="w-3 h-3 shrink-0" />
) : paused ? (
<PauseIcon className="w-3 h-3 shrink-0" />
) : inCampaign ? (
<ActivityIcon className="w-3 h-3 shrink-0" />
) : (
<RiFireLine className="w-3 h-3 shrink-0" />
)}
{warmupLabel}
</span>
);
return (
<tr
onClick={() => onOpen(box.id)}
@@ -874,7 +939,7 @@ function MailboxRow({
<td className="pl-5 pr-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={checked} onChange={onToggleSelect} />
</td>
<td className="px-3 overflow-hidden">
<td className="px-3 py-2 @2xl:py-0 overflow-hidden">
{/* The flag is a sibling of the open-row button, not a child:
it has its own trigger and nesting buttons is invalid. */}
<div className="flex w-full min-w-0 items-center gap-2">
@@ -896,7 +961,7 @@ function MailboxRow({
{inCloud && (
<span
title={cloud?.managed ? "Signed in through Warmbly Cloud, which warms it" : cloudPaused ? "Paused in Warmbly Cloud" : "Warmed by Warmbly Cloud"}
className={`inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium uppercase tracking-[0.08em] shrink-0 ${cloudPaused ? "bg-amber-50 text-amber-600" : "bg-sky-600 text-white"}`}
className={`hidden @2xl:inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium uppercase tracking-[0.08em] shrink-0 ${cloudPaused ? "bg-amber-50 text-amber-600" : "bg-sky-600 text-white"}`}
>
<CloudIcon className="w-2.5 h-2.5" /> Cloud
</span>
@@ -904,7 +969,7 @@ function MailboxRow({
{shownTags.map((t) => (
<span
key={t.id}
className="hidden lg:inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium shrink-0"
className="hidden @4xl:inline-flex items-center gap-1 h-4 px-1.5 rounded-full text-[9.5px] font-medium shrink-0"
style={{ backgroundColor: `${t.color}1a`, color: labelInk(t.color) }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: t.color }} />
@@ -912,14 +977,14 @@ function MailboxRow({
</span>
))}
{rowTags.length > shownTags.length && (
<span className="hidden lg:inline-flex items-center h-4 px-1 rounded-full bg-slate-100 text-slate-500 text-[9.5px] font-medium shrink-0">
<span className="hidden @4xl:inline-flex items-center h-4 px-1 rounded-full bg-slate-100 text-slate-500 text-[9.5px] font-medium shrink-0">
+{rowTags.length - shownTags.length}
</span>
)}
</div>
{/* Who it sends as, and how it connects: the vendor or grant chip, else the host. */}
<div className="mt-0.5 flex items-center gap-1.5 min-w-0 text-[11px] text-slate-400 leading-tight">
{box.name && <span className="truncate text-slate-500">{box.name}</span>}
<div className="mt-0.5 flex items-center gap-1.5 min-w-0 overflow-hidden text-[11px] text-slate-400 leading-tight">
{box.name && <span className="truncate shrink-0 max-w-[60%] text-slate-500">{box.name}</span>}
{box.name && <span className="text-slate-300">·</span>}
{source.kind !== "host" ? (
<MailboxSourceChip box={box} labelClassName="inline" />
@@ -929,26 +994,47 @@ function MailboxRow({
{inCloud && (
<span className={`inline-flex items-center gap-1 shrink-0 ${cloudPaused ? "text-amber-600" : "text-sky-600"}`}>
<span className="text-slate-300">·</span>
<CloudIcon className="w-2.5 h-2.5" /> {cloudPaused ? "Cloud warmup paused" : "Warmed by Warmbly Cloud"}
<CloudIcon className="w-2.5 h-2.5" />
<span className="@4xl:hidden">{cloudPaused ? "Cloud paused" : "Cloud"}</span>
<span className="hidden @4xl:inline">{cloudPaused ? "Cloud warmup paused" : "Warmed by Warmbly Cloud"}</span>
</span>
)}
{inCampaign && (
<span className="hidden sm:inline-flex items-center gap-1 shrink-0 text-sky-600">
<span className="hidden @4xl:inline-flex items-center gap-1 shrink-0 text-sky-600">
<span className="text-slate-300">·</span>
<ActivityIcon className="w-2.5 h-2.5" /> In campaign
</span>
)}
</div>
{/* Too narrow for the metric columns: the same readings, labelled, under the address. */}
<div className="@2xl:hidden mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 leading-tight">
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} compact />
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[10.5px] text-slate-400">Warmup</span>
<span className={`font-mono text-[11.5px] tabular-nums ${warmupTone}`}>{warmupCell}</span>
</span>
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[10.5px] text-slate-400">Inbox</span>
<PlacementRateBadge rate={status?.warmup_placement} />
</span>
<span className="inline-flex items-center gap-1.5 whitespace-nowrap" title={status?.health ? tone.label : undefined}>
<span className="text-[10.5px] text-slate-400">Health</span>
<span className={`inline-flex items-center gap-1 font-mono text-[11.5px] font-medium tabular-nums ${tone.text}`}>
<span className={`inline-flex w-1.5 h-1.5 rounded-full ${tone.dot}`} />
{status?.health?.score ?? "—"}
</span>
</span>
</div>
</div>
</button>
{retiring && <SigninRetiringChip onClick={onRetiring} />}
<AdvisorRowFlag findings={findings} subject={box.email} />
</div>
</td>
<td className="px-3 overflow-hidden">
<td className={`px-3 overflow-hidden ${colShow("status")}`}>
<MailboxStatusPill box={box} status={status} warming={inCloud ? !cloudPaused : warming} />
</td>
<td className="px-3 overflow-hidden hidden md:table-cell">
<td className={`px-3 overflow-hidden ${colShow("sent")}`}>
{status?.daily_usage ? (
<span
className={`inline-flex items-center gap-1.5 font-mono text-[12px] tabular-nums ${status.daily_usage.campaign_sent > 0 ? "text-sky-700" : "text-slate-500"}`}
@@ -964,80 +1050,21 @@ function MailboxRow({
<Dash />
)}
</td>
<td className={`px-3 overflow-hidden font-mono text-[12px] tabular-nums ${warmupTone}`}>
{inCloud && cloud?.cloud ? (
<span
className="inline-flex items-center gap-1.5"
title={
sendFailure
? `Warmbly Cloud could not send from this mailbox: ${sendFailure.message}`
: `${cloudPaused ? "Paused in Warmbly Cloud. " : ""}${cloud.cloud.sent_today} of ${cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base} warmup emails sent today by Warmbly Cloud`
}
>
{sendFailure ? (
<AlertTriangleIcon className="w-3 h-3 shrink-0" />
) : cloudPaused ? (
<PauseIcon className="w-3 h-3 shrink-0" />
) : (
<span className="campaign-grid shrink-0" aria-hidden />
)}
<span>
<AnimatedNumber value={cloud.cloud.sent_today} />
<span className="opacity-60">/{cloud.cloud.warmup?.target_volume ?? cloud.cloud.settings.base}</span>
</span>
</span>
) : inCloud ? (
<span className="inline-flex items-center gap-1.5" title="Waiting for Warmbly Cloud to report">
<CloudIcon className="w-3 h-3 shrink-0" />
<span>{warmupLabel}</span>
</span>
) : warming ? (
<span
className="inline-flex items-center gap-1.5"
title={sendFailure ? `The last warmup email was not sent: ${sendFailure.message}` : `${ws?.current_volume ?? 0} of ${ws?.target_volume ?? box.warmup_base} warmup emails sent today`}
>
{sendFailure ? <AlertTriangleIcon className="w-3 h-3 shrink-0" /> : <span className="campaign-grid shrink-0" aria-hidden />}
<span>
<AnimatedNumber value={ws?.current_volume ?? 0} />
<span className="opacity-60">/{ws?.target_volume ?? box.warmup_base}</span>
</span>
</span>
) : (
<span
className="inline-flex items-center gap-1.5 font-sans text-[11.5px] font-medium"
title={
active
? box.status === "revoked"
? "Warmup is on, but the mailbox's access was revoked, so nothing is sent. Reconnect it to resume."
: "Warmup is on, but the mailbox is not, so nothing is sent. Switch the mailbox back on to resume."
: undefined
}
>
{active ? (
<PowerOffIcon className="w-3 h-3 shrink-0" />
) : paused ? (
<PauseIcon className="w-3 h-3 shrink-0" />
) : inCampaign ? (
<ActivityIcon className="w-3 h-3 shrink-0" />
) : (
<RiFireLine className="w-3 h-3 shrink-0" />
)}
{warmupLabel}
</span>
)}
<td className={`px-3 overflow-hidden font-mono text-[12px] tabular-nums ${warmupTone} ${colShow("warmup")}`}>
{warmupCell}
</td>
<td className="px-3">
<td className={`px-3 ${colShow("inbox")}`}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "deliverability"); }}
aria-label="View warmup deliverability"
className="inline-flex items-center gap-1.5"
>
<MailCheckIcon className="w-3 h-3 shrink-0 text-slate-400 hidden sm:block" />
<MailCheckIcon className="w-3 h-3 shrink-0 text-slate-400" />
<PlacementRateBadge rate={status?.warmup_placement} />
</button>
</td>
<td className="px-3 overflow-hidden">
<td className={`px-3 overflow-hidden ${colShow("health")}`}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onOpen(box.id, "overview"); }}
@@ -1045,7 +1072,7 @@ function MailboxRow({
title={status?.health?.issues?.join("\n") || "View mailbox health"}
>
<span className={`inline-flex w-1.5 h-1.5 rounded-full shrink-0 ${tone.dot}`} />
<span className={`uppercase tracking-[0.08em] hidden md:inline truncate ${tone.pulse ? "text-shimmer" : ""}`}>{tone.label}</span>
<span className={`uppercase tracking-[0.08em] hidden @4xl:inline truncate ${tone.pulse ? "text-shimmer" : ""}`}>{tone.label}</span>
</button>
</td>
<td className="px-3" onClick={(e) => e.stopPropagation()}>
@@ -1056,7 +1083,7 @@ function MailboxRow({
type="button"
aria-label="Warmup actions"
disabled={life.isPending}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-orange-600 transition-colors cursor-pointer disabled:opacity-50"
className="size-7 @2xl:size-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-orange-600 transition-colors cursor-pointer disabled:opacity-50"
>
{inCloud ? <CloudIcon className={`w-3.5 h-3.5 ${cloudPaused ? "text-amber-500" : "text-sky-600"}`} /> : <RiFireLine className={`w-3.5 h-3.5 ${warming ? "text-orange-500" : paused ? "text-amber-500" : ""}`} />}
</button>
@@ -1142,7 +1169,7 @@ function MailboxRow({
<PopoverMenuTrigger asChild>
<button
type="button"
className="w-6 h-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors cursor-pointer"
className="size-7 @2xl:size-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors cursor-pointer"
aria-label="Mailbox actions"
>
<RiMoreLine className="w-3.5 h-3.5" />
@@ -1198,8 +1225,10 @@ interface MailboxColumn {
id: MailboxColumnId;
label: string;
header?: React.ReactNode;
// Width and breakpoint, shared by the header and the row's cell.
// Width of the header cell, which sets the column in a fixed table.
className: string;
// Container breakpoint the column appears at, shared by the header and the row's cell.
show?: string;
align?: "right";
// Whether the first click sorts ascending (text) rather than descending.
sortAsc?: boolean;
@@ -1219,10 +1248,11 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
header: (
<>
<span className="sr-only">Status</span>
<span aria-hidden className="hidden sm:inline">Status</span>
<span aria-hidden className="hidden @4xl:inline">Status</span>
</>
),
className: "w-16 sm:w-32",
className: "w-12 @4xl:w-32",
show: "hidden @2xl:table-cell",
// Problems first, then idle, warming, sending, sending and warming.
sortValue: (b, s) =>
b.status !== "active" || s?.errors?.length
@@ -1233,7 +1263,8 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
id: "sent",
label: "Sent today",
header: <InfoHeader label="Sent today" title="Campaign emails sent from this mailbox today, against its daily cap." aria="How sends are counted" />,
className: "w-28 hidden md:table-cell",
className: "w-32",
show: "hidden @5xl:table-cell",
sortValue: (_, s) => s?.daily_usage?.campaign_sent ?? -1,
},
{
@@ -1241,6 +1272,7 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
label: "Warmup",
header: <InfoHeader label="Warmup" title="Warmup emails sent today, against today's ramp target." aria="How warmup is counted" />,
className: "w-28",
show: "hidden @2xl:table-cell",
sortValue: (b, s) => (diagnosticWarmupActive(b) ? (s?.warmup_status?.current_volume ?? 0) : -1),
},
{
@@ -1253,7 +1285,8 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
aria="How the inbox rate is measured"
/>
),
className: "w-20 sm:w-24",
className: "w-24",
show: "hidden @2xl:table-cell",
sortValue: (_, s) => s?.warmup_placement?.inbox_rate ?? -1,
},
{
@@ -1262,19 +1295,24 @@ const MAILBOX_COLUMNS: MailboxColumn[] = [
header: (
<>
<span className="sr-only">Health</span>
<span aria-hidden className="hidden md:inline">
<span aria-hidden className="hidden @4xl:inline">
<InfoHeader label="Health" title="The mailbox's overall health score out of 100: connection, errors, bounces and warmup standing. Click it for the details." aria="How health is scored" />
</span>
</>
),
className: "w-10 md:w-32",
className: "w-10 @4xl:w-32",
show: "hidden @2xl:table-cell",
sortValue: (_, s) => s?.health?.score ?? -1,
},
];
function colShow(id: MailboxColumnId): string {
return MAILBOX_COLUMNS.find((c) => c.id === id)?.show ?? "";
}
// A header cell; a sortable one is the sort control, like the Leads list.
function MailboxTh({ col, sort, onSort }: { col: MailboxColumn; sort: MailboxSort | null; onSort: (col: MailboxColumn) => void }) {
const base = `px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] truncate ${col.className} ${col.align === "right" ? "text-right" : ""}`;
const base = `px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] truncate ${col.className} ${col.show ?? ""} ${col.align === "right" ? "text-right" : ""}`;
const content = col.header ?? col.label;
if (!col.sortValue) return <th className={base}>{content}</th>;
const active = sort?.by === col.id;
@@ -1298,7 +1336,8 @@ function MailboxTh({ col, sort, onSort }: { col: MailboxColumn; sort: MailboxSor
// What the mailbox is doing right now. Cold sending and warmup run side by
// side, so both show when both are on; a problem that stops it wins.
function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: AccountStatus; warming: boolean }) {
// compact spells a problem out, since it is the one reading a phone must not miss.
function MailboxStatusPill({ box, status, warming, compact = false }: { box: Inbox; status?: AccountStatus; warming: boolean; compact?: boolean }) {
const error = status?.errors?.[0];
const lifecycle = status?.send_lifecycle;
const inCampaign = !!status?.in_campaign;
@@ -1315,7 +1354,7 @@ function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: Acco
<span className={`inline-flex items-center gap-1.5 max-w-full text-[10.5px] font-medium uppercase tracking-[0.08em] ${problem.text}`} title={problem.title}>
<Icon className="w-3 h-3 shrink-0" />
<span className="sr-only">{problem.label}</span>
<span aria-hidden className="hidden sm:inline truncate">{problem.label}</span>
<span aria-hidden className={`${compact ? "inline" : "hidden @4xl:inline"} truncate`}>{problem.label}</span>
</span>
);
}
@@ -1342,7 +1381,7 @@ function MailboxStatusPill({ box, status, warming }: { box: Inbox; status?: Acco
{!sending && !resting && !warming && <CircleSlashIcon className="w-3 h-3" />}
</span>
<span className="sr-only">{label}</span>
<span aria-hidden className={`hidden sm:inline truncate ${sending || warming ? "text-shimmer" : ""}`}>{label}</span>
<span aria-hidden className={`hidden @4xl:inline truncate ${sending || warming ? "text-shimmer" : ""}`}>{label}</span>
</span>
);
}
@@ -89,9 +89,9 @@ export default function CloudPathsPanel({ mailboxCount, onAdd }: { mailboxCount:
return (
<div>
<div className="flex items-center gap-2.5 rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-700">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5 rounded-md border border-slate-200 bg-white px-3 py-2 text-[12.5px] text-slate-700">
<CloudIcon className="w-4 h-4 shrink-0 text-sky-600" />
<span className="min-w-0 flex-1 leading-snug">
<span className="min-w-0 flex-1 basis-56 leading-snug">
<span className="font-medium">
{free ? `${used} of ${allowance} free mailboxes used. ` : ""}
{warming === undefined
@@ -31,23 +31,25 @@ export default function CloudPoolBanner({ onConnect, mailboxCount }: { onConnect
if (atLimit && plan?.upgrade_url) {
return (
<div>
<div className="flex items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2.5 text-[12.5px] text-slate-700">
<div className="flex items-start sm:items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2.5 text-[12.5px] text-slate-700">
<SoftIcon icon={TrendingUpIcon} />
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium text-slate-900">Give every mailbox better deliverability.</span>{" "}
<span className="text-slate-500">
All {limit} free pool mailboxes are in use. Premium warms the rest in the premium pool, built for inbox placement, for ${plan.price_usd} a month.
<div className="min-w-0 flex-1 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium text-slate-900">Give every mailbox better deliverability.</span>{" "}
<span className="text-slate-500">
All {limit} free pool mailboxes are in use. Premium warms the rest in the premium pool, built for inbox placement, for ${plan.price_usd} a month.
</span>
</span>
</span>
<a
href={plan.upgrade_url}
target="_blank"
rel="noreferrer"
className="shrink-0 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
Upgrade to Premium
<ExternalLinkIcon className="w-3 h-3" />
</a>
<a
href={plan.upgrade_url}
target="_blank"
rel="noreferrer"
className="self-start sm:self-auto shrink-0 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
Upgrade to Premium
<ExternalLinkIcon className="w-3 h-3" />
</a>
</div>
</div>
</div>
);
@@ -78,23 +80,25 @@ export default function CloudPoolBanner({ onConnect, mailboxCount }: { onConnect
return (
<div>
<div className="flex items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2.5 text-[12.5px] text-slate-700">
<div className="flex items-start sm:items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2.5 text-[12.5px] text-slate-700">
<SoftIcon icon={MailCheckIcon} />
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium text-slate-900">Get these mailboxes into the inbox, not spam.</span>{" "}
<span className="text-slate-500">
Warm them in Warmbly Cloud's large, active pool to build sender reputation, with replies and spam rescue handled for you.
Free for 10 mailboxes, set up in a minute, and your data stays here.
<div className="min-w-0 flex-1 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium text-slate-900">Get these mailboxes into the inbox, not spam.</span>{" "}
<span className="text-slate-500">
Warm them in Warmbly Cloud's large, active pool to build sender reputation, with replies and spam rescue handled for you.
Free for 10 mailboxes, set up in a minute, and your data stays here.
</span>
</span>
</span>
<button
type="button"
onClick={onConnect}
className="shrink-0 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
Connect for free
<ArrowRightIcon className="w-3 h-3" />
</button>
<button
type="button"
onClick={onConnect}
className="self-start sm:self-auto shrink-0 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
Connect for free
<ArrowRightIcon className="w-3 h-3" />
</button>
</div>
<button
type="button"
aria-label="Dismiss"
@@ -53,7 +53,8 @@ export default function WarmupCoverageNotice({
<div className="min-w-0 text-[12.5px] leading-snug">
<span className="font-medium">{title}.</span>{" "}
<span className="text-amber-800/90">
{body} A believable pool is around 20 to 50 mailboxes, ramped over a few weeks toward a safe 40 to 50 a day.
{body}
<span className="hidden sm:inline"> A believable pool is around 20 to 50 mailboxes, ramped over a few weeks toward a safe 40 to 50 a day.</span>
</span>{" "}
<button
type="button"
+5 -5
View File
@@ -213,7 +213,7 @@ export function Stat({
</span>
)}
</div>
<div className="text-[26px] text-slate-900 font-light leading-none mt-2 tabular-nums">
<div className="text-[22px] md:text-[26px] text-slate-900 font-light leading-none mt-1.5 md:mt-2 tabular-nums">
{typeof value === "number" ? value.toLocaleString() : value}
</div>
{sub && (
@@ -222,7 +222,7 @@ export function Stat({
</>
);
const cls = cn(
"group px-5 py-3 md:py-4 transition-colors",
"group px-5 py-2.5 md:py-4 transition-colors",
!last && "border-r border-slate-200",
(href || onClick) && "hover:bg-slate-50 cursor-pointer",
);
@@ -276,8 +276,8 @@ export function SectionBar({
return (
<div
className={cn(
// Single 36px row on >=md; on mobile it wraps so a full-width
// search + filters stack instead of crowding off the edge.
// Single 36px row on >=md; on mobile a toolbar holding a search
// takes its own full-width row instead of crowding the label.
"min-h-9 md:h-9 px-5 py-1.5 md:py-0 border-b border-slate-200/60 flex flex-wrap md:flex-nowrap items-center gap-x-2.5 gap-y-1.5 shrink-0",
className,
)}
@@ -291,7 +291,7 @@ export function SectionBar({
</span>
)}
{children && (
<div className="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0">
<div className="ml-auto flex items-center gap-1.5 flex-wrap justify-end min-w-0 max-md:has-[[data-search-input]]:basis-full">
{children}
</div>
)}