diff --git a/web/src/app/app/analytics/browseState.test.tsx b/web/src/app/app/analytics/browseState.test.tsx new file mode 100644 index 000000000..f014da410 --- /dev/null +++ b/web/src/app/app/analytics/browseState.test.tsx @@ -0,0 +1,98 @@ +import { act, renderHook } from "@testing-library/react"; +import type { ComponentProps, ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { z } from "zod"; +import useBrowseState from "@/hooks/useBrowseState"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; +import * as schemas from "@/lib/browse-accounts-analytics"; + +function Owner({ children }: { children: ReactNode }) { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return {children}; +} + +const cases: { name: string; schema: z.ZodType; initial: unknown; value: unknown }[] = [ + { name: "analytics.range", schema: schemas.analyticsRange, initial: "7d", value: "90d" }, + { name: "analytics.hiddenMetrics", schema: schemas.analyticsHiddenMetrics, initial: [], value: ["sent", "replies"] }, + { name: "deliverability.view", schema: schemas.deliverabilityView, initial: { range: "7d", hidden: [] }, value: { range: "30d", hidden: ["providers", "mailboxes"] } }, + { name: "deliverability.hiddenMetrics", schema: schemas.deliverabilityHiddenMetrics, initial: ["sent"], value: ["opens", "replies"] }, + { name: "emails.search", schema: schemas.browseString, initial: "", value: "alice" }, + { name: "emails.sort", schema: schemas.mailboxSort, initial: null, value: { by: "health", reverse: true } }, + { name: "emails.mailbox.a.tab", schema: schemas.mailboxTab, initial: "overview", value: "analytics" }, + { name: "emails.domains.filter", schema: schemas.domainFilter, initial: "all", value: "vendor" }, + { name: "emails.domain.a.test.tab", schema: schemas.domainTab, initial: "overview", value: "tracking" }, + { name: "emails.import.a.results.filter", schema: schemas.importFilter, initial: { status: "", cause: "" }, value: { status: "failed", cause: "password" } }, + { name: "emails.import.vendor.a.pick.filter", schema: schemas.importPickFilter, initial: "all", value: "moving" }, + { name: "emails.domains.bulkSetup.list.limit", schema: schemas.domainListLimit, initial: 20, value: 70 }, + { name: "emails.mailbox.a.placement.range", schema: schemas.placementRange, initial: "30d", value: "14d" }, + { name: "emails.mailbox.a.placement.group", schema: schemas.placementGroupFilter, initial: "all", value: "yahoo" }, + { name: "deliverability.placement.providerExpanded", schema: schemas.placementGroup.nullable(), initial: null, value: "google" }, + { name: "deliverability.placement.showAll", schema: schemas.browseBoolean, initial: false, value: true }, + { name: "placement.batch.a.tab", schema: schemas.batchTab, initial: "mailboxes", value: "recipients" }, + { name: "placement.batch.a.senders.sort", schema: schemas.batchSenderSort, initial: "worst", value: "status" }, + { name: "placement.batch.a.senders.status", schema: schemas.batchSenderStatus, initial: "", value: "cancelled" }, +]; + +describe("accounts and analytics browse schemas", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it.each(cases)("restores $name synchronously after change and remount", ({ name, schema, initial, value }) => { + const page = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner }); + act(() => page.result.current[1](value)); + page.unmount(); + const refreshed = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner }); + expect(refreshed.result.current[0]).toEqual(value); + act(() => refreshed.result.current[1](initial)); + refreshed.unmount(); + const cleared = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner }); + expect(cleared.result.current[0]).toEqual(initial); + }); + + it("isolates import history filters by resource without erasing a remembered cause", () => { + const page = renderHook(({ id }) => useBrowseState(`emails.import.${id}.results.filter`, { status: "", cause: "" }, schemas.importFilter), { + wrapper: Owner, initialProps: { id: "a" }, + }); + act(() => page.result.current[1]({ status: "failed", cause: "password" })); + page.rerender({ id: "b" }); + expect(page.result.current[0]).toEqual({ status: "", cause: "" }); + act(() => page.result.current[1]({ status: "needs_signin", cause: "" })); + page.rerender({ id: "a" }); + expect(page.result.current[0]).toEqual({ status: "failed", cause: "password" }); + }); + + it("lets initial navigation override a remembered resource tab and remembers explicit clearing", () => { + const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.mailbox.a.tab`; + sessionStorage.setItem(key, JSON.stringify({ value: "settings" })); + const page = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab, { initialOverride: "analytics" }), { wrapper: Owner }); + expect(page.result.current[0]).toBe("analytics"); + act(() => page.result.current[1]("overview")); + page.unmount(); + const refreshed = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab), { wrapper: Owner }); + expect(refreshed.result.current[0]).toBe("overview"); + }); + + it("rejects stale enums, malformed structures, duplicate metrics and hidden-all metrics", () => { + expect(schemas.mailboxSort.safeParse({ by: "health", reverse: false, obsolete: true }).success).toBe(false); + expect(schemas.mailboxSort.safeParse({ by: "old", reverse: false }).success).toBe(false); + expect(schemas.mailboxSort.safeParse({ by: "health", reverse: "yes" }).success).toBe(false); + expect(schemas.mailboxTab.safeParse("old").success).toBe(false); + expect(schemas.domainTab.safeParse("old").success).toBe(false); + expect(schemas.batchSenderStatus.safeParse("needs_signin").success).toBe(false); + expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "sent"]).success).toBe(false); + expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false); + expect(schemas.deliverabilityHiddenMetrics.safeParse(["sent", "opens", "replies", "bounces", "complaints"]).success).toBe(false); + expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["old"] }).success).toBe(false); + expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["chart", "chart"] }).success).toBe(false); + expect(schemas.importFilter.safeParse({ status: "connected", cause: "password" }).success).toBe(false); + expect(schemas.importFilter.safeParse({ status: "failed", cause: "password", cursor: "a" }).success).toBe(false); + }); + + it.each([0, -1, 1.5, Infinity, NaN, null, "20"])("rejects invalid display limits: %s", (value) => { + expect(schemas.domainListLimit.safeParse(value).success).toBe(false); + }); +}); diff --git a/web/src/app/app/analytics/page.tsx b/web/src/app/app/analytics/page.tsx index 8e752c001..d74268f2b 100644 --- a/web/src/app/app/analytics/page.tsx +++ b/web/src/app/app/analytics/page.tsx @@ -1,6 +1,8 @@ import { NoAccess } from "@/components/layout/NoAccess"; import { usePermission } from "@/hooks/usePermission"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { analyticsRange, analyticsHiddenMetrics } from "@/lib/browse-accounts-analytics"; import { Link } from "@tanstack/react-router"; import { ActivityIcon, @@ -60,9 +62,9 @@ function num(v: number | undefined): string { export default function AnalyticsPage() { const canView = usePermission("VIEW_ANALYTICS"); - const [range, setRange] = useState("7d"); + const [range, setRange] = useBrowseState("analytics.range", "7d", analyticsRange); // Legend toggles: every metric charts together; hidden ones drop out. - const [hiddenMetrics, setHiddenMetrics] = useState([]); + const [hiddenMetrics, setHiddenMetrics] = useBrowseState("analytics.hiddenMetrics", [], analyticsHiddenMetrics); const dash = useDashboard(range); const d = dash.data; const os = d?.overall_stats; diff --git a/web/src/app/app/deliverability/browsePersistence.test.tsx b/web/src/app/app/deliverability/browsePersistence.test.tsx new file mode 100644 index 000000000..b86e0ebc3 --- /dev/null +++ b/web/src/app/app/deliverability/browsePersistence.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps, ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; +import DeliverabilityPage from "./page"; + +vi.mock("@/lib/api/hooks/app/analytics/useDeliverability", () => ({ default: () => ({ data: undefined, isPending: true, refetch: vi.fn() }) })); +vi.mock("@/components/app/placement/WarmupPlacementSection", () => ({ default: () => null })); +vi.mock("@/components/app/advisor/AdvisorSummaryBar", () => ({ default: () => null })); +vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => {children} })); + +function Page() { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return ; +} +const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:deliverability.view`; + +describe("deliverability legacy browse preferences", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("seeds compatible legacy preferences once, deduplicates sections, and never dual-writes them", () => { + vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["providers", "providers", "old"] })); + const page = render(); + expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "30d", hidden: ["providers"] }); + fireEvent.click(screen.getByRole("button", { name: "90d" })); + page.unmount(); + render(); + expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["providers"] }); + expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String)); + }); + + it("retains a scoped preference in preference to a global legacy value", () => { + vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["chart"] })); + sessionStorage.setItem(key, JSON.stringify({ value: { range: "90d", hidden: ["totals"] } })); + render(); + expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["totals"] }); + expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String)); + }); +}); diff --git a/web/src/app/app/deliverability/page.tsx b/web/src/app/app/deliverability/page.tsx index e14746fc5..1036d1d3d 100644 --- a/web/src/app/app/deliverability/page.tsx +++ b/web/src/app/app/deliverability/page.tsx @@ -4,7 +4,9 @@ // is keyed under ["analytics", ...] which useRealtimeEvents already invalidates. // The visible sections and the time window are user-customizable and persisted. -import { useMemo, useState } from "react"; +import { useMemo } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { deliverabilityView, deliverabilityHiddenMetrics } from "@/lib/browse-accounts-analytics"; import { Link } from "@tanstack/react-router"; import { AlertTriangleIcon, ArrowUpRightIcon, CheckIcon, RefreshCcwIcon, SlidersHorizontalIcon } from "lucide-react"; import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, Stat, StatStrip } from "@/components/layout/Page"; @@ -88,7 +90,7 @@ function num(v: number | undefined): string { return (v ?? 0).toLocaleString(); } -// View preferences (window + hidden sections), persisted per browser. +// Legacy browser preferences seed the scoped session preference once. const VIEW_KEY = "warmbly:deliverability-view"; interface ViewPrefs { @@ -103,7 +105,7 @@ function loadView(): ViewPrefs { const v = JSON.parse(raw) as Partial; return { range: v.range === "30d" || v.range === "90d" ? v.range : "7d", - hidden: Array.isArray(v.hidden) ? v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)) : [], + hidden: Array.isArray(v.hidden) ? [...new Set(v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)))] : [], }; } } catch { @@ -121,10 +123,10 @@ function BandChip({ band }: { band: DeliverabilityBand }) { } export default function DeliverabilityPage() { - const [view, setView] = useState(loadView); + const [view, setView] = useBrowseState("deliverability.view", loadView, deliverabilityView); // Legend toggles: every metric charts together; "sent" starts hidden so // it doesn't dwarf the failure signals this page exists to surface. - const [hiddenMetrics, setHiddenMetrics] = useState(["sent"]); + const [hiddenMetrics, setHiddenMetrics] = useBrowseState("deliverability.hiddenMetrics", ["sent"], deliverabilityHiddenMetrics); const toggleMetric = (k: Metric) => setHiddenMetrics((cur) => { if (cur.includes(k)) return cur.filter((x) => x !== k); @@ -137,11 +139,6 @@ export default function DeliverabilityPage() { const updateView = (next: ViewPrefs) => { setView(next); - try { - localStorage.setItem(VIEW_KEY, JSON.stringify(next)); - } catch { - // Persisting is best-effort; the in-memory view still applies. - } }; const show = (k: SectionKey) => !view.hidden.includes(k); const toggleSection = (k: SectionKey) => diff --git a/web/src/app/app/emails/domains/browsePersistence.test.tsx b/web/src/app/app/emails/domains/browsePersistence.test.tsx new file mode 100644 index 000000000..1cb7c8a3c --- /dev/null +++ b/web/src/app/app/emails/domains/browsePersistence.test.tsx @@ -0,0 +1,97 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useCallback, useState, type ComponentProps, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; +import type { SendingDomain } from "@/lib/api/models/app/emails/SendingDomain"; +import type { DomainTab } from "@/components/app/emails/domains/SendingDomainDrawer"; +import DomainsPage from "./page"; + +const navigation = vi.hoisted(() => ({ initial: "", consumed: "" })); +vi.mock("@/hooks/useSearchParams", () => ({ + useSearchParams: () => { + const [params, setParams] = useState(() => new URLSearchParams(navigation.initial)); + const set = useCallback((next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => { + setParams((prev) => { + const result = typeof next === "function" ? next(prev) : next; + navigation.consumed = result.toString(); + return result; + }); + }, []); + return [params, set]; + }, +})); +const domains: SendingDomain[] = ["a.test", "b.test"].map((domain) => ({ + domain, mailboxes: 1, mail_hosts: [], auth_state: "passing", auth_spf: true, auth_dkim: true, auth_dmarc: true, tracking_domains: [], +})); +vi.mock("@/lib/api/hooks/app/emails/useSendingDomains", () => ({ + useSendingDomains: () => ({ data: { data: domains }, isLoading: false }), +})); +vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => {children} })); +vi.mock("@/components/app/emails/domains/RedirectRescue", () => ({ default: () => null })); +vi.mock("@/components/app/emails/domains/BulkSetupDialog", () => ({ default: () => null })); +vi.mock("@/components/app/emails/domains/DomainSelectionBar", () => ({ default: () => null })); +vi.mock("@/components/app/emails/domains/SendingDomainDrawer", () => ({ + default: ({ open, tab, setTab, onClose }: { open: string; tab: DomainTab; setTab: (tab: DomainTab) => void; onClose: () => void }) => open ? ( +
+ {tab} + + + +
+ ) : null, +})); + +function Page() { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return ; +} + +describe("domain drawer browse persistence", () => { + beforeEach(() => { + sessionStorage.clear(); + navigation.initial = ""; + navigation.consumed = ""; + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("remembers per-domain tabs without reopening drawers on page refresh", () => { + const page = render(); + fireEvent.click(screen.getByText("a.test")); + fireEvent.click(screen.getByRole("button", { name: "Choose tracking" })); + fireEvent.click(screen.getByRole("button", { name: "Close domain" })); + fireEvent.click(screen.getByText("b.test")); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview"); + fireEvent.click(screen.getByRole("button", { name: "Close domain" })); + page.unmount(); + render(); + expect(screen.queryByRole("region", { name: "Domain drawer" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("a.test")); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking"); + }); + + it("prioritizes and consumes a URL tab intent, then remembers the user's reset", () => { + const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.domain.a.test.tab`; + sessionStorage.setItem(key, JSON.stringify({ value: "tracking" })); + navigation.initial = "domain=a.test&tab=redirect&keep=1"; + const page = render(); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect"); + expect(navigation.consumed).toBe("keep=1"); + fireEvent.click(screen.getByRole("button", { name: "Choose overview" })); + page.unmount(); + navigation.initial = ""; + render(); + fireEvent.click(screen.getByText("a.test")); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview"); + }); + + it("does not replay a consumed URL tab intent on workspace switching", () => { + sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.domain.a.test.tab`, JSON.stringify({ value: "tracking" })); + navigation.initial = "domain=a.test&tab=redirect"; + render(); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect"); + act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } })); + expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking"); + }); +}); diff --git a/web/src/app/app/emails/domains/page.tsx b/web/src/app/app/emails/domains/page.tsx index 6f70100bb..f0171bf35 100644 --- a/web/src/app/app/emails/domains/page.tsx +++ b/web/src/app/app/emails/domains/page.tsx @@ -2,6 +2,8 @@ // the tracking host its mailboxes use, whether its root redirects to the // company website, and the inbox vendor that holds it. A row opens the drawer. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseString, domainFilter, domainTab } from "@/lib/browse-accounts-analytics"; import { Link } from "@tanstack/react-router"; import { useSearchParams } from "@/hooks/useSearchParams"; import { AnimatePresence, motion } from "framer-motion"; @@ -69,10 +71,18 @@ export default function SendingDomainsPage() { const canView = usePermission("MANAGE_EMAILS"); const profile = useUserProfile(); const list = useSendingDomains(canView); - const [query, setQuery] = React.useState(""); - const [filter, setFilter] = React.useState("all"); + const [query, setQuery] = useBrowseState("emails.domains.search", "", browseString); + const [filter, setFilter] = useBrowseState("emails.domains.filter", "all", domainFilter); const [open, setOpen] = React.useState(""); - const [tab, setTab] = React.useState("overview"); + const [tabIntent, setTabIntent] = React.useState(); + const [tab, setTab] = useBrowseState(`emails.domain.${open}.tab`, "overview", domainTab, + tabIntent === undefined ? undefined : { initialOverride: tabIntent }); + React.useEffect(() => { + if (tabIntent !== undefined) { + setTab(tabIntent); + setTabIntent(undefined); + } + }, [tabIntent, setTab]); const [searchParams, setSearchParams] = useSearchParams(); const [selected, setSelected] = React.useState>(() => new Set()); const [bulk, setBulk] = React.useState(null); @@ -139,7 +149,9 @@ export default function SendingDomainsPage() { const d = searchParams.get("domain"); if (!d) return; const t = searchParams.get("tab"); - setTab(t === "redirect" || t === "tracking" ? t : "overview"); + const nextTab = t === "redirect" || t === "tracking" ? t : "overview"; + setTabIntent(nextTab); + if (open === d.toLowerCase()) setTab(nextTab); setOpen(d.toLowerCase()); setSearchParams( (prev) => { @@ -150,10 +162,11 @@ export default function SendingDomainsPage() { }, { replace: true }, ); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, open, setTab]); - const openDomain = (d: string, t: DomainTab = "overview") => { - setTab(t); + const openDomain = (d: string, t?: DomainTab) => { + setTabIntent(t); + if (open === d && t !== undefined) setTab(t); setOpen(d); }; const addMailboxes = () => profile.setAddEmail(true); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index d249af5a5..e0f20b7f8 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -1,5 +1,7 @@ import { RiFireLine, RiMoreLine } from "@remixicon/react"; import React, { useEffect, useMemo, useRef } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseString, mailboxSort, mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics"; import { useSearchParams } from "@/hooks/useSearchParams"; import useMailboxTagFilter from "@/hooks/useMailboxTagFilter"; import groupMailboxes from "@/lib/groupMailboxes"; @@ -130,13 +132,15 @@ export default function AddressesPage() { const confirm = useConfirm(); const canView = usePermission("MANAGE_EMAILS"); - const [query, setQuery] = React.useState(""); + const [query, setQuery] = useBrowseState("emails.search", "", browseString); const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal"); const [tag, setTag] = useMailboxTagFilter(`${p.user.id}:${organizationID}`, p.user.tags); const emailsData = useEmails({ query, tag }); const [selected, setSelected] = React.useState([]); const [view, setView] = React.useState(""); - const [viewTab, setViewTab] = React.useState("overview"); + const [viewTab, setViewTab] = React.useState(); + const [tabIntentKey, setTabIntentKey] = React.useState(0); + const consumeTabIntent = React.useCallback(() => setViewTab(undefined), []); const [removing, setRemoving] = React.useState(false); const [bulkStart, setBulkStart] = React.useState(false); const [searchParams, setSearchParams] = useSearchParams(); @@ -302,8 +306,10 @@ export default function AddressesPage() { return out; }, [selected, boxStatusById]); - const openDetail = (id: string, tab: string = "overview") => { - setViewTab(tab); + const openDetail = (id: string, tab?: string) => { + const parsed = mailboxTab.safeParse(tab); + setViewTab(tab === undefined ? undefined : parsed.success ? parsed.data : "overview"); + setTabIntentKey((key) => key + 1); setView(id); }; @@ -360,7 +366,7 @@ export default function AddressesPage() { } // Every mailbox page is loaded, so the headers sort in the browser. - const [sort, setSort] = React.useState(null); + const [sort, setSort] = useBrowseState("emails.sort", null, mailboxSort); const sortBy = (col: MailboxColumn) => setSort((cur) => cur?.by === col.id ? { by: col.id, reverse: !cur.reverse } : { by: col.id, reverse: !!col.sortAsc }, @@ -647,7 +653,7 @@ export default function AddressesPage() { )} - + ) : ( - + )} ); @@ -100,7 +102,7 @@ function Detail({ batch }: { batch: PlacementBatchDetail }) { const confirm = useConfirm(); const cancel = useCancelPlacementBatch(); const campaign = useCampaign(batch.campaign_id ?? ""); - const [tab, setTab] = React.useState("mailboxes"); + const [tab, setTab] = useBrowseState(`placement.batch.${batch.id}.tab`, "mailboxes", batchTab); const open = batchOpen(batch.status); const p = batch.progress; @@ -289,9 +291,9 @@ const STATUS_ORDER: PlacementBatchSenderStatus[] = ["running", "queued", "deferr function SendersTab({ batch }: { batch: PlacementBatchDetail }) { const navigate = useNavigate(); - const [sort, setSort] = React.useState("worst"); - const [status, setStatus] = React.useState(""); - const [q, setQ] = React.useState(""); + const [sort, setSort] = useBrowseState(`placement.batch.${batch.id}.senders.sort`, "worst", batchSenderSort); + const [status, setStatus] = useBrowseState(`placement.batch.${batch.id}.senders.status`, "", batchSenderStatus); + const [q, setQ] = useBrowseState(`placement.batch.${batch.id}.senders.search`, "", browseString); const debounced = useDebouncedValue(q.trim(), 300); const list = usePlacementBatchSenders(batch.id, { sort, status, q: debounced }); const p = batch.progress; @@ -481,7 +483,7 @@ function GroupTable({ groups, label, empty }: { groups: PlacementBatchGroup[]; l // Sending domain by recipient provider: the inbox rate in each cell. function Matrix({ batch }: { batch: PlacementBatchDetail }) { - const [all, setAll] = React.useState(false); + const [all, setAll] = useBrowseState(`placement.batch.${batch.id}.matrix.showAll`, false, browseBoolean); const cols = batch.recipients; if (cols.length === 0 || batch.matrix.length === 0) { return

No copy has a verdict yet.

; diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 44acc6bd0..3f90b07c1 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -13,6 +13,8 @@ import { DashboardImage } from "@/components/ui/dashboard-image"; // validation. Read data: /analytics/accounts/:id and /analytics/warmup?email_id=. import React, { useMemo, useState } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics"; import { diagnosticSendingAllowed, diagnosticWarmupActive } from "@/lib/diagnosticParticipation"; import { AnimatePresence, motion } from "framer-motion"; import AdvisorStrip from "@/components/app/advisor/AdvisorStrip"; @@ -333,7 +335,7 @@ function statusTone(status: string) { /* ── tabs ─────────────────────── */ -const TABS: { key: string; label: string; icon: LucideIcon }[] = [ +const TABS: { key: MailboxTab; label: string; icon: LucideIcon }[] = [ { key: "overview", label: "Overview", icon: GaugeIcon }, { key: "deliverability", label: "Deliverability", icon: MailCheckIcon }, { key: "analytics", label: "Analytics", icon: BarChart3Icon }, @@ -350,13 +352,17 @@ export default function InboxDetails({ emails, view, setView, - initialTab = "overview", + initialTab, + tabIntentKey, + onTabIntentConsumed, canWarmup = true, }: { emails: Inbox[] | null; view: string; setView: React.Dispatch>; - initialTab?: string; + initialTab?: MailboxTab; + tabIntentKey?: number; + onTabIntentConsumed?: () => void; canWarmup?: boolean; }) { const mailbox = emails?.find((e) => e.id === view) ?? null; @@ -381,7 +387,7 @@ export default function InboxDetails({ transition={{ type: "spring", damping: 32, stiffness: 320 }} className="fixed right-0 top-0 z-50 h-full w-full sm:w-[640px] xl:w-[720px] bg-white border-l border-slate-200 shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex flex-col" > - + )} @@ -400,8 +406,15 @@ const EDITABLE: (keyof Inbox)[] = [ "test_mode", "test_send_enabled", "test_receive_enabled", "shared_daily_limit", "rolling_recipient_limit", ]; -function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: string; canWarmup?: boolean }) { - const [tab, setTab] = useState(initialTab); +function Detail({ mailbox, onClose, initialTab, tabIntentKey, onTabIntentConsumed, canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void; canWarmup?: boolean }) { + const [tab, setTab] = useBrowseState(`emails.mailbox.${mailbox.id}.tab`, "overview", mailboxTab, + initialTab === undefined ? undefined : { initialOverride: initialTab }); + React.useEffect(() => { + if (initialTab !== undefined) { + setTab(initialTab); + onTabIntentConsumed?.(); + } + }, [initialTab, tabIntentKey, onTabIntentConsumed, setTab]); const [form, setForm] = useState(mailbox); const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); diff --git a/web/src/components/app/emails/InboxDetailsBrowse.test.tsx b/web/src/components/app/emails/InboxDetailsBrowse.test.tsx new file mode 100644 index 000000000..af841f786 --- /dev/null +++ b/web/src/components/app/emails/InboxDetailsBrowse.test.tsx @@ -0,0 +1,89 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { ConfirmContext } from "@/hooks/context/confirm"; +import { useAppStore } from "@/stores/useAppStore"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; +import type { MailboxTab } from "@/lib/browse-accounts-analytics"; +import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState"; +import InboxDetails from "./InboxDetails"; + +vi.mock("@/lib/api/client/Request", () => ({ default: () => new Promise(() => {}) })); +vi.mock("@/hooks/PresenceProvider", () => ({ usePresenceResource: () => {} })); +vi.mock("@/components/app/presence/ResourceViewers", () => ({ default: () => null })); +vi.mock("@/components/app/advisor/AdvisorStrip", () => ({ default: () => null })); +vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ connected: false, selfHosted: false, isEnrolled: () => false }) })); +vi.mock("./SendingBehaviorTab", () => ({ default: () =>

Sending behavior

})); + +const mailbox: Inbox = { + id: "a", email: "alice@a.test", name: "Alice", signature_plain: "", signature_html: "", signature_sync: false, signature_code: false, + send_as_email: "", tags: [], provider: "smtp_imap", status: "active", last_synced_at: new Date(), campaign_limit: 50, min_wait_time: 60, + reply_to: "", save_to_sent: false, tracking_domain: "", tracking_domain_verified: false, + auth_state: "unknown", auth_spf: false, auth_dkim: false, auth_dmarc: false, + warmup_base: 5, warmup_max: 20, warmup_increase: 2, warmup_reply_rate: 30, created_at: new Date(), updated_at: new Date(), +}; +let client: QueryClient; +function Page({ id = "a", initialTab, tabIntentKey = 0, onTabIntentConsumed }: { id?: string; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void }) { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return ( + + + + {}} initialTab={initialTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={onTabIntentConsumed} /> + + + + ); +} +const tab = (name: string) => screen.getByRole("button", { name }); + +describe("mailbox drawer browse persistence", () => { + beforeEach(() => { + HTMLElement.prototype.scrollBy = vi.fn(); + sessionStorage.clear(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("restores drawer tabs on remount and isolates mailbox resources", () => { + const page = render(); + fireEvent.click(tab("Analytics")); + page.unmount(); + const refreshed = render(); + expect(tab("Analytics")).toHaveAttribute("data-active", "true"); + refreshed.rerender(); + expect(tab("Overview")).toHaveAttribute("data-active", "true"); + refreshed.rerender(); + expect(tab("Analytics")).toHaveAttribute("data-active", "true"); + }); + + it("honors repeated explicit tab intents without discarding mailbox drafts", () => { + const first = render(); + fireEvent.click(tab("Sending")); + first.unmount(); + const page = render(); + expect(tab("Analytics")).toHaveAttribute("data-active", "true"); + fireEvent.click(tab("Warmup")); + fireEvent.change(screen.getByLabelText(/Shared daily send limit/), { target: { value: "80" } }); + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + page.rerender(); + expect(tab("Analytics")).toHaveAttribute("data-active", "true"); + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + fireEvent.click(tab("Warmup")); + expect(screen.getByLabelText(/Shared daily send limit/)).toHaveValue(80); + page.rerender(); + expect(tab("Analytics")).toHaveAttribute("data-active", "true"); + }); + + it("consumes explicit navigation so another workspace restores its own tab", () => { + sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.mailbox.a.tab`, JSON.stringify({ value: "sending" })); + const consumed = vi.fn(); + const page = render(); + expect(consumed).toHaveBeenCalledOnce(); + page.rerender(); + act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } })); + expect(tab("Sending")).toHaveAttribute("data-active", "true"); + }); +}); diff --git a/web/src/components/app/emails/domains/BulkSetupDialog.tsx b/web/src/components/app/emails/domains/BulkSetupDialog.tsx index 1caf4f2ea..85503f057 100644 --- a/web/src/components/app/emails/domains/BulkSetupDialog.tsx +++ b/web/src/components/app/emails/domains/BulkSetupDialog.tsx @@ -381,6 +381,7 @@ function Dialog({ {n > 1 && ( setEach((prev) => ({ ...prev, [d]: { ...prev[d], ...patch } }))} onReset={(d) => diff --git a/web/src/components/app/emails/domains/PerDomainList.tsx b/web/src/components/app/emails/domains/PerDomainList.tsx index 37280afb3..e04d135f1 100644 --- a/web/src/components/app/emails/domains/PerDomainList.tsx +++ b/web/src/components/app/emails/domains/PerDomainList.tsx @@ -2,6 +2,8 @@ // listing each domain with its own tracking host and redirect website, filled // from the shared value until someone types over it. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseString, browseBoolean, domainListLimit } from "@/lib/browse-accounts-analytics"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronDownIcon, RotateCcwIcon } from "lucide-react"; import { SearchInput, TextInput } from "@/components/ui/field"; @@ -51,19 +53,21 @@ function Tick({ checked, onToggle, label, disabled }: { checked: boolean; onTogg } export function PerDomainList({ + browseScope, rows, onChange, onReset, defaultOpen = false, }: { + browseScope: string; rows: PerDomainRow[]; onChange: (domain: string, patch: DomainOverride) => void; onReset: (domain: string) => void; defaultOpen?: boolean; }) { - const [open, setOpen] = React.useState(defaultOpen); - const [query, setQuery] = React.useState(""); - const [limit, setLimit] = React.useState(20); + const [open, setOpen] = useBrowseState(`${browseScope}.expanded`, defaultOpen, browseBoolean); + const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString); + const [limit, setLimit] = useBrowseState(`${browseScope}.limit`, 20, domainListLimit); const changed = rows.filter((r) => r.custom).length; const problems = rows.filter((r) => (r.track && r.hostProblem) || (r.redirect && r.urlProblem)).length; const q = query.trim().toLowerCase(); diff --git a/web/src/components/app/emails/domains/browsePersistence.test.tsx b/web/src/components/app/emails/domains/browsePersistence.test.tsx new file mode 100644 index 000000000..bb214db3f --- /dev/null +++ b/web/src/components/app/emails/domains/browsePersistence.test.tsx @@ -0,0 +1,84 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useState, type ComponentProps, type ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import PickTable, { type PickItem } from "../import/PickTable"; +import { PerDomainList, type PerDomainRow } from "./PerDomainList"; + +function Owner({ children }: { children: ReactNode }) { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return {children}; +} + +const items: PickItem[] = [ + { id: "a", email: "alice@a.test", name: "Alice", connected: true, group: "A" }, + { id: "b", email: "bob@b.test", name: "Bob", connected: false, group: "B" }, +]; + +function Picker({ scope = "emails.import.vendor.a.pick", loading = false }: { scope?: string; loading?: boolean }) { + const [selected, setSelected] = useState>(new Set()); + return ; +} + +const rows: PerDomainRow[] = Array.from({ length: 80 }, (_, i) => ({ + domain: `domain-${i}.test`, canTrack: false, track: false, host: "", redirect: false, url: "", urlPlaceholder: "", custom: false, +})); +function DomainList({ scope = "emails.domains.bulkSetup.list" }: { scope?: string }) { + return {}} onReset={() => {}} />; +} + +describe("nested account browsing persistence", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("restores picker search, filter and group after loading/remount without restoring selected rows", () => { + const picker = render(); + fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } }); + fireEvent.click(screen.getByRole("button", { name: /^Connected/ })); + fireEvent.click(screen.getByRole("button", { name: /^A\s?1$/ })); + fireEvent.click(screen.getByRole("button", { name: /alice@a.test/ })); + picker.unmount(); + const refreshed = render(); + refreshed.rerender(); + expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice"); + expect(screen.getByRole("button", { name: /^Connected/ })).toHaveClass("bg-sky-50"); + expect(screen.getByRole("button", { name: /^A\s?1$/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: /alice@a.test/ })).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByText("bob@b.test")).not.toBeInTheDocument(); + }); + + it("isolates connection scopes and persists explicit picker clearing", () => { + const picker = render(); + fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } }); + picker.rerender(); + expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue(""); + picker.rerender(); + expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice"); + fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "" } }); + picker.unmount(); + render(); + expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue(""); + }); + + it("restores expanded domain lists, search and show-more without persisting editor values", () => { + const list = render(); + fireEvent.click(screen.getByRole("button", { name: /^Change per domain/ })); + fireEvent.click(screen.getByRole("button", { name: /Show more/ })); + fireEvent.change(screen.getByPlaceholderText("Find a domain…"), { target: { value: "domain" } }); + list.unmount(); + const refreshed = render(); + expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain"); + expect(screen.getByText("domain-69.test")).toBeInTheDocument(); + expect(screen.queryByText("domain-70.test")).not.toBeInTheDocument(); + refreshed.rerender(); + expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false"); + refreshed.rerender(); + expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain"); + act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } })); + expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false"); + }); +}); diff --git a/web/src/components/app/emails/import/DomainChoices.tsx b/web/src/components/app/emails/import/DomainChoices.tsx index e4944df39..6d994480d 100644 --- a/web/src/components/app/emails/import/DomainChoices.tsx +++ b/web/src/components/app/emails/import/DomainChoices.tsx @@ -171,10 +171,12 @@ export function AllDomainsChoice({ /** The shared choice with each domain's own values in a dropdown under it. */ export function DomainChoicesPanel({ + browseScope = "emails.import.file.domainChoices", infos, picks, setPicks, }: { + browseScope?: string; infos: DomainInfo[]; picks: DomainPicks; setPicks: React.Dispatch>; @@ -226,6 +228,7 @@ export function DomainChoicesPanel({ {usable.length > 1 && ( setPicks((prev) => ({ ...prev, each: { ...prev.each, [d]: { ...prev.each[d], ...patch } } }))} onReset={(d) => @@ -243,11 +246,13 @@ export function DomainChoicesPanel({ /** The picked mailboxes' domains with their choices, for the vendor and grant wizards. */ export function DomainChoicesSection({ + browseScope, infos, picks, setPicks, capped = 0, }: { + browseScope: string; infos: DomainInfo[]; picks: DomainPicks; setPicks: React.Dispatch>; @@ -258,7 +263,7 @@ export function DomainChoicesSection({ return (
Domains - + {capped > 0 && (

{plural(capped, "more domain is", "more domains are")} not listed. Set them up on the Sending domains page after the diff --git a/web/src/components/app/emails/import/MailboxImportWizard.tsx b/web/src/components/app/emails/import/MailboxImportWizard.tsx index ba0a77178..357f2c30f 100644 --- a/web/src/components/app/emails/import/MailboxImportWizard.tsx +++ b/web/src/components/app/emails/import/MailboxImportWizard.tsx @@ -350,6 +350,7 @@ export default function MailboxImportWizard({ )} {step === "run" && importId && ( ("all"); - const [group, setGroup] = React.useState(null); + const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString); + const [filter, setFilter] = useBrowseState(`${browseScope}.filter`, "all", importPickFilter); + const [group, setGroup] = useBrowseState(`${browseScope}.group`, null, browseString.nullable()); const [page, setPage] = React.useState(0); const all = React.useMemo(() => items ?? [], [items]); @@ -199,7 +203,7 @@ export default function PickTable({ ["connected", "Connected"], ] as const ) - .filter(([key]) => key !== "moving" || counts.moving > 0) + .filter(([key]) => key !== "moving" || counts.moving > 0 || filter === "moving") .map(([key, label]) => ( )} {run ? ( setRun(null)} diff --git a/web/src/components/app/placement/MailboxPlacementTab.tsx b/web/src/components/app/placement/MailboxPlacementTab.tsx index 27c6003d9..d1914a70c 100644 --- a/web/src/components/app/placement/MailboxPlacementTab.tsx +++ b/web/src/components/app/placement/MailboxPlacementTab.tsx @@ -1,7 +1,9 @@ // A mailbox's warmup deliverability: where its warmup mail landed, day by day // and per recipient provider, and the trailing inbox rate behind the list's // percentage. Live: WARMUP_PLACEMENT events refresh the query. -import { useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { placementRange, placementGroupFilter } from "@/lib/browse-accounts-analytics"; import { InboxIcon } from "lucide-react"; import { DitherRing } from "@/components/ui/dither"; import { Loading } from "@/components/loader"; @@ -35,8 +37,8 @@ const POOL_STATE_LABEL: Record = { }; export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailboxId: string; poolHealth?: WarmupHealthInfo }) { - const [range, setRange] = useState("30d"); - const [group, setGroup] = useState("all"); + const [range, setRange] = useBrowseState(`emails.mailbox.${mailboxId}.placement.range`, "30d", placementRange); + const [group, setGroup] = useBrowseState(`emails.mailbox.${mailboxId}.placement.group`, "all", placementGroupFilter); const days = RANGES.find((r) => r.key === range)?.days ?? 30; const { from, to } = useMemo(() => utcWindow(days), [days]); const q = useWarmupPlacement(mailboxId, from, to); @@ -171,7 +173,7 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox

By recipient provider
- +
)} diff --git a/web/src/components/app/placement/PlacementBrowse.test.tsx b/web/src/components/app/placement/PlacementBrowse.test.tsx new file mode 100644 index 000000000..a1ee270b0 --- /dev/null +++ b/web/src/components/app/placement/PlacementBrowse.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UserContext } from "@/hooks/context/user"; +import { useAppStore } from "@/stores/useAppStore"; +import type { PlacementHost, PlacementProvider } from "@/lib/api/models/app/analytics/WarmupPlacement"; +import { ProviderBreakdown } from "./PlacementCharts"; + +const counts = { sent: 10, delivered: 10, inbox: 8, tabs: 0, spam: 2, rescued: 0, unconfirmed: 0, inbox_rate: 80, spam_rate: 20 }; +const providers: PlacementProvider[] = [{ + ...counts, group: "google", hosts: ["gmail", "google_workspace"].map((host): PlacementHost => ({ ...counts, host })), +}]; +function Breakdown({ scope = "emails.mailbox.a.placement", loading = false }: { scope?: string; loading?: boolean }) { + const profile = { user: { id: "account-user" } } as ComponentProps["value"]; + return ; +} + +describe("provider expansion browse persistence", () => { + beforeEach(() => { + sessionStorage.clear(); + useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } }); + }); + + it("restores expansion after remount and metadata loading, isolated from other placement panels", () => { + const panel = render(); + fireEvent.click(screen.getByRole("button")); + panel.unmount(); + const refreshed = render(); + refreshed.rerender(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + refreshed.rerender(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false"); + refreshed.rerender(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + fireEvent.click(screen.getByRole("button")); + refreshed.unmount(); + render(); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false"); + }); +}); diff --git a/web/src/components/app/placement/PlacementCharts.tsx b/web/src/components/app/placement/PlacementCharts.tsx index 01238210c..571801749 100644 --- a/web/src/components/app/placement/PlacementCharts.tsx +++ b/web/src/components/app/placement/PlacementCharts.tsx @@ -2,6 +2,8 @@ // deliverability page: the rate badge, daily landing columns, the rolling // inbox-rate trend, the provider breakdown and the filter controls. import React, { useState } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { placementGroup } from "@/lib/browse-accounts-analytics"; import { ChevronRightIcon } from "lucide-react"; import { DitherColumns, DitherStack, type DitherTone } from "@/components/ui/dither"; import { EmptyChart } from "@/components/ui/charts"; @@ -298,8 +300,8 @@ export function RateSpark({ values, width = 96, height = 22 }: { values: (number // One row per recipient group; expanding it lists the hosts inside // (Gmail vs Google Workspace, Outlook.com vs Microsoft 365). -export function ProviderBreakdown({ providers }: { providers: PlacementProvider[] }) { - const [open, setOpen] = useState(null); +export function ProviderBreakdown({ providers, browseScope }: { providers: PlacementProvider[]; browseScope: string }) { + const [open, setOpen] = useBrowseState(`${browseScope}.providerExpanded`, null, placementGroup.nullable()); if (providers.length === 0) { return

No deliveries at any provider in this window.

; } diff --git a/web/src/components/app/placement/WarmupPlacementSection.tsx b/web/src/components/app/placement/WarmupPlacementSection.tsx index b8759611e..373755ce5 100644 --- a/web/src/components/app/placement/WarmupPlacementSection.tsx +++ b/web/src/components/app/placement/WarmupPlacementSection.tsx @@ -1,6 +1,8 @@ // Workspace warmup placement on the deliverability page: inbox vs spam over // time, per recipient provider, and every mailbox ranked worst first. -import { useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseBoolean, placementGroupFilter } from "@/lib/browse-accounts-analytics"; import { useNavigate } from "@tanstack/react-router"; import { ChevronRightIcon } from "lucide-react"; import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Page"; @@ -25,9 +27,9 @@ export default function WarmupPlacementSection({ days }: { days: number }) { const { from, to } = useMemo(() => utcWindow(days), [days]); const q = useWarmupPlacement(undefined, from, to); const report = q.data; - const [group, setGroup] = useState("all"); - const [attentionOnly, setAttentionOnly] = useState(false); - const [showAll, setShowAll] = useState(false); + const [group, setGroup] = useBrowseState("deliverability.placement.group", "all", placementGroupFilter); + const [attentionOnly, setAttentionOnly] = useBrowseState("deliverability.placement.attentionOnly", false, browseBoolean); + const [showAll, setShowAll] = useBrowseState("deliverability.placement.showAll", false, browseBoolean); const navigate = useNavigate(); const groups = useMemo( @@ -121,7 +123,7 @@ export default function WarmupPlacementSection({ days }: { days: number }) { - +

Warmup partner selection reads these numbers per mail host: a mailbox landing in spam at one host is sent fewer partners there while the rate stays high, and more again once it recovers. It is never cut off diff --git a/web/src/components/app/placement/tests/SeedInboxes.tsx b/web/src/components/app/placement/tests/SeedInboxes.tsx index e217a9e05..3c2a50448 100644 --- a/web/src/components/app/placement/tests/SeedInboxes.tsx +++ b/web/src/components/app/placement/tests/SeedInboxes.tsx @@ -3,6 +3,8 @@ // allowance, and a seed never warms up or sends campaign mail. import React from "react"; +import useBrowseState from "@/hooks/useBrowseState"; +import { browseString } from "@/lib/browse-accounts-analytics"; import { InboxIcon, Loader2Icon } from "lucide-react"; import toast from "react-hot-toast/headless"; import { Link } from "@tanstack/react-router"; @@ -26,7 +28,7 @@ const STATUS_LABEL: Record = { export default function SeedInboxes() { const seeds = usePlacementSeeds(); const canManage = usePermission("MANAGE_EMAILS"); - const [q, setQ] = React.useState(""); + const [q, setQ] = useBrowseState("placement.seeds.search", "", browseString); const rows = seeds.data ?? []; const needle = q.trim().toLowerCase(); const shown = needle ? rows.filter((r) => r.email.toLowerCase().includes(needle) || r.label.toLowerCase().includes(needle)) : rows; diff --git a/web/src/lib/browse-accounts-analytics.ts b/web/src/lib/browse-accounts-analytics.ts new file mode 100644 index 000000000..0459548c5 --- /dev/null +++ b/web/src/lib/browse-accounts-analytics.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const browseString = z.string(); +export const browseBoolean = z.boolean(); +export const analyticsRange = z.enum(["7d", "30d", "90d"]); +export const mailboxTab = z.enum(["overview", "deliverability", "analytics", "warmup", "sending", "settings"]); +export type MailboxTab = z.infer; +export const domainTab = z.enum(["overview", "tracking", "redirect"]); +export const domainFilter = z.enum(["all", "attention", "vendor"]); +export const mailboxSort = z.strictObject({ + by: z.enum(["mailbox", "status", "sent", "warmup", "inbox", "health"]), + reverse: z.boolean(), +}).nullable(); +export const analyticsHiddenMetrics = z.array(z.enum(["sent", "opens", "clicks", "replies"])) + .max(3).refine((values) => new Set(values).size === values.length); +export const deliverabilityHiddenMetrics = z.array(z.enum(["bounces", "complaints", "opens", "replies", "sent"])) + .max(4).refine((values) => new Set(values).size === values.length); +export const deliverabilityView = z.strictObject({ + range: analyticsRange, + hidden: z.array(z.enum(["chart", "totals", "placement", "providers", "mailboxes", "campaigns"])) + .max(6).refine((values) => new Set(values).size === values.length), +}); +export const placementRange = z.enum(["7d", "14d", "30d", "90d"]); +export const placementGroup = z.enum(["google", "microsoft", "yahoo", "other"]); +export const placementGroupFilter = z.enum(["all", "google", "microsoft", "yahoo", "other"]); +export const batchTab = z.enum(["mailboxes", "domains", "providers", "recipients"]); +export const batchSenderSort = z.enum(["worst", "best", "email", "status"]); +export const batchSenderStatus = z.enum(["", "queued", "deferred", "running", "completed", "skipped", "failed", "cancelled"]); +export const importFilter = z.strictObject({ + status: z.enum(["", "queued", "running", "connected", "updated", "skipped", "failed", "needs_signin", "cancelled"]), + cause: z.string(), +}).refine((value) => !value.cause || value.status === "failed"); +export const importPickFilter = z.enum(["all", "new", "moving", "connected"]); +export const domainListLimit = z.number().finite().int().positive();