diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d0804a32..8f99b19b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,7 +202,7 @@ jobs: env: WARMBLY_TEST_DB: postgres://warmbly:warmbly@127.0.0.1:5432/warmbly_test?sslmode=disable run: | - go test -race ./internal/repository -run '^TestLive(LastWarmupSendFailure|PersistentMailboxLoadingFailure|DispatchRetry|LineageQueueRevisions|AdminPlatformOverview|WarmupRecoversOldPreparation|WarmupPreparationRecoveryRetains|WarmupFilingRequiresAcknowledgement)' -count=1 + go test -race ./internal/repository -run '^TestLive(Monitoring|LastWarmupSendFailure|PersistentMailboxLoadingFailure|DispatchRetry|LineageQueueRevisions|AdminPlatformOverview|WarmupRecoversOldPreparation|WarmupPreparationRecoveryRetains|WarmupFilingRequiresAcknowledgement)' -count=1 go test -race ./internal/tasks -run '^Test(LiveWarmup(SafetyHoldReschedules|Recovery|TaskDoesNotApplyResults)|WarmupSenderDistinguishes)' -count=1 - name: Test the Kafka codec and client lifecycle diff --git a/admin/src/app/auth/LoginPage.test.tsx b/admin/src/app/auth/LoginPage.test.tsx new file mode 100644 index 000000000..c286f0dd8 --- /dev/null +++ b/admin/src/app/auth/LoginPage.test.tsx @@ -0,0 +1,81 @@ +import { act, type PropsWithChildren } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "./LoginPage"; + +const state = vi.hoisted(() => ({ + login: vi.fn(), confirm: vi.fn(), verify: vi.fn(), saveToken: vi.fn(), + onToken: (_value: string): Promise => Promise.resolve(), + onCode: (_value: string): void => {}, +})); +vi.mock("@/lib/api/client/auth", () => ({ getAuthConfig: () => Promise.resolve({ captcha: false }), login: state.login, loginConfirm: state.confirm, verifyTwoFA: state.verify })); +vi.mock("@/lib/auth/storage", () => ({ setToken: state.saveToken })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("motion/react", () => ({ AnimatePresence: ({ children }: PropsWithChildren) => children, motion: { div: ({ children }: PropsWithChildren) =>
{children}
} })); +vi.mock("@/components/captcha/TurnstileModal", () => ({ TurnstileModal: ({ onToken }: { onToken: typeof state.onToken }) => { state.onToken = onToken; return null; } })); +vi.mock("@/components/ui/input-otp", () => ({ InputOTP: ({ onChange }: { onChange: typeof state.onCode }) => { state.onCode = onChange; return null; }, InputOTPGroup: () => null, InputOTPSlot: () => null })); + +const token = { access_token: "fixture-access", refresh_token: "fixture-refresh", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" }; +let root: Root; +let client: QueryClient; +let clear: ReturnType; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["me"], { id: "previous-admin", admin_permissions: 0xffff }); + client.setQueryData(["system", "mail-status"], { private: "previous-session" }); + clear = vi.spyOn(client, "clear"); + root = createRoot(document.createElement("div")); + await act(async () => root.render()); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function expectClearedBeforeToken() { + expect(client.getQueryCache().getAll()).toHaveLength(0); + expect(state.saveToken).toHaveBeenCalledWith(token); + expect(clear.mock.invocationCallOrder[0]).toBeLessThan(state.saveToken.mock.invocationCallOrder[0]); +} + +describe("admin sign-in session isolation", () => { + it("clears cached identity and private data before a direct successful login", async () => { + state.login.mockResolvedValue({ code_required: false, token }); + await act(async () => state.onToken("fixture-captcha")); + expectClearedBeforeToken(); + }); + + it("keeps cache during an email-code challenge and clears it on confirmation", async () => { + state.login.mockResolvedValue({ code_required: true, session: "fixture-session" }); + state.confirm.mockResolvedValue(token); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + await act(async () => state.onCode("123456")); + expectClearedBeforeToken(); + }); + + it("keeps cache during a second-factor challenge and clears it on verification", async () => { + state.login.mockResolvedValue({ code_required: false, two_fa_required: true, pending_token: "fixture-pending" }); + state.verify.mockResolvedValue(token); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + await act(async () => state.onCode("123456")); + expectClearedBeforeToken(); + }); + + it("does not replace the session or clear caches on a failed login", async () => { + state.login.mockRejectedValue(new Error("fixture-login-failure")); + await act(async () => state.onToken("fixture-captcha")); + expect(clear).not.toHaveBeenCalled(); + expect(state.saveToken).not.toHaveBeenCalled(); + expect(client.getQueryCache().getAll()).toHaveLength(2); + }); +}); diff --git a/admin/src/app/auth/LoginPage.tsx b/admin/src/app/auth/LoginPage.tsx index ea63e38a1..6886215f1 100644 --- a/admin/src/app/auth/LoginPage.tsx +++ b/admin/src/app/auth/LoginPage.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { AnimatePresence, motion, type Transition } from "motion/react"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Eye, EyeOff, Loader2, ArrowLeft, ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -40,6 +41,7 @@ function errorMessage(err: unknown): string { export default function LoginPage() { const nav = useNavigate(); + const queryClient = useQueryClient(); const loc = useLocation(); const [phase, setPhase] = useState<"credentials" | "code" | "twofa">("credentials"); const [pendingToken, setPendingToken] = useState(""); @@ -118,6 +120,7 @@ export default function LoginPage() { return; } if (res.token) { + queryClient.clear(); setToken(res.token); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); @@ -166,6 +169,7 @@ export default function LoginPage() { return; } if (!res.access_token) throw new Error("The server returned an unexpected sign-in response."); + queryClient.clear(); setToken(res as LoginResponse); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); @@ -188,6 +192,7 @@ export default function LoginPage() { setSubmitting(true); try { const tok = await verifyTwoFA({ pending_token: pendingToken, code: value }); + queryClient.clear(); setToken(tok); const dest = (loc.state as { from?: string } | null)?.from ?? "/"; nav(dest, { replace: true }); diff --git a/admin/src/app/dashboard/HealthPage.tsx b/admin/src/app/dashboard/HealthPage.tsx index 724f98d63..d123f6a87 100644 --- a/admin/src/app/dashboard/HealthPage.tsx +++ b/admin/src/app/dashboard/HealthPage.tsx @@ -1,7 +1,3 @@ -// Setup and health: two views of "is this instance ok". Findings are the -// backend's own verdicts about configuration and state; services are live -// probes of what it runs on. ?tab= deep-links either one. - import { Link, useSearchParams } from "react-router-dom"; import { Activity, ListChecks } from "lucide-react"; import { PageHeader } from "@/components/layout/PageHeader"; @@ -10,8 +6,11 @@ import { Button } from "@/components/ui/button"; import { useInstanceHealth } from "@/hooks/useInstanceHealth"; import { FindingsTab } from "./health/FindingsTab"; import { ServicesTab } from "./health/ServicesTab"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; +import { useAdminPerm } from "@/hooks/useAdminPerm"; +import { AdminPerm } from "@/lib/auth/permissions"; -const TAB_IDS = ["findings", "services"] as const; +const TAB_IDS = ["findings", "services", "operations"] as const; type TabId = (typeof TAB_IDS)[number]; function isTabId(v: string | null): v is TabId { @@ -19,6 +18,7 @@ function isTabId(v: string | null): v is TabId { } export default function HealthPage() { + const canConfigure = useAdminPerm(AdminPerm.ManageSettings); const [params, setParams] = useSearchParams(); const raw = params.get("tab"); const tab: TabId = isTabId(raw) ? raw : "findings"; @@ -44,11 +44,11 @@ export default function HealthPage() {
- + } 0 ? findings : undefined, }, { id: "services", label: "Services", icon: Activity }, + { id: "operations", label: "Operations", icon: Activity }, ]} value={tab} onChange={setTab} /> - {tab === "findings" ? : } + {tab === "findings" ? : tab === "services" ? : }
); } diff --git a/admin/src/app/dashboard/InstanceHealthPanel.tsx b/admin/src/app/dashboard/InstanceHealthPanel.tsx index a47162a55..fd749e17b 100644 --- a/admin/src/app/dashboard/InstanceHealthPanel.tsx +++ b/admin/src/app/dashboard/InstanceHealthPanel.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router-dom"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AlertTriangle, ArrowRight, ExternalLink, Info, XCircle, type LucideIcon } from "lucide-react"; -import { StatusBadge } from "@/components/ui/kit"; +import { Callout, StatusBadge } from "@/components/ui/kit"; import { Button } from "@/components/ui/button"; import { useConfirm } from "@/components/ConfirmDialog"; import { docsUrl } from "@/lib/docs"; @@ -140,15 +140,21 @@ function ExpiredInvitationsCleanup() { const OVERVIEW_LIMIT = 4; -// Overview's problems strip. Silent when the instance is clean or when the -// caller cannot read the endpoint, so it never becomes noise on the page. export function InstanceProblemsPanel() { const canRead = useAdminPerm(AdminPerm.ViewAnalytics); const healthQ = useInstanceHealth({ enabled: canRead }); const checks = healthQ.data?.checks ?? []; const actionable = checks.filter((c) => c.severity === "error" || c.severity === "warning"); - if (healthQ.isError || actionable.length === 0) return null; + if (!canRead) return null; + if (healthQ.isError || (healthQ.data && actionable.length === 0)) return ( + + {healthQ.isError ? "Could not refresh findings. This is not an all-clear." : "Absent findings do not establish check coverage or operational health."} + {healthQ.isError && healthQ.data && ` Previous response listed ${actionable.length} errors or warnings; it is not a fresh verdict.`} + {" "}Setup and health + + ); + if (actionable.length === 0) return null; const errors = actionable.filter((c) => c.severity === "error").length; const shown = actionable.slice(0, OVERVIEW_LIMIT); diff --git a/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx b/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx new file mode 100644 index 000000000..3e24d20d5 --- /dev/null +++ b/admin/src/app/dashboard/InstanceMonitoringPanel.test.tsx @@ -0,0 +1,192 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MonitoringMetric, MonitoringSnapshot, MonitoringSource } from "@/lib/api/client/admin/monitoring"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; + +const state = vi.hoisted(() => ({ + mask: 0 as number | undefined, + query: { data: undefined as MonitoringSnapshot | undefined, isError: false, isLoading: false, isFetching: false, error: new Error("provider-password-private"), refetch: vi.fn() }, +})); +vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: "admin", admin_permissions: state.mask } }) })); +vi.mock("@/hooks/useInstanceMonitoring", () => ({ useInstanceMonitoring: () => state.query })); + +const at = new Date().toISOString(); +const evidence = new Date(Date.now() - 3_600_000).toISOString(); +function metric(overrides: Partial = {}): MonitoringMetric { + return { + id: "failed_send", title: "Stored failure report", unit: "reports", availability: "fresh", + condition: "recent_failure", severity: "warning", note: "Only confirmed send evidence closes an incident.", + count: 7, affected_mailboxes: 3, affected_organizations: 2, unknown_organization_rows: 1, + observed_at: at, evidence_at: evidence, latest_evidence_at: at, evidence_age_seconds: 3600, + next_eligible_at: null, window_start: evidence, window_end: at, threshold_seconds: 3600, + investigate: "/sends?tab=failures", ...overrides, + }; +} +function source(overrides: Partial = {}): MonitoringSource { + return { id: "sends", availability: "fresh", coverage: "complete", observed_at: at, checked_at: at, + measured_scopes: null, expected_scopes: null, metrics: [metric()], ...overrides }; +} +function snapshot(sources: MonitoringSource[] = [source()]): MonitoringSnapshot { + return { version: "1", checked_at: at, refresh_after: at, coverage: "complete", sources }; +} +function render(compact = false) { + const html = renderToStaticMarkup(); + return new DOMParser().parseFromString(html, "text/html"); +} + +beforeEach(() => { + state.mask = Object.values(AdminPerm).reduce((mask, bit) => mask | bit, 0); + Object.assign(state.query, { data: snapshot(), isError: false, isLoading: false, isFetching: false }); +}); + +describe("operational monitoring evidence", () => { + it("keeps metric counts distinct from affected mailboxes, organizations and attention signals", () => { + const document = render(); + const text = document.body.textContent; + expect(text).toContain("1 recorded attention signals"); + expect(text).toContain("7 reports"); + expect(text).toContain("3 affected mailboxes"); + expect(text).toContain("2 affected organizations"); + expect(text).toContain("1 rows with unknown organization"); + expect(text).toContain("Observed:"); + expect(text).toContain("Evidence since:"); + expect(text).toContain("Latest evidence:"); + expect(document.querySelector(`time[datetime="${evidence}"]`)).not.toBeNull(); + expect(document.querySelector('a[href="/sends?tab=failures"]')).not.toBeNull(); + }); + + it("renders loading, failed and successful empty requests without a green verdict", () => { + state.query.data = undefined; + state.query.isLoading = true; + expect(render().querySelector('[role="status"]')?.textContent).toContain("Loading operational monitoring"); + state.query.isLoading = false; + state.query.isError = true; + expect(render().body.textContent).toContain("Monitoring refresh failed"); + expect(render().body.textContent).not.toContain("provider-password-private"); + state.query.isError = false; + state.query.data = snapshot([]); + expect(render().body.textContent).toContain("No monitoring sources returned"); + expect(render().body.textContent).not.toContain("recorded attention signals"); + }); + + it.each(["unsupported", "dependency_missing", "schema_absent", "timeout", "query_failed", "permission_denied"])("exposes %s coverage without inventing zero counts", (reason) => { + state.query.data = snapshot([source({ availability: "unavailable", coverage: "unavailable", reason, metrics: [] })]); + const text = render().body.textContent; + expect(text).toContain("1 sources with coverage gaps"); + expect(text).toContain("No current measurement available"); + expect(text).not.toContain("0 reports"); + }); + + it("keeps a missing measurement distinct from a measured zero", () => { + state.query.data = snapshot([source({ metrics: [metric({ count: null, affected_mailboxes: null, affected_organizations: null }), metric({ id: "sent", title: "Send confirmations", count: 0, condition: "no_recent_evidence" })] })]); + const text = render().body.textContent; + expect(text).toContain("Not measured"); + expect(text).toContain("0 reports"); + expect(text).toContain("No matching recent evidence"); + expect(text).not.toContain("Confirmed scoped observation"); + }); + + it("retains unsupported metric explanations without presenting their counts as current measurements", () => { + state.query.data = snapshot([source({ id: "send_wait_attribution", availability: "unavailable", coverage: "unavailable", reason: "unsupported", metrics: [metric({ title: "Detailed live send waits", condition: "budget", count: 123, note: "Scheduler does not persist per-task budget decisions." })] })]); + const text = render().body.textContent; + expect(text).toContain("Detailed live send waits"); + expect(text).toContain("Scheduler does not persist per-task budget decisions"); + expect(text).toContain("Not measured"); + expect(text).not.toContain("123 reports"); + expect(text).not.toContain("3 affected mailboxes"); + }); + + it("shows retained stale observations and partial samples with their original evidence clocks", () => { + state.query.data = snapshot([source({ availability: "stale", coverage: "partial", measured_scopes: 20, expected_scopes: 35, metrics: [metric({ availability: "stale" })] })]); + const document = render(); + expect(document.body.textContent).toContain("20 / 35 scopes measured"); + expect(document.body.textContent).toContain("lower bounds"); + expect(document.body.textContent).toContain("Stale observation"); + expect(document.body.textContent).toContain("historical, not proof of a current failure"); + expect(document.querySelector(`time[datetime="${evidence}"]`)).not.toBeNull(); + }); + + it("a failed refresh cannot make cached healthy data appear fresh", () => { + state.query.isError = true; + state.query.data = snapshot([source({ metrics: [metric({ condition: "healthy", severity: "info" })] })]); + const document = render(); + expect(document.body.textContent).toContain("previous snapshot with its original timestamps"); + expect(document.body.textContent).toContain("Stale observation"); + expect(document.body.textContent).not.toContain("Fresh / complete"); + expect(document.querySelector(`time[datetime="${at}"]`)).not.toBeNull(); + }); + + it("ages snapshots by observation time, not successful client fetch time", () => { + const old = new Date(Date.now() - 300_000).toISOString(); + state.query.data = snapshot([source({ observed_at: old, metrics: [metric({ observed_at: old })] })]); + expect(render().body.textContent).toContain("Stale observation"); + }); + + it("denies metrics and drill-downs when permission bits are absent or unknown", () => { + for (const mask of [undefined, 0]) { + state.mask = mask; + const document = render(true); + expect(document.body.textContent).toContain("Monitoring permission required"); + expect(document.body.textContent).not.toContain("Stored failure report"); + expect(document.querySelector("a")).toBeNull(); + } + state.mask = AdminPerm.ViewAnalytics; + expect(render().body.textContent).toContain("Permission required"); + expect(render().body.textContent).not.toContain("Stored failure report"); + expect(render().querySelector('a[href="/sends?tab=failures"]')).toBeNull(); + }); + + it("checks the link destination permission separately from the visible source", () => { + state.mask = AdminPerm.ViewAnalytics | AdminPerm.ViewOrganizations; + state.query.data = snapshot([source({ id: "webhooks", metrics: [metric({ investigate: "/sends?tab=webhooks" })] })]); + expect(render().body.textContent).toContain("7 reports"); + expect(render().querySelector('a[href="/sends?tab=webhooks"]')).toBeNull(); + state.mask |= AdminPerm.ViewCampaigns; + expect(render().querySelector('a[href="/sends?tab=webhooks"]')).not.toBeNull(); + }); + + it("rejects unrecognized source data, arbitrary links, raw reason and scope payloads", () => { + state.query.data = snapshot([source({ reason: "provider-password-private", metrics: [metric({ scope_id: "secret@example.com", reason: "provider-password-private", investigate: "https://evil.invalid/?token=secret" })] }), source({ id: "secret-source", metrics: [metric({ title: "secret-title" })] })]); + const text = render().body.innerHTML; + for (const secret of ["provider-password-private", "secret@example.com", "evil.invalid", "secret-title", "secret-source"]) expect(text).not.toContain(secret); + }); + + it.each(["committed_lag", "pending", "ack_pending", "redelivered"])("keeps positive broker %s prominent while preserving unknown/info", (id) => { + state.query.data = snapshot([source({ metrics: Array.from({ length: 8 }, (_, i) => metric({ id: `failure-${i}` })) }), source({ id: "backend_event_broker", metrics: [metric({ id, title: `Broker ${id}`, condition: "unknown", severity: "info", count: 45 })] })]); + const document = render(true); + expect(document.body.textContent).toContain("1 positive broker queue measurements"); + expect(document.body.textContent).toContain("45 reports"); + expect(document.body.textContent).toContain("Queue measurement, not outage"); + expect(document.body.textContent).toContain("Unknown"); + expect(document.body.textContent).not.toContain("Confirmed scoped observation"); + }); + + it("does not classify waits or a fresh zero queue as a failure", () => { + state.query.data = snapshot([source({ metrics: ["waiting_schedule", "waiting_retry", "budget", "daily_limit", "in_flight"].map((condition) => metric({ condition: condition as MonitoringMetric["condition"], severity: "info", next_eligible_at: at })) }), source({ id: "backend_event_broker", metrics: [metric({ id: "pending", condition: "unknown", severity: "info", count: 0 })] })]); + const text = render().body.textContent; + expect(text).toContain("0 recorded attention signals"); + expect(text).toContain("Scheduled wait"); + expect(text).toContain("Retry wait"); + expect(text).toContain("Budget wait"); + expect(text).toContain("Daily-limit wait"); + expect(text).toContain("Next eligible:"); + expect(text).toContain("not an instance-wide all-clear"); + }); + + it("shows safety holds as restrictions and counts unknown warning observations without outage claims", () => { + state.query.data = snapshot([source({ metrics: [metric({ condition: "safety_hold" }), metric({ id: "missing", condition: "unknown", severity: "warning" })] })]); + const text = render(true).body.textContent; + expect(text).toContain("2 recorded attention signals"); + expect(text).toContain("Safety hold"); + expect(text).toContain("Unknown"); + expect(text).toContain("restrictions, not provider outages"); + }); + + it("does not interpret an unsupported contract version", () => { + state.query.data = { ...snapshot(), version: "2" }; + expect(render().body.textContent).toContain("Unsupported monitoring version"); + expect(render().body.textContent).not.toContain("7 reports"); + }); +}); diff --git a/admin/src/app/dashboard/InstanceMonitoringPanel.tsx b/admin/src/app/dashboard/InstanceMonitoringPanel.tsx new file mode 100644 index 000000000..0da059928 --- /dev/null +++ b/admin/src/app/dashboard/InstanceMonitoringPanel.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Activity, AlertTriangle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Callout, EmptyState, StatusBadge } from "@/components/ui/kit"; +import { useInstanceMonitoring } from "@/hooks/useInstanceMonitoring"; +import { useMe } from "@/hooks/useMe"; +import { AdminPerm } from "@/lib/auth/permissions"; +import type { MonitoringMetric, MonitoringSource } from "@/lib/api/client/admin/monitoring"; +import { + ageLabel, canMonitor, CONDITION_LABELS, investigationPath, isBrokerQueue, + MONITORING_SOURCES, monitoringReason, needsMonitoringAttention, observationIsOld, + permittedSource, timestamp, +} from "@/lib/monitoring"; +import type { Tone } from "@/lib/tones"; + +function ObservationTime({ label, value, now }: { label: string; value: string | null; now: number }) { + const at = timestamp(value); + return ( + + {label}: {at === null ? "unknown" : } + + ); +} + +function metricIsStale(metric: MonitoringMetric, source: MonitoringSource, now: number): boolean { + return metric.availability === "stale" || source.availability === "stale" || + observationIsOld(metric.observed_at, now) || observationIsOld(source.observed_at, now); +} + +function metricTone(metric: MonitoringMetric, stale: boolean): Tone { + if (metric.availability === "unavailable") return "neutral"; + if (stale) return "warning"; + if (needsMonitoringAttention(metric)) return isBrokerQueue(metric) ? "info" : metric.severity === "error" ? "danger" : "warning"; + if (metric.condition === "healthy") return "success"; + return "info"; +} + +function MetricRow({ metric: m, source, mask, now, compact }: { + metric: MonitoringMetric; source: MonitoringSource; mask: number | undefined; now: number; compact?: boolean; +}) { + const stale = metricIsStale(m, source, now); + const unavailable = m.availability === "unavailable"; + const path = investigationPath(m.investigate, mask); + const measured = !unavailable && typeof m.count === "number"; + return ( +
  • +
    +

    {m.title}

    +
    + {unavailable ? "Unavailable" : !measured ? "Unknown" : CONDITION_LABELS[m.condition] ?? "Unknown"} + {!unavailable && stale && Stale observation} + {isBrokerQueue(m) && Queue measurement, not outage} +
    +
    + {compact &&

    {MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}

    } +
    + {measured ? `${m.count!.toLocaleString()} ${m.unit}` : "Not measured"} + {!unavailable && m.affected_mailboxes !== null && {m.affected_mailboxes.toLocaleString()} affected mailboxes} + {!unavailable && m.affected_organizations !== null && {m.affected_organizations.toLocaleString()} affected organizations} + {!unavailable && m.unknown_organization_rows !== null && m.unknown_organization_rows > 0 && {m.unknown_organization_rows.toLocaleString()} rows with unknown organization} +
    + {m.reason &&

    {monitoringReason(m.reason)}

    } + {m.note &&

    {m.note}

    } +
    + + + {m.latest_evidence_at && } + {m.next_eligible_at && } +
    + {!compact &&
    + {m.evidence_age_seconds !== null && Evidence age reported by snapshot: {m.evidence_age_seconds.toLocaleString()}s} + {m.threshold_seconds !== null && Monitoring threshold: {m.threshold_seconds.toLocaleString()}s (not a provider SLA)} + {m.window_start && } + {m.window_end && } +
    } + {path && Investigate {m.title}} +
  • + ); +} + +function SourcePanel({ source, mask, now }: { source: MonitoringSource; mask: number | undefined; now: number }) { + const unavailable = source.availability === "unavailable"; + const stale = !unavailable && (source.availability === "stale" || observationIsOld(source.observed_at, now)); + return ( +
    +
    +
    +

    {MONITORING_SOURCES[source.id]?.title ?? "Unrecognized source"}

    + + {unavailable ? "Unavailable" : stale ? "Stale" : "Fresh"} / {source.coverage === "complete" ? "complete" : "incomplete"} coverage + +
    +
    + + + {source.measured_scopes !== null && {source.measured_scopes.toLocaleString()} / {source.expected_scopes?.toLocaleString() ?? "unknown"} scopes measured} +
    + {source.reason &&

    {monitoringReason(source.reason)}

    } + {source.coverage !== "complete" && !unavailable &&

    Incomplete coverage. Measured counts may be lower bounds; do not sum overlapping metrics.

    } +
    + {(unavailable || source.metrics.length === 0) && ( +

    {unavailable ? "No current measurement available. This is not zero backlog or evidence of an outage." : "No metrics returned. Operational health is unknown for this source."}

    + )} + {source.metrics.length > 0 && ( +
      {source.metrics.map((m, i) => )}
    + )} +
    + ); +} + +export function InstanceMonitoringPanel({ compact = false }: { compact?: boolean }) { + const { data: me } = useMe(); + const query = useInstanceMonitoring(); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + const mask = me?.admin_permissions; + const allowed = canMonitor(mask, AdminPerm.ViewAnalytics); + const data = allowed ? query.data : undefined; + const sources = data?.sources.map((source) => { + const permitted = permittedSource(source, mask); + return query.isError && permitted.availability === "fresh" ? { ...permitted, availability: "stale" as const } : permitted; + }) ?? []; + const attention = sources.flatMap((source) => source.availability === "unavailable" ? [] : source.metrics.filter(needsMonitoringAttention).map((metric) => ({ source, metric }))); + const queues = attention.filter(({ metric }) => isBrokerQueue(metric)); + const issues = attention.filter(({ metric }) => !isBrokerQueue(metric)); + const gaps = sources.filter((s) => s.availability === "unavailable" || s.coverage !== "complete" || s.metrics.length === 0 || s.metrics.some((m) => m.availability === "unavailable" || m.count === null)); + const stale = sources.filter((s) => s.availability === "stale" || s.availability !== "unavailable" && (observationIsOld(s.observed_at, now) || s.metrics.some((m) => metricIsStale(m, s, now)))); + const unknown = sources.filter((s) => s.metrics.some((m) => ["unknown", "no_recent_evidence"].includes(m.condition))); + const missing = data?.version === "1" ? Object.keys(MONITORING_SOURCES).filter((id) => !sources.some((s) => s.id === id)).length : 0; + const refreshAt = timestamp(data?.refresh_after); + const waiting = !query.isError && refreshAt !== null && refreshAt > now; + const unsupported = data && data.version !== "1"; + return ( +
    +
    +

    Operational monitoring

    +
    + {compact && allowed && All operational details} + {allowed && } +
    +
    + {!allowed && Analytics access and a known permission mask are required. No operational measurements were requested.} + {allowed && query.isLoading &&
    Loading operational monitoring
    } + {allowed && query.isError && {data ? "Showing the previous snapshot with its original timestamps, not a fresh health verdict." : "Operational measurements are unavailable. An empty or failed request is not an all-clear. Verify access or retry."}} + {unsupported && This admin release cannot interpret this snapshot. Update the admin frontend; health remains unknown.} + {data && !unsupported && <> +
    + + +
    + {sources.length === 0 ? : <> + + {gaps.length} sources with coverage gaps; {stale.length} stale; {unknown.length} with unknown or no recent evidence{missing > 0 ? `; ${missing} expected sources not returned` : ""}. Snapshot coverage: {data.coverage === "complete" ? "complete for collected sources" : "incomplete"}. + {attention.length === 0 && " No attention signals in measured sources. This is not an instance-wide all-clear."} + {stale.length > 0 && " Stale observations are historical, not proof of a current failure."} + +

    Counts are per metric and may overlap. Scheduled, retry, budget and daily-limit waits are informational; safety holds are restrictions, not provider outages. Heartbeats, completed tasks and sync ticks do not prove sends or recovery.

    + {compact ? <> + {queues.length > 0 &&
    +

    {queues.length} positive broker queue measurements

    +

    Observed pending or lag counts, not a proven outage or delivery rate. Separate scopes and metrics must not be added together.

    +
      {queues.map(({ source, metric }, i) => )}
    +
    } + {issues.length > 0 &&
      {issues.slice(0, 6).map(({ source, metric }, i) => )}
    } + {issues.length > 6 &&

    {issues.length - 6} more signals in operational details.

    } + :
    {sources.map((source) => )}
    } + } + } +
    + ); +} diff --git a/admin/src/app/dashboard/OverviewPage.tsx b/admin/src/app/dashboard/OverviewPage.tsx index 35e05739f..455bb6b7e 100644 --- a/admin/src/app/dashboard/OverviewPage.tsx +++ b/admin/src/app/dashboard/OverviewPage.tsx @@ -25,6 +25,7 @@ import { PageHeader } from "@/components/layout/PageHeader"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState, Panel, Section, Segmented, Stat, StatGrid } from "@/components/ui/kit"; import { InstanceProblemsPanel } from "./InstanceHealthPanel"; +import { InstanceMonitoringPanel } from "./InstanceMonitoringPanel"; import { useAdminPerm } from "@/hooks/useAdminPerm"; import { AdminPerm } from "@/lib/auth/permissions"; import { TONE_DOT, TONE_TEXT } from "@/lib/tones"; @@ -83,6 +84,7 @@ export default function OverviewPage() { + {!canView ? (
    diff --git a/admin/src/app/dashboard/health/FindingsTab.tsx b/admin/src/app/dashboard/health/FindingsTab.tsx index 818f5afa7..2087864b3 100644 --- a/admin/src/app/dashboard/health/FindingsTab.tsx +++ b/admin/src/app/dashboard/health/FindingsTab.tsx @@ -1,7 +1,3 @@ -// Setup and health, findings: everything this deployment is currently -// getting wrong, as decided by the running backend. The endpoint returns -// only checks that are not ok, so an empty response is a real all-clear. - import { useState } from "react"; import { cn } from "@/lib/utils"; import { useSearchParams } from "react-router-dom"; @@ -43,7 +39,7 @@ export function FindingsTab() {
    {healthQ.dataUpdatedAt > 0 && ( - Last checked {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} + Response received {new Date(healthQ.dataUpdatedAt).toLocaleTimeString()} )} -
    -
    - - -
    - {statStrip.map((s) => ( -
    -
    - {s.label} - {s.accent && } -
    -
    {s.value}
    -
    {s.sub}
    -
    - ))} -
    - - -
    -
    -
    - Email performance -
    - {series.map((m) => ( - - - {m.label} - - ))} -
    -
    -
    - -
    - {grid.map((g) => ( -
    -
    - {g.label} -
    - ))} - -
    -
    - {days[0]} - {days[days.length - 1]} -
    -
    -
    -
    - - -
    - - -
    - Top campaigns - - All campaigns - - -
    -
    - {campaigns.map((c) => ( -
    - - {c.name} - - {c.sent} sent - - {c.reply} reply - -
    - ))} -
    - - -
    - Recent activity -
    -
    - {activity.map((a) => ( -
    - - - {a.verb} {a.email} - - {a.origin && ( - - )} - - {a.ts} -
    - ))} -
    - - - - - - - - - - - - diff --git a/site/src/components/AppFrame.astro b/site/src/components/AppFrame.astro deleted file mode 100644 index 33160102a..000000000 --- a/site/src/components/AppFrame.astro +++ /dev/null @@ -1,28 +0,0 @@ ---- -/** - * Mac-style app window chrome. Renders the traffic-light dots, an - * optional URL pill, and a slot for the app body. - */ -interface Props { - url?: string; - class?: string; -} -const { url, class: className = '' } = Astro.props; ---- -
    -
    -
    - - - -
    - {url && ( -
    - - {url} -
    - )} -
    -
    - -
    diff --git a/site/src/components/AppShellMock.astro b/site/src/components/AppShellMock.astro index aeaa7ca36..dfb9ecd71 100644 --- a/site/src/components/AppShellMock.astro +++ b/site/src/components/AppShellMock.astro @@ -58,6 +58,7 @@ const navIcons: Record = { Forms: '', Analytics: '', Deliverability: '', + 'Placement tests': '', Tasks: '', Meetings: '', Templates: '', @@ -103,6 +104,7 @@ const sidebarSections: { label: string; items: Row[] }[] = [ { title: 'Forms' }, { title: 'Analytics', stat: { total: 84320, live: 'period' } }, { title: 'Deliverability' }, + { title: 'Placement tests' }, ]}, { label: 'CRM', items: [ { title: 'Pipelines', stat: { total: 3 } }, @@ -236,9 +238,9 @@ const online = [ 3 - - - + + + @@ -296,8 +298,9 @@ const online = [ {sidebarSections.map((s, si) => (
    {s.label && ( -
    - {s.label} +
    + {s.label} +
    )}
    @@ -306,7 +309,7 @@ const online = [ const path = navIcons[it.title] ?? ''; const st = it.stat; return ( -
    +
    {it.title} {it.badge && ( @@ -345,13 +348,13 @@ const online = [
    -
    +
    Settings
    -
    +
    Collapse
    diff --git a/site/src/components/AutomationsShowcase.astro b/site/src/components/AutomationsShowcase.astro index 3d9878c55..e458752e0 100644 --- a/site/src/components/AutomationsShowcase.astro +++ b/site/src/components/AutomationsShowcase.astro @@ -1,6 +1,7 @@ --- import Icon from './Icon.astro'; import AutomationsDemo from './AutomationsDemo.astro'; +import StackStrip from './StackStrip.astro'; /** * Landing-page centerpiece for Automations. Breaks the copy-beside-window @@ -9,21 +10,27 @@ import AutomationsDemo from './AutomationsDemo.astro'; * fragments), with the live demo window running a test end to end at * near-full container width. */ +// Every line follows docs/content/docs/guides/automations.mdx. const callouts = [ { icon: 'gitBranch', - label: 'Branches like you think', - text: 'One IF chip routes on intent, confidence, source, or a random split. Yes goes right, no goes down, and the no path is still yours to wire.', + label: 'Branch on anything', + text: 'Split on reply intent, confidence, where a contact came from or any field the event carries. Yes goes one way, no goes the other.', }, { icon: 'layers', - label: 'Acts across your stack', - text: 'Seven built-in actions plus Slack, Discord, HubSpot, Salesforce, Pipedrive, Close and HMAC-signed webhooks. No keys to paste for automation tools.', + label: 'Act inside Warmbly and out', + text: 'Open the deal, create a task, label the thread or enrol the contact in a campaign, then tell Slack and update your CRM.', + }, + { + icon: 'spark', + label: 'Let AI decide', + text: 'Ask a yes or no question about the event, or add an AI step that reads the reply and acts, using only the actions you allow.', }, { icon: 'flask', - label: 'Test before it is on', - text: 'Dry-run any automation against a sample event and read every node verdict, yes or no, before the switch ever flips.', + label: 'Test before it runs', + text: 'A dry run against sample data shows the exact path on the canvas, without sending anything or changing a record.', }, ]; --- @@ -68,20 +75,23 @@ const callouts = [
    - X-Warmbly-Signature: sha256=… + X-Warmbly-Signature: t=1746820392,v1=3f9c…
    -
    Automations
    -

    +
    Automations
    +

    When this happens, do that.

    -

    +

    Every reply, booked meeting, bounce and warmup dip is a trigger. Wire it to your stack on a canvas: branch on intent, post to Slack, open the deal, update the CRM. The one below is running its test right now. Press Test in the window to fire it again.

    + + Explore automations +
    @@ -90,28 +100,19 @@ const callouts = [
    -
    +
    {callouts.map((c) => ( -
    -
    - -
    {c.label}
    -
    -

    {c.text}

    +
    + +

    {c.label}

    +

    {c.text}

    ))}
    - + + +
    - - - - diff --git a/site/src/components/Button.astro b/site/src/components/Button.astro deleted file mode 100644 index 28eb82285..000000000 --- a/site/src/components/Button.astro +++ /dev/null @@ -1,31 +0,0 @@ ---- -interface Props { - href: string; - variant?: 'primary' | 'secondary' | 'ghost' | 'sky'; - size?: 'sm' | 'md' | 'lg'; - external?: boolean; - class?: string; -} -const { href, variant = 'primary', size = 'md', external = false, class: className = '' } = Astro.props; - -const base = 'inline-flex items-center justify-center gap-2 rounded-[10px] font-medium transition-colors whitespace-nowrap'; -const sizes = { - sm: 'h-8 px-3 text-[12.5px]', - md: 'h-10 px-4 text-[14px]', - lg: 'h-11 px-5 text-[14.5px]', -}; -const variants = { - primary: 'bg-foreground text-background hover:bg-foreground/85', - secondary: 'bg-[color:var(--surface-2)] text-foreground ring-1 ring-[color:var(--border)] hover:bg-[color:var(--surface-3)]', - ghost: 'text-foreground hover:bg-[color:var(--surface-2)]', - sky: 'bg-[color:var(--brand)] text-white hover:bg-[color:var(--brand-strong)] shadow-[0_10px_24px_-10px_rgba(2,132,199,0.55)]', -}; ---- - - - diff --git a/site/src/components/CRMMock.astro b/site/src/components/CRMMock.astro deleted file mode 100644 index 68045879d..000000000 --- a/site/src/components/CRMMock.astro +++ /dev/null @@ -1,282 +0,0 @@ ---- -/** - * The /app/crm/deals screen, mirrored from the app: - * - * web/src/app/app/crm/deals/page.tsx PageTopbar, StatStrip, the Board (280px columns on a 12px - * gap), BoardColumn (h-9 header, open value row), DealCard - * (name, contact, value and expected close), and the deal - * dialog a card click opens (480px, scrim with a 2px blur, - * 8px lift over 160ms) - * web/src/components/layout/Page.tsx PageTopbar / StatStrip / SectionBar - * web/src/components/ui/field.tsx Label / TextInput - * web/src/components/ui/DatePicker.tsx the Expected close trigger - * - * Demo: the cursor clicks the Stratech card, the deal dialog opens, then it - * closes from the dialog's own X. - */ -import DemoCursor from './DemoCursor.astro'; - -// Money formatter, mirrors formatMoney() in deals/page.tsx. -const money = (n: number) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); - -type Deal = { name: string; value: number; contact: string; close?: string; status?: 'won'; key?: string }; -type Stage = { name: string; color: string; deals: Deal[] }; - -const stages: Stage[] = [ - { - name: 'Qualified', - color: '#0ea5e9', - deals: [ - { name: 'Stratech', value: 45000, contact: 'rowan@stratech.io', close: 'Jul 15', key: 'stratech' }, - { name: 'Northwind', value: 8500, contact: 'ravi@northwind.dev', close: 'Jul 30' }, - { name: 'Spruce Bio', value: 6200, contact: 'lena@sprucebio.com' }, - ], - }, - { - name: 'Proposal', - color: '#8b5cf6', - deals: [ - { name: 'Helio', value: 24000, contact: 'founder@helio.gen', close: 'Jun 28' }, - { name: 'Tidewater Labs', value: 15500, contact: 'ops@tidewater.io', close: 'Jul 8' }, - ], - }, - { - name: 'Negotiation', - color: '#f59e0b', - deals: [ - { name: 'Brightfold', value: 31000, contact: 'priya@brightfold.com', close: 'Jun 20' }, - { name: 'Maple & Co', value: 9800, contact: 'sam@mapleco.com', close: 'Jun 24' }, - ], - }, - { - name: 'Won', - color: '#10b981', - deals: [{ name: 'Cobalt Group', value: 42000, contact: 'ceo@cobaltgroup.com', close: 'Jun 2', status: 'won' }], - }, -]; - -const allDeals = stages.flatMap((s) => s.deals); -const openValue = allDeals.filter((d) => d.status !== 'won').reduce((a, d) => a + d.value, 0); -const openCount = allDeals.filter((d) => d.status !== 'won').length; -const wonValue = allDeals.filter((d) => d.status === 'won').reduce((a, d) => a + d.value, 0); -// The app's stage summary counts and sums open deals only (pg_crm.go). -const stageOpen = (s: Stage) => s.deals.filter((d) => d.status !== 'won'); -const stageTotal = (s: Stage) => stageOpen(s).reduce((a, d) => a + d.value, 0); - -const stats = [ - { label: 'Open', value: String(openCount), sub: 'open deals', last: false }, - { label: 'Pipeline value', value: money(openValue), sub: 'open · server total', last: false }, - { label: 'Won', value: money(wonValue), sub: 'closed won', last: false }, - { label: 'Stages', value: String(stages.length), sub: 'on this pipeline', last: true }, -]; - -const ic = { - user: '', - dollar: '', - calendar: '', - trophy: '', - trash: '', - x: '', -}; ---- -
    - -
    - Deals -
    - Sales pipeline - -
    -
    - - - Table - - - - Board - -
    - -
    - - Search deals… -
    - -
    -
    - - -
    - {stats.map((s) => ( -
    -
    - {s.label} -
    -
    {s.value}
    -
    {s.sub}
    -
    - ))} -
    - - -
    - {stages.length} stages -
    - - -
    -
    - {stages.map((stage) => ( -
    -
    - - {stage.name} - {stageOpen(stage).length} -
    - {stageTotal(stage) > 0 && ( -
    - {money(stageTotal(stage))} open -
    - )} -
    - {stage.deals.map((d) => ( -
    -
    -
    {d.name}
    - {d.status === 'won' && Won} -
    -
    - - {d.contact} -
    -
    - - - {money(d.value)} - - {d.close && ( - - - {d.close} - - )} -
    -
    - ))} -
    -
    - ))} -
    -
    - - - - - - - - - -
    - - diff --git a/site/src/components/CTA.astro b/site/src/components/CTA.astro index 4a67179e4..c42e6655c 100644 --- a/site/src/components/CTA.astro +++ b/site/src/components/CTA.astro @@ -1,7 +1,11 @@ --- -import Cloud from './Cloud.astro'; import Icon from './Icon.astro'; +import Photo from './Photo.astro'; +/** + * The closing band on every marketing page: a photo frame with a white wash + * on the copy side, one line, one sentence and two buttons. + */ interface Props { title?: string; description?: string; @@ -9,33 +13,35 @@ interface Props { primaryHref?: string; secondaryLabel?: string; secondaryHref?: string; + photo?: string; } const { - title = 'Send cold email people actually open.', - description = 'Connect a mailbox, warm it up, send your first campaign today. Free to self-host, warmup included.', + title = 'Start sending cold email that lands.', + description = 'Connect a mailbox and start warming it today. Free to start, free to self-host.', primaryLabel = 'Start free', primaryHref = 'https://app.warmbly.com/auth/register', secondaryLabel = 'See pricing', secondaryHref = '/pricing/', + photo = 'sheer-blue', } = Astro.props; +const external = secondaryHref.startsWith('http'); --- -
    -
    - -
    - -
    -

    - {title} -

    -

    {description}

    -
    - - {primaryLabel} - - - {secondaryLabel} - +
    +
    + + + +
    +

    {title}

    +

    {description}

    +
    diff --git a/site/src/components/CampaignBuilderMock.astro b/site/src/components/CampaignBuilderMock.astro deleted file mode 100644 index 19b98e458..000000000 --- a/site/src/components/CampaignBuilderMock.astro +++ /dev/null @@ -1,842 +0,0 @@ ---- -/** - * The campaign Steps screen, mirrored from the app: - * - * web/src/app/app/campaigns/[id]/layout.tsx header (back link, name, status, Start, actions) + tab bar - * web/src/components/app/campaigns/sequences/CampaignFlow.tsx the tree canvas (web/src/components/app/flow): the dot - * grid, the Stop on reply toolbar row, the zoom controls, - * StepCard cards with waits beside the lines into them, - * branch pills over their columns, + buttons and End, - * and the edit sheet a card click opens (NodeTypeSwitcher, - * then the email editor or the ActionEditor with - * SwitchStepFields / AIStepFields). The sheet itself stays - * the 320px SidePanel springing in from the right over the - * whole window. - * .../sequences/StepEmailArms.tsx + SequenceView.tsx + EmailContentEditor.tsx + RichTextEditor.tsx - * web/src/components/app/campaigns/ContentScore.tsx - * web/src/components/app/ai/RichTextAICaret.tsx the caret companion and its ⌘J menu - * - * `demo` picks the screen: - * flow the cursor clicks the first step (the edit panel appears at once, - * as in the app), then closes it with the panel's X - * ai the first step is open and focused; the cursor clicks the sparkle - * on the caret line, types an instruction, and the assistant writes - * the email at the caret - * switch a Switch step is open: AI reads each contact and picks a case, - * every case its own column under the step - * agent an AI step is open: an instruction plus the actions the agent may - * take for each contact - */ -import DemoCursor from './DemoCursor.astro'; - -interface Props { demo?: 'flow' | 'ai' | 'switch' | 'agent' } -const { demo = 'flow' } = Astro.props; -// The panel is mounted from the start on every screen but the flow demo. -const open = demo !== 'flow'; -const play = demo === 'flow' || demo === 'ai'; - -const ic: Record = { - arrowLeft: '', - play: '', - more: '', - barchart: '', - users: '', - listchecks: '', - calendar: '', - settings2: '', - plus: '', - minus: '', - maximize: '', - clock: '', - login: '', - chevron: '', - mail: '', - trash: '', - flag: '', - zap: '', - branch: '', - tag: '', - tags: '', - x: '', - split: '', - sparkles: '', - layers: '', - checksquare: '', - handshake: '', - arrowsLR: '', - belloff: '', - send: '', - bookmarkPlus: '', - braces: '', - pencilLine: '', - eye: '', - bold: '', - italic: '', - underline: '', - strike: '', - h2: '', - list: '', - listOrdered: '', - link2: '', - shuffle: '', - clipboardList: '', - shieldCheck: '', - arrowUp: '', - penLine: '', - check: '', - undo: '', - refresh: '', -}; - -const tabs = [ - { label: 'Overview', icon: 'barchart' }, - { label: 'Leads', icon: 'users' }, - { label: 'Steps', icon: 'listchecks', active: true }, - { label: 'Schedule', icon: 'calendar' }, - { label: 'Settings', icon: 'settings2' }, -]; - -// NodeTypeSwitcher items: Send email, Switch, AI step, then ADD_ACTION_OPTIONS. -const activeType = demo === 'switch' ? 'Switch (AI / value)' : demo === 'agent' ? 'AI step (agent)' : 'Send email'; -const stepTypes = [ - { label: 'Send email', icon: 'mail', tint: 'text-sky-600' }, - { label: 'Switch (AI / value)', icon: 'split', tint: 'text-purple-600' }, - { label: 'AI step (agent)', icon: 'sparkles', tint: 'text-purple-600' }, - { label: 'Add tag', icon: 'tag', tint: 'text-emerald-600' }, - { label: 'Remove tag', icon: 'tag', tint: 'text-amber-600' }, - { label: 'Add to segment', icon: 'layers', tint: 'text-emerald-600' }, - { label: 'Remove from segment', icon: 'layers', tint: 'text-amber-600' }, - { label: 'Label email', icon: 'tags', tint: 'text-fuchsia-600' }, - { label: 'Create task', icon: 'checksquare', tint: 'text-violet-600' }, - { label: 'Create deal', icon: 'handshake', tint: 'text-emerald-600' }, - { label: 'Move deal stage', icon: 'arrowsLR', tint: 'text-sky-600' }, - { label: 'Unsubscribe', icon: 'belloff', tint: 'text-rose-600' }, - { label: 'Run automation', icon: 'zap', tint: 'text-indigo-600' }, - { label: 'Fire event', icon: 'send', tint: 'text-sky-600' }, -].map((t) => ({ ...t, active: t.label === activeType })); - -// Toolbar of RichTextEditor, in order. -const tools = ['bold', 'italic', 'underline', 'strike', '|', 'h2', 'list', 'listOrdered', 'link2', '|']; - -// ── The canvas ─────────────────────────────────────────────────── -// Laid out as layoutFlow (web/src/components/app/flow/tree.ts) places it: -// 264x56 cards, 52px between chained steps, 92px under a split, 36px between -// columns (never narrower than the pill naming them). Waits sit beside the -// line into a step; a line's own + only shows on hover, so the mock draws the -// + at the end of each path only. -type Card = { id: string; x: number; y: number; icon: string; tone: string; kicker: string; title: string; summary: string; selected?: boolean }; -type Scene = { - w: number; - cards: Card[]; - ends: [number, number][]; - lines: string[]; - pills: { x: number; y: number; label: string; cls: string }[]; - notes: { x: number; y: number; label: string }[]; - adds: [number, number][]; -}; -const TONE = { - sky: 'bg-sky-50 text-sky-600 ring-sky-100', - violet: 'bg-violet-50 text-violet-600 ring-violet-100', - purple: 'bg-purple-50 text-purple-600 ring-purple-100', - emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100', -}; -const PILL = { - yes: 'bg-emerald-50 text-emerald-700 ring-emerald-200/80', - no: 'bg-white text-slate-500 ring-slate-200', - case: 'bg-violet-50 text-violet-700 ring-violet-200/80', -}; -const entry = (x: number): Card => ({ id: 'entry', x, y: 0, icon: 'login', tone: TONE.violet, kicker: 'Start', title: 'Contact enters the campaign', summary: 'First email right away' }); -const firstEmail = (x: number, selected = false): Card => ({ id: 'first', x, y: 108, icon: 'mail', tone: TONE.sky, kicker: 'First email', title: 'First email', summary: 'Quick question, {{.FirstName}}', selected }); -// A branch from (sx, sy) across the bus at busY down to (tx, ty). -const branch = (sx: number, sy: number, tx: number, ty: number) => { - const busY = sy + 20; - const r = Math.min(10, Math.abs(tx - sx) / 2); - const dir = tx >= sx ? 1 : -1; - return `M${sx},${sy} L${sx},${busY - r} Q${sx},${busY} ${sx + dir * r},${busY} L${tx - dir * r},${busY} Q${tx},${busY} ${tx},${busY + r} L${tx},${ty}`; -}; - -let scene: Scene; -if (demo === 'switch') { - // Columns: 264 + 264 + 96.8 (wrong person) + 77.6 (Otherwise), 36 apart. - const cx = 405.2; - scene = { - w: 810.4, - cards: [ - entry(cx - 132), - firstEmail(cx - 132), - { id: 'sw', x: cx - 132, y: 216, icon: 'split', tone: TONE.purple, kicker: 'Switch', title: 'Route the reply', summary: "Read this contact's reply and company details. Decide whether…", selected: true }, - { id: 'deal', x: 0, y: 364, icon: 'handshake', tone: TONE.emerald, kicker: 'Action', title: 'Open a deal', summary: 'Create a CRM deal' }, - { id: 'later', x: 300, y: 364, icon: 'mail', tone: TONE.sky, kicker: 'Email', title: 'Check back later', summary: 'Circling back next quarter' }, - ], - ends: [[648.4, 376]], - lines: [ - `M${cx},56 L${cx},108`, - `M${cx},164 L${cx},216`, - branch(cx, 272, 132, 364), - branch(cx, 272, 432, 364), - branch(cx, 272, 648.4, 364), - branch(cx, 272, 771.6, 364), - 'M132,420 L132,446', - 'M432,420 L432,446', - ], - pills: [ - { x: 132, y: 312, label: 'interested', cls: PILL.case }, - { x: 432, y: 312, label: 'not ready yet', cls: PILL.case }, - { x: 648.4, y: 312, label: 'wrong person', cls: PILL.case }, - { x: 771.6, y: 312, label: 'Otherwise', cls: PILL.no }, - ], - notes: [ - { x: cx, y: 190, label: '3 days' }, - { x: 432, y: 344, label: '90 days' }, - ], - adds: [[132, 457], [432, 457], [771.6, 375]], - }; -} else if (demo === 'agent') { - scene = { - w: 264, - cards: [ - entry(0), - firstEmail(0), - { id: 'ai', x: 0, y: 216, icon: 'sparkles', tone: TONE.purple, kicker: 'AI step', title: 'Triage the reply', summary: 'Agent · 3 actions', selected: true }, - ], - ends: [], - lines: ['M132,56 L132,108', 'M132,164 L132,216', 'M132,272 L132,298'], - pills: [], - notes: [{ x: 132, y: 190, label: '3 days' }], - adds: [[132, 309]], - }; -} else { - scene = { - w: 564, - cards: [ - entry(150), - firstEmail(150, demo === 'ai'), - { id: 'followup', x: 0, y: 256, icon: 'mail', tone: TONE.sky, kicker: 'Reply in thread', title: 'Follow-up', summary: 'Quick question, {{.FirstName}}' }, - { id: 'tag', x: 0, y: 364, icon: 'tag', tone: TONE.emerald, kicker: 'Action', title: 'Add engagement label', summary: 'Add a label' }, - { id: 'alt', x: 300, y: 256, icon: 'mail', tone: TONE.sky, kicker: 'Email', title: 'Alternative', summary: 'Trying a different angle' }, - ], - ends: [], - lines: ['M282,56 L282,108', branch(282, 164, 132, 256), branch(282, 164, 432, 256), 'M132,312 L132,364', 'M132,420 L132,446', 'M432,312 L432,338'], - pills: [ - { x: 132, y: 204, label: 'Opened the email within 3d · instant', cls: PILL.yes }, - { x: 432, y: 204, label: 'Otherwise', cls: PILL.no }, - ], - notes: [{ x: 432, y: 236, label: '2 days' }], - adds: [[132, 457], [432, 349]], - }; -} -// Opens at 100%, centred; an open panel covers the right 320px of the canvas. -const sceneLeft = `calc(${open ? '(100% - 320px) / 2' : '50%'} - ${scene.w / 2}px)`; - -const selected = scene.cards.find((c) => c.selected); -const panelTitle = selected ? selected.title : 'First email'; - -// What the assistant writes at the caret (ai demo). -const written = [ - 'Noticed Acme has been scaling fast on AWS. Teams your size are usually overpaying 30 to 40 percent on compute they spun up once and never tuned.', - "I help engineering teams find that spend without re-architecting anything. Worth a quick 15-minute call to walk through the three line items I'd check first?", - 'Open to next Tuesday or Wednesday afternoon?', -]; - -// AIStepFields: the allowlist, with what this step enables. -const agentActions = [ - { id: 'add_tag', label: 'Add tag', icon: 'tag', tint: 'text-emerald-600', on: true, pool: 'Tags the agent can add' }, - { id: 'remove_tag', label: 'Remove tag', icon: 'tag', tint: 'text-amber-600', on: false }, - { id: 'label_email', label: 'Label email', icon: 'tags', tint: 'text-fuchsia-600', on: false }, - { id: 'unsubscribe', label: 'Unsubscribe', icon: 'belloff', tint: 'text-rose-600', on: true }, - { id: 'create_task', label: 'Create task', icon: 'checksquare', tint: 'text-violet-600', on: true }, - { id: 'create_deal', label: 'Create deal', icon: 'handshake', tint: 'text-emerald-600', on: false }, - { id: 'move_deal_stage', label: 'Move deal stage', icon: 'arrowsLR', tint: 'text-sky-600', on: false }, -]; - -const rootClass = ['cb relative w-full bg-white text-slate-900 flex flex-col', open ? 'is-open' : ''].join(' '); ---- -
    - -
    -
    - - - Campaigns - -
    -

    Q1 Sales Outreach

    - draft -
    -

    cmp_8fK2pQ7rXa19

    -
    -
    - - -
    -
    - - -
    - {tabs.map((t) => ( -
    - - {t.label} - {t.active && } -
    - ))} -
    - - -
    - -
    - -
    -
    - Stop on reply - - - -
    -
    - - -
    - -
    - - 100% - - - -
    - - -
    - - {scene.pills.map((p) => ( - {p.label} - ))} - {scene.notes.map((n) => ( - - - {n.label} - - ))} - {scene.adds.map(([x, y]) => ( - - ))} - {scene.ends.map(([x, y]) => ( - - - End - - ))} - {scene.cards.map((c) => ( -
    -
    - -
    -
    {c.title}
    -
    {c.kicker} · {c.summary}
    -
    -
    -
    - ))} -
    -
    - - - -
    -
    - - - {demo === 'ai' && ( - <> - - - - -
    -
    -
    - - - Ask AI to write… - Ask for a 15 minute call about their AWS bill - - - - - -
    -
    -
    - - Continue writing - from 1 credit -
    -
    -
    -
    - - Writing… -
    -
    - - - Inserted - · 2 credits · 1.2k tok - - - Undo - - - Again - - Done -
    -
    - - )} - - {play && } - - -
    - - diff --git a/site/src/components/ChangelogShowcase.astro b/site/src/components/ChangelogShowcase.astro deleted file mode 100644 index b45beac74..000000000 --- a/site/src/components/ChangelogShowcase.astro +++ /dev/null @@ -1,159 +0,0 @@ ---- -import Icon from './Icon.astro'; -import { getChangelog, type ChangelogTag } from '../lib/changelog'; - -/** - * ChangelogShowcase - landing-page changelog. Copy and CTA on the left; on - * the right a plinth artifact holds the release feed as compact cards on a - * rail, newest emphasized, older entries dissolving off the bottom edge. - * Content is the same build-time data that powers /changelog/ (real merged - * pull requests), so the section never drifts from the actual history. - */ - -interface Release { - date: string; - category: 'New' | 'Improved' | 'Fixed'; - title: string; - url: string; - summary: string; -} - -const categoryByTag: Record = { - shipped: 'New', - changed: 'Improved', - fixed: 'Fixed', -}; - -// Five latest entries; the plinth crops the tail so the feed reads as ongoing. -const releases: Release[] = (await getChangelog()).slice(0, 5).map((e) => ({ - date: e.date, - category: categoryByTag[e.tag], - title: e.title, - url: e.url, - summary: e.bullets.length ? e.bullets[0] : e.body, -})); - -const styleFor = (c: Release['category']) => - c === 'New' ? { chip: 'bg-sky-50 text-sky-700 ring-sky-200', dot: 'bg-sky-500' } - : c === 'Improved' ? { chip: 'bg-emerald-50 text-emerald-700 ring-emerald-200', dot: 'bg-emerald-500' } - : { chip: 'bg-amber-50 text-amber-700 ring-amber-200', dot: 'bg-amber-500' }; ---- -
    -
    - - -
    -
    - Changelog - - - - Shipping continuously - -
    -

    - The latest from Warmbly. -

    -

    - New features, changes and fixes in the order they reached production. Every entry links to its pull request. -

    - - View full changelog - - -
    - - -
    -
    - - - - -
    - {releases.map((r, i) => { - const s = styleFor(r.category); - return ( - - ); - })} -
    -
    -
    -
    -
    - - - - - - diff --git a/site/src/components/Cloud.astro b/site/src/components/Cloud.astro deleted file mode 100644 index a92836364..000000000 --- a/site/src/components/Cloud.astro +++ /dev/null @@ -1,30 +0,0 @@ ---- -/** - * Realistic painterly cloud — pre-generated PNG via Perlin noise. - * - * Source: site/scripts/gen-clouds.mjs creates 5 webp variants in - * site/public/backdrops/cloud-{1..5}.webp. Regenerate with: - * - * node scripts/gen-clouds.mjs - * - * Browser-perfect (it's just an ), zero filter / displacement-map - * surface area, no Safari quirks. - */ -interface Props { - variant?: 1 | 2 | 3 | 4 | 5; - opacity?: number; - class?: string; - eager?: boolean; -} -const { variant = 1, opacity = 1, class: className = '', eager = false } = Astro.props; ---- - diff --git a/site/src/components/DashboardMock.astro b/site/src/components/DashboardMock.astro index 00eddb370..46936e9cb 100644 --- a/site/src/components/DashboardMock.astro +++ b/site/src/components/DashboardMock.astro @@ -4,60 +4,94 @@ * * Chrome (header, sidebar, LivePanel) lives in AppShellMock so the hero and * every feature window show the same dashboard. This file is only the - * content panel — web/src/app/app/emails/page.tsx. + * content panel, web/src/app/app/emails/page.tsx: PageTopbar, StatStrip, + * SectionBar and the mailbox table with its Status, Sent today, Warmup, + * Inbox and Health columns and the row actions that show on hover. */ import AppShellMock from './AppShellMock.astro'; +import { PROVIDER_MARKS } from '../lib/marks'; -// Warmup: current/target while warming (orange, with the dot-grid), else the -// state word. Health: healthTone() gives "Healthy N", "At risk N" (pulse) or -// "Issue N" (pulse). -type Row = { email: string; warm?: [number, number]; warmState?: 'Off' | 'Paused'; health: 'healthy' | 'risk' | 'issue'; score: number }; +type Status = 'active' | 'sending' | 'warming' | 'resting'; +type Row = { + email: string; name: string; provider: 'google' | 'microsoft' | 'imap'; + status: Status; sent?: number; warm: [number, number] | 'Paused' | 'Off'; + inbox?: number; collecting?: string; health: 'healthy' | 'risk' | 'issue'; score: number; + tag?: { label: string; color: string }; +}; +const providerName = { google: 'Google Workspace', microsoft: 'Microsoft 365', imap: 'SMTP / IMAP' } as const; +const sales = { label: 'Sales', color: '#6366f1' }; +const founders = { label: 'Founders', color: '#0ea5e9' }; const rows: Row[] = [ - { email: 'founder@acme.co', warm: [22, 40], health: 'healthy', score: 96 }, - { email: 'hello@acme.co', warm: [18, 40], health: 'healthy', score: 94 }, - { email: 'team@northwind.dev', warm: [34, 40], health: 'healthy', score: 97 }, - { email: 'ml@helio.gen', warm: [12, 40], health: 'risk', score: 71 }, - { email: 'sales@brightco.io', warm: [27, 40], health: 'healthy', score: 92 }, - { email: 'maya@sunriselabs.io', warm: [25, 40], health: 'healthy', score: 95 }, - { email: 'ops@coastline.io', warm: [10, 40], health: 'healthy', score: 88 }, - { email: 'growth@acme.co', warm: [31, 40], health: 'healthy', score: 96 }, - { email: 'jonas@helio.gen', warmState: 'Off', health: 'healthy', score: 90 }, - { email: 'partners@acme.co', warm: [14, 40], health: 'healthy', score: 93 }, - { email: 'ana@brightco.io', warm: [26, 40], health: 'healthy', score: 95 }, - { email: 'leo@northwind.dev', warm: [33, 40], health: 'healthy', score: 98 }, - { email: 'sofia@coastline.io', warmState: 'Paused', health: 'healthy', score: 91 }, - { email: 'dev@sunriselabs.io', warm: [8, 40], health: 'issue', score: 42 }, - { email: 'hello@brightco.io', warm: [29, 40], health: 'healthy', score: 94 }, + { email: 'founder@acme.co', name: 'Maya Okonkwo', provider: 'google', status: 'active', sent: 23, warm: [33, 40], inbox: 98, health: 'healthy', score: 97, tag: founders }, + { email: 'alex@northwind.dev', name: 'Alex Kim', provider: 'google', status: 'active', sent: 31, warm: [40, 40], inbox: 96, health: 'healthy', score: 96, tag: sales }, + { email: 'sam@northwind.io', name: 'Sam Ortiz', provider: 'microsoft', status: 'active', sent: 27, warm: [36, 40], inbox: 95, health: 'healthy', score: 93, tag: sales }, + { email: 'ops@northwind.co', name: 'Ops team', provider: 'imap', status: 'sending', sent: 12, warm: 'Off', inbox: 92, health: 'healthy', score: 88 }, + { email: 'mia@northwind.dev', name: 'Mia Chen', provider: 'google', status: 'warming', warm: [14, 40], inbox: 97, health: 'healthy', score: 91 }, + { email: 'hello@helio.gen', name: 'Lena Fischer', provider: 'microsoft', status: 'active', sent: 18, warm: [29, 40], inbox: 94, health: 'healthy', score: 92 }, + { email: 'jon@northwind.dev', name: 'Jon Reyes', provider: 'google', status: 'warming', warm: [11, 40], collecting: '7/20', health: 'healthy', score: 90 }, + { email: 'sales@brightco.io', name: 'Ana Silva', provider: 'google', status: 'active', sent: 34, warm: [40, 40], inbox: 96, health: 'healthy', score: 95, tag: sales }, + { email: 'ml@helio.gen', name: 'Kai Brandt', provider: 'microsoft', status: 'active', sent: 9, warm: [19, 40], inbox: 84, health: 'risk', score: 71 }, + { email: 'partners@acme.co', name: 'Ravi Shah', provider: 'google', status: 'resting', sent: 0, warm: [28, 40], inbox: 95, health: 'healthy', score: 94 }, + { email: 'leo@northwind.dev', name: 'Leo Park', provider: 'google', status: 'active', sent: 21, warm: [31, 40], inbox: 98, health: 'healthy', score: 98 }, + { email: 'team@coastline.io', name: 'Sofia Ruiz', provider: 'imap', status: 'warming', warm: 'Paused', inbox: 90, health: 'healthy', score: 89 }, + { email: 'dev@sunriselabs.io', name: 'Noah Grant', provider: 'microsoft', status: 'active', sent: 6, warm: [12, 40], inbox: 72, health: 'issue', score: 42 }, + { email: 'hello@brightco.io', name: 'Ana Silva', provider: 'google', status: 'sending', sent: 16, warm: [40, 40], inbox: 95, health: 'healthy', score: 94 }, + { email: 'growth@acme.co', name: 'Maya Okonkwo', provider: 'google', status: 'active', sent: 25, warm: [34, 40], inbox: 97, health: 'healthy', score: 96, tag: founders }, ]; -const healthTone = (h: Row['health'], score: number) => { - if (h === 'healthy') return { dot: 'bg-emerald-500', text: 'text-emerald-600', label: `Healthy ${score}`, pulse: false }; - if (h === 'risk') return { dot: 'bg-amber-500', text: 'text-amber-600', label: `At risk ${score}`, pulse: true }; - return { dot: 'bg-rose-500', text: 'text-rose-600', label: `Issue ${score}`, pulse: true }; +const icon = { + send: '', + flame: '', + moon: '', + pause: '', + mailCheck: '', + info: '', + server: '', + globe: '', + plus: '', + more: '', }; +const svg = (d: string, cls: string, size = 12) => + `${d}`; + +const statusTone: Record = { + active: { text: 'text-sky-700', icons: svg(icon.send, 'w-3 h-3 text-sky-600') + svg(icon.flame, 'w-3 h-3 text-orange-500'), label: 'Active', shimmer: true }, + sending: { text: 'text-sky-700', icons: svg(icon.send, 'w-3 h-3 text-sky-600'), label: 'Sending', shimmer: true }, + warming: { text: 'text-orange-600', icons: svg(icon.flame, 'w-3 h-3 text-orange-500'), label: 'Warming', shimmer: true }, + resting: { text: 'text-violet-600', icons: svg(icon.moon, 'w-3 h-3 text-violet-500'), label: 'Resting', shimmer: false }, +}; +const inboxTone = (r: number) => + r >= 90 ? { text: 'text-emerald-600', dot: 'bg-emerald-500' } : r >= 80 ? { text: 'text-amber-600', dot: 'bg-amber-500' } : { text: 'text-rose-600', dot: 'bg-rose-500' }; +const healthTone = (h: Row['health'], score: number) => { + if (h === 'healthy') return { dot: 'bg-emerald-500', text: 'text-emerald-700', label: `Healthy ${score}`, shimmer: false }; + if (h === 'risk') return { dot: 'bg-amber-500', text: 'text-amber-600', label: `At risk ${score}`, shimmer: true }; + return { dot: 'bg-rose-500', text: 'text-rose-600', label: `Issue ${score}`, shimmer: true }; +}; +const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] truncate'; +const infoTh = (label: string) => `${label} ${svg(icon.info, 'w-3 h-3')}`; +const checkbox = ''; +const action = 'size-6 flex items-center justify-center rounded hover:bg-slate-100 text-slate-400 transition-colors'; ---
    -
    +
    Accounts
    128 mailboxes -
    - +
    + +
    -
    +
    {[ - { label: 'Total', value: '128', sub: 'connected', accent: false, last: false }, - { label: 'Healthy', value: '98', sub: 'sending now', accent: true, last: false }, - { label: 'Warming', value: '21', sub: 'ramping up', accent: false, last: false }, + { label: 'Total', value: '128', sub: 'connected', accent: false, last: false }, + { label: 'Healthy', value: '98', sub: 'sending now', accent: true, last: false }, + { label: 'Warming', value: '21', sub: 'ramping up', accent: false, last: false }, { label: 'Needs attention', value: '9', sub: 'paused or failing', accent: false, last: true }, ].map((s) => (
    @@ -72,66 +106,122 @@ const healthTone = (h: Row['health'], score: number) => {
    -
    +
    Mailboxes 128 -
    -
    - +
    +
    + Search by email…
    - + +
    - - - + +
    + - - - - - + + + + + + + + {rows.map((r) => { - const t = healthTone(r.health, r.score); + const st = statusTone[r.status]; + const h = healthTone(r.health, r.score); + const ib = r.inbox ? inboxTone(r.inbox) : null; return ( - - - + + - {r.warm ? ( - - ) : ( - - )} - - + + {Array.isArray(r.warm) ? ( + + ) : r.warm === 'Paused' ? ( + + ) : ( + + )} + + + ); })} diff --git a/site/src/components/DeveloperMock.astro b/site/src/components/DeveloperMock.astro deleted file mode 100644 index 9e26c4938..000000000 --- a/site/src/components/DeveloperMock.astro +++ /dev/null @@ -1,371 +0,0 @@ ---- -import DemoCursor from './DemoCursor.astro'; -/** - * The /app/api-keys screen, mirrored from the app: - * - * - web/src/app/app/api-keys/page.tsx PageTopbar / StatStrip / Traffic (StackedBars) / - * Quick start (CodeSnippet) / Keys (KeyRow, StatusPill) - * - web/src/app/app/api-keys/_components/CreateKeyModal.tsx the Create API key modal (the reaction): Name, - * Description, Permissions (presets + matrix), - * Rate limit, Expires, the IP allowlist disclosure - * - web/src/components/layout/Page.tsx PageTopbar / StatStrip / Stat / SectionBar / TopbarAction - * - internal/models/api_permission.go the real scope names + descriptions + categories - * - * Demo: the cursor presses Create key, the modal opens, then a click on the - * scrim dismisses it. - */ - -const keys = [ - { name: 'production-server', desc: 'Server-side calls from EU production', prefix: 'wmbly_live_a1', suffix: '4f2a', rpm: 600, last: 'used 2m ago', status: 'active' as const }, - { name: 'ci-pipeline', desc: 'GitHub Actions deploy step', prefix: 'wmbly_live_7c', suffix: '91c7', rpm: 120, last: 'used 1h ago', status: 'active' as const }, - { name: 'zapier-replies', desc: 'Inbound reply automation', prefix: 'wmbly_live_3d', suffix: '3d8e', rpm: 300, last: 'never used', status: 'active' as const }, - { name: 'old-laptop', desc: 'Rotated out, kept for audit', prefix: 'wmbly_live_0p', suffix: 'xx0p', rpm: 120, last: 'used 23d ago', status: 'revoked' as const }, -]; - -const statusTone = (s: string) => - s === 'active' - ? { bg: 'bg-emerald-50', text: 'text-emerald-700', dot: 'bg-emerald-500' } - : s === 'revoked' - ? { bg: 'bg-rose-50', text: 'text-rose-700', dot: 'bg-rose-500' } - : { bg: 'bg-slate-100', text: 'text-slate-600', dot: 'bg-slate-400' }; - -const stats = [ - { label: 'Active keys', value: '3', sub: '1 inactive', accent: true, last: false }, - { label: 'Requests · 24h', value: '8,241', sub: '4 errors', accent: false, last: false }, - { label: 'Avg latency · 24h', value: '28ms', sub: 'across all keys', accent: false, last: false }, - { label: 'Last call', value: '14s ago', sub: 'Jun 6, 02:02 PM', accent: false, last: true }, -]; - -// 24 hourly buckets: 2xx / 4xx / 5xx shares of a 104px-tall bar. -const buckets = [42, 55, 38, 61, 49, 72, 58, 80, 64, 47, 69, 53, 76, 60, 84, 51, 68, 57, 73, 45, 62, 79, 54, 66]; - -const keyIcon = ''; -const gaugeIcon = ''; - -// PermissionMatrix groups, in api_permission.go order. -const perms = [ - { group: 'read', rows: [ - ['READ_EMAILS', 'View email accounts and settings'], - ['READ_CAMPAIGNS', 'View campaigns and sequences'], - ['READ_CONTACTS', 'View contact lists, segments, notes, and activities'], - ['READ_UNIBOX', 'View emails in the unified inbox'], - ['READ_ANALYTICS', 'View analytics and reports'], - ], on: true }, - { group: 'write', rows: [ - ['WRITE_EMAILS', 'Modify email account settings'], - ['WRITE_CAMPAIGNS', 'Create and modify campaigns and sequences'], - ['WRITE_CONTACTS', 'Create and modify contacts, segments, notes, and activities'], - ], on: false }, -]; ---- -
    - - -
    - API keys -
    - Programmatic access · stripe-style scoped tokens -
    - -
    - - Search… -
    - - - - - - -
    -
    - - -
    - {stats.map((s) => ( -
    -
    - {s.label} - {s.accent && } -
    -
    {s.value}
    -
    {s.sub}
    -
    - ))} -
    - - -
    - Traffic · last 24h - 8,241 -
    - - -
    -
    -
    - {buckets.map((h, i) => ( -
    - {i % 7 === 3 &&
    } - {i % 5 === 2 &&
    } -
    -
    - ))} -
    -
    -
    - Jun 5, 03:00 PM - Jun 6, 02:00 PM -
    -
    -
    - 2xx - 4xx - 5xx - - - live; refreshes every minute - -
    -
    - - -
    - Quick start -
    -
    -
    -
    - - - - curl - - - Copy - -
    -
    curl https://api.warmbly.com/v1/campaigns \
    -  -H "Authorization: Bearer wmbly_live_a1……" \
    -  -H "Content-Type: application/json"
    -
    -

    - Pass the secret as a Bearer header. Every request returns - rate-limit headers (X-RateLimit-Limit, - X-RateLimit-Remaining) so well-behaved clients can self-throttle - before hitting 429. -

    -
    - - -
    - Keys - 4 -
    - - -
    -
    - {keys.map((k) => { - const t = statusTone(k.status); - return ( -
    - -
    - {k.name} - {k.desc} -
    - {k.prefix}…{k.suffix} - - - {k.status} - - - - - -
    - ); - })} -
    -
    - - - - - - - - -
    - - diff --git a/site/src/components/Eyebrow.astro b/site/src/components/Eyebrow.astro deleted file mode 100644 index 34ac74110..000000000 --- a/site/src/components/Eyebrow.astro +++ /dev/null @@ -1,7 +0,0 @@ ---- -interface Props { class?: string } -const { class: className = '' } = Astro.props; ---- - - - diff --git a/site/src/components/FeatureGrid.astro b/site/src/components/FeatureGrid.astro new file mode 100644 index 000000000..7392c5205 --- /dev/null +++ b/site/src/components/FeatureGrid.astro @@ -0,0 +1,54 @@ +--- +import InboxScene from './scenes/InboxScene.astro'; +import WriterScene from './scenes/WriterScene.astro'; +import DealsScene from './scenes/DealsScene.astro'; +import AnalyticsScene from './scenes/AnalyticsScene.astro'; +import InstallScene from './scenes/InstallScene.astro'; + +/** + * Landing page bento: what the product does, two wide cards then three, + * each a live dashboard scene with a plain title and one line underneath. + */ +const title = 'mt-6 text-[21px] md:text-[23px] tracking-[-0.02em] text-slate-900'; +const body = 'mt-2 max-w-xl text-[16px] md:text-[17px] leading-[1.55] text-slate-500'; +--- +
    +

    + Everything you need to run cold email. +

    +

    + Connect your mailboxes, warm them up, send campaigns and handle the replies, all from one dashboard. +

    + +
    +
    + +

    One inbox for every reply

    +

    Replies from all your mailboxes arrive in one place, already labeled as interested, out of office, a question or an unsubscribe.

    +
    + +
    + +

    Emails that read as personal

    +

    Write each step with variables like first name and company, test two versions against each other, and draft or rewrite with AI.

    +
    + +
    + +

    A pipeline for your deals

    +

    Move interested leads from reply to meeting to closed on a simple board.

    +
    + +
    + +

    Campaign analytics

    +

    Sends, replies and bounces for every campaign and every mailbox.

    +
    + +
    + +

    Open source

    +

    Use Warmbly Cloud, or install it on your own server with one command.

    +
    +
    +
    diff --git a/site/src/components/FeatureSection.astro b/site/src/components/FeatureSection.astro deleted file mode 100644 index 5bc02686d..000000000 --- a/site/src/components/FeatureSection.astro +++ /dev/null @@ -1,473 +0,0 @@ ---- -import Icon from './Icon.astro'; -import AppShellMock from './AppShellMock.astro'; -import GrainBackdrop from './GrainBackdrop.astro'; -import type { GrainTone } from '../lib/grain'; - -/** - * One feature showcase section: copy on one side, the product on the other. - * - * The visual is a rounded grain-gradient frame with a whole browser window - * inside it, chrome and all, authored at 1280x944. The window is scaled UP - * past the frame and hangs from one of its own real corners (`anchor`), so - * the corner on screen is a real window corner and the two far edges run - * off the frame, clipped by the frame's rounded corners. It is never panned - * into: a visible chrome is a whole chrome, and a section that wants the - * bottom of a screen anchors the window's bottom corner. - * - * `zoom` is how many of the window's 1280 design px map across the frame: - * smaller is closer. The window always at least covers the frame, so a short - * window never leaves grain showing past a cut edge. - * - * `slides` turns the window into a story-style carousel: the default slot is - * slide one and `slide2`, `slide3`... are the rest, each its own whole - * window. Segmented bars at the bottom fill one at a time and the next screen - * slides in when a bar completes; hovering holds it, swiping or the arrows - * move it by hand. - * - * The frame takes its height from the copy beside it (the grid row - * stretches). Below lg the copy sits above and the frame keeps a desktop - * frame's proportions, with the very same window, chrome and crop inside it, - * just smaller: a phone sees what a desktop sees. - */ -interface Callout { label: string; text: string } -type Anchor = 'tl' | 'tr' | 'bl' | 'br'; -interface Slide { - url: string; - /** Sidebar row + breadcrumb leaf; omit to reuse the section's. */ - nav?: string; - crumb?: string; - /** false renders the slot under the chrome alone, no app shell. */ - shell?: boolean; -} -interface Props { - eyebrow: string; - title: string; - description: string; - callouts?: Callout[]; - cta?: { text: string; href: string }; - side?: 'left' | 'right'; - bg?: 'sky' | 'light' | 'navy' | 'wallpaper'; - url: string; - cursor?: 'a' | 'b' | 'c' | 'd'; - id?: string; - /** Sidebar row to highlight + breadcrumb leaf inside the app shell. */ - nav: string; - /** Extra breadcrumb segment after the section, e.g. "Q1 sales". */ - crumb?: string; - /** Which real corner of the window sits in the frame. */ - anchor?: Anchor; - /** Window design px shown across the frame width at lg+. */ - zoom?: number; - /** Extra slides after the default one, in order; content in slide2, slide3... */ - slides?: Slide[]; - /** - * The whole window, as large as the row allows: the visual takes 8 columns - * and bleeds to the viewport edge, and the frame's height follows the - * window's aspect instead of the copy beside it. For screens whose demo is - * a window-centred dialog or a full-height drawer. - */ - wide?: boolean; -} -const { - eyebrow, title, description, callouts = [], cta, - side = 'right', bg = 'sky', url, id, nav, crumb, - anchor = 'tl', zoom = 1000, slides = [], wide = false, -} = Astro.props; - -const allSlides: Slide[] = [{ url, nav, crumb, shell: true }, ...slides]; -// Named slots rendered up front, so the template can loop over them. -const slideHtml: string[] = [await Astro.slots.render('default')]; -for (let i = 0; i < slides.length; i++) slideHtml.push(await Astro.slots.render(`slide${i + 2}`)); -const isCarousel = allSlides.length > 1; - -const lightText = bg !== 'light'; - -// The frame has to read AGAINST its band. Dark bands take the light tone, the -// bright sky band takes the deep one, and the whiteish band keeps the mid blue. -const grainToneByBg: Record = { - sky: 'deep', light: 'mid', navy: 'light', wallpaper: 'light', -}; -const grainTone = grainToneByBg[bg]; - -const bgStyle: Record = { - sky: 'background: radial-gradient(ellipse 130% 120% at 50% -10%, #38bdf8 0%, #0ea5e9 34%, #0284c7 68%, #0369a1 100%);', - light: 'background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);', - navy: 'background: radial-gradient(ellipse 110% 90% at 50% -10%, #143a63 0%, #0e2545 38%, #0a1730 68%, #070d1c 100%);', - wallpaper: 'background: radial-gradient(78% 95% at 15% 6%, #38bdf8 0%, #1d4ed8 42%, rgba(29,78,216,0) 72%), linear-gradient(120deg, #1e40af 0%, #1d4ed8 48%, #172554 100%);', -}; - -// One radius for every frame on the page, and one for every window corner. -const RADIUS = 28; -const frameShadow = - bg === 'navy' - ? '0 54px 110px -34px rgba(2,132,199,0.6), 0 10px 28px -14px rgba(0,0,0,0.5)' - : bg === 'light' - ? '0 40px 80px -34px rgba(15,23,42,0.45)' - : '0 50px 100px -34px rgba(2,10,32,0.6)'; -// Inline, so no stylesheet (ours, Tailwind's, or the shader library's own -// runtime layer) can take the corners back. -const shellStyle = `border-radius:${RADIUS}px; box-shadow:${frameShadow};`; -const frameStyle = `border-radius:${RADIUS}px; overflow:hidden; clip-path: inset(0 round ${RADIUS}px);`; - -// Where the window's box sits in the frame: inset on the two sides that meet -// at the anchored corner (16px below lg, 28px at lg+), flush on the two it -// bleeds off. -const fitPos: Record = { - tl: 'left-4 top-4 right-0 bottom-0 lg:left-7 lg:top-7', - tr: 'right-4 top-4 left-0 bottom-0 lg:right-7 lg:top-7', - bl: 'left-4 bottom-4 right-0 top-0 lg:left-7 lg:bottom-7', - br: 'right-4 bottom-4 left-0 top-0 lg:right-7 lg:bottom-7', -}; -// Only the real window corner is rounded; the edges that meet the frame stay -// straight, the way a cropped screenshot does. -const fitCorner: Record = { - tl: 'rounded-tl-[12px]', - tr: 'rounded-tr-[12px]', - bl: 'rounded-bl-[12px]', - br: 'rounded-br-[12px]', -}; -const fitShadow: Record = { - tl: 'shadow-[-8px_-8px_28px_-10px_rgba(2,10,32,0.55)]', - tr: 'shadow-[8px_-8px_28px_-10px_rgba(2,10,32,0.55)]', - bl: 'shadow-[-8px_8px_28px_-10px_rgba(2,10,32,0.55)]', - br: 'shadow-[8px_8px_28px_-10px_rgba(2,10,32,0.55)]', -}; -// The screen pins to the box corner the anchor names and scales away from it. -const screenPos: Record = { - tl: 'top-0 left-0 origin-top-left', - tr: 'top-0 right-0 origin-top-right', - bl: 'bottom-0 left-0 origin-bottom-left', - br: 'bottom-0 right-0 origin-bottom-right', -}; - -const eyebrowColor = lightText ? 'text-white/60' : 'text-sky-600'; -const titleColor = lightText ? 'text-white' : 'text-slate-900'; -const bodyColor = lightText ? 'text-white/75' : 'text-slate-600'; -const calloutLabel = lightText ? 'text-white' : 'text-slate-900'; -const calloutText = lightText ? 'text-white/65' : 'text-slate-500'; - -function splitUrl(u: string) { - const i = u.indexOf('/'); - return i === -1 ? { domain: u, path: '' } : { domain: u.slice(0, i), path: u.slice(i) }; -} - -const copyOrder = side === 'left' ? 'lg:order-2' : 'lg:order-1'; -const mockOrder = side === 'left' ? 'lg:order-1' : 'lg:order-2'; -const copyCol = wide ? 'lg:col-span-4' : 'lg:col-span-5'; -// Wide visuals run out through the container's 40px padding on their outer side. -const mockCol = wide ? `lg:col-span-8 ${side === 'left' ? 'lg:-ml-10' : 'lg:-mr-10'}` : 'lg:col-span-7'; ---- -
    - {bg === 'sky' && ( - <> -
    - - - )} - {bg === 'wallpaper' && ( -
    - )} - {bg === 'navy' && ( -
    - )} - -
    -
    - -
    -
    {eyebrow}
    -

    {title}

    -

    {description}

    - - {callouts.length > 0 && ( -
    - {callouts.map((c) => ( -
    - -
    - {c.label}. - {c.text} -
    -
    - ))} -
    - )} - - {cta && ( - - {cta.text} - - - )} -
    - - -
    -
    - -
    -
    - - -
    -
    - - {allSlides.map((s, i) => { - const { domain, path } = splitUrl(s.url); - return ( -
    - -
    - ); - })} -
    -
    - - {isCarousel && ( - <> - - - -
    - {allSlides.map((_, i) => ( - - ))} -
    - - )} -
    -
    -
    -
    -
    -
    -
    - - - - diff --git a/site/src/components/FormBuilderMock.astro b/site/src/components/FormBuilderMock.astro deleted file mode 100644 index e2dc70ca6..000000000 --- a/site/src/components/FormBuilderMock.astro +++ /dev/null @@ -1,142 +0,0 @@ ---- -/** - * The form builder (/app/forms/:id, Build tab) as FormBuilder.tsx draws it: - * h-14 header with the name field and status, the six-tab bar, then the - * palette (w-52), the canvas with the live card, and the field settings - * panel (w-80) for the field that is selected on the canvas. - */ -import FormCardMock from './FormCardMock.astro'; - -const paletteFields = [ - { label: 'Text', icon: '' }, - { label: 'Email', icon: '' }, - { label: 'Phone', icon: '' }, - { label: 'Long text', icon: '' }, - { label: 'Number', icon: '' }, - { label: 'Dropdown', icon: '' }, - { label: 'Radio buttons', icon: '' }, - { label: 'Checkbox group', icon: '' }, - { label: 'Single checkbox', icon: '' }, - { label: 'Date', icon: '' }, - { label: 'Hidden field', icon: '' }, -]; -const paletteLayout = [ - { label: 'Heading', icon: '' }, - { label: 'Text block', icon: '' }, - { label: 'Divider', icon: '' }, - { label: 'Page break', icon: '' }, -]; - -const tabs = [ - { label: 'Build', on: true, icon: '' }, - { label: 'Design', icon: '' }, - { label: 'Settings', icon: '' }, - { label: 'Share', icon: '' }, - { label: 'Analytics', icon: '' }, - { label: 'Submissions', badge: '1,041', icon: '' }, -]; - -// TextInput and Label from ui/field.tsx: a bare input keeps the browser's default width. -const field = 'h-7 w-[178px] px-2.5 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 flex items-center min-w-0'; -const label = 'text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium block mb-1.5'; ---- -
    - -
    - - - Forms - -
    -
    Book a demo
    - published -
    - - Unpublish -
    - - -
    - {tabs.map((t) => ( - - - {t.label} - {t.badge && {t.badge}} - {t.on && } - - ))} -
    - - -
    - - - -
    - -
    - - - -
    -
    diff --git a/site/src/components/FormCardMock.astro b/site/src/components/FormCardMock.astro deleted file mode 100644 index 35785d312..000000000 --- a/site/src/components/FormCardMock.astro +++ /dev/null @@ -1,117 +0,0 @@ ---- -/** - * The "Book a demo" form as the hosted renderer draws a split layout - * (forms/src/FormPage.tsx + FormRenderer.tsx + form-theme.css, design - * defaults from designCore.ts): the cover column with the logo, title and - * subtitle over the cover image and its accent wash, then `.wf-main` with - * the field grid, the submit row and the "Powered by Warmbly" line. - * - * The same component sits on the builder canvas, where `selected` marks a - * field the way SortableField does: the sky ring and the drag / duplicate / - * delete toolbar. - */ -interface Props { selected?: string } -const { selected } = Astro.props; - -type Field = - | { id: string; kind: 'heading'; text: string } - | { id: string; kind: 'paragraph'; text: string } - | { id: string; kind: 'input'; label: string; placeholder: string; half?: boolean; required?: boolean; value?: string; focus?: boolean } - | { id: string; kind: 'select'; label: string; value: string; half?: boolean } - | { id: string; kind: 'textarea'; label: string; placeholder: string; help?: string } - | { id: string; kind: 'checkbox'; text: string }; - -const fields: Field[] = [ - { id: 'h', kind: 'heading', text: 'Tell us about your outreach' }, - { id: 'p', kind: 'paragraph', text: 'We reply within one business day.' }, - { id: 'fn', kind: 'input', label: 'First name', placeholder: 'Maya', half: true, required: true, value: 'Maya' }, - { id: 'ln', kind: 'input', label: 'Last name', placeholder: 'Okonkwo', half: true, required: true, value: 'Okonkwo' }, - { id: 'em', kind: 'input', label: 'Work email', placeholder: 'you@company.com', required: true, value: 'maya@northwind.dev', focus: true }, - { id: 'co', kind: 'input', label: 'Company', placeholder: 'Northwind', half: true, value: 'Northwind' }, - { id: 'sz', kind: 'select', label: 'Team size', value: '11 to 50', half: true }, - { id: 'msg', kind: 'textarea', label: 'What are you hoping to solve?', placeholder: 'Deliverability, volume, replies, CRM handoff...', help: 'Optional, but it makes the call better.' }, - { id: 'ok', kind: 'checkbox', text: 'Send me the occasional deliverability note. Unsubscribe any time.' }, -]; - -// FONT_CATALOG.inter.stack and the resolveDesign defaults. -const font = "font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;"; -const ACCENT = '#0284c7'; -// The owner's cover image (a photo in the app) under the accent wash. -const coverImage = 'linear-gradient(160deg, #0b2a4a 0%, #0f3d6e 55%, #0c2f57 100%)'; - -const inputStyle = `width:100%; font-size:14px; color:#0f172a; background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; ${font}`; -const focusStyle = `border-color:${ACCENT}; box-shadow:0 0 0 3px color-mix(in srgb, ${ACCENT} 18%, transparent);`; ---- -
    - - - - -
    -
    - {fields.map((f) => { - const half = 'half' in f && f.half; - const isSel = selected === f.id; - return ( -
    - {isSel && ( -
    - - - -
    - )} - {f.kind === 'heading' &&

    {f.text}

    } - {f.kind === 'paragraph' &&

    {f.text}

    } - {f.kind === 'input' && ( -
    - -
    {f.value ? f.value : {f.placeholder}}
    -
    - )} - {f.kind === 'select' && ( -
    - -
    - {f.value} - -
    -
    - )} - {f.kind === 'textarea' && ( -
    - -
    {f.placeholder}
    - {f.help &&
    {f.help}
    } -
    - )} - {f.kind === 'checkbox' && ( - - )} -
    - ); - })} - -
    - Book my demo -
    -
    - -
    Powered by Warmbly
    -
    -
    diff --git a/site/src/components/FormsMock.astro b/site/src/components/FormsMock.astro deleted file mode 100644 index 6a00d65a1..000000000 --- a/site/src/components/FormsMock.astro +++ /dev/null @@ -1,242 +0,0 @@ ---- -/** - * The /app/forms list, as web/src/app/app/forms/page.tsx draws it: topbar, - * five-stat strip, the section bar with status tabs + category filter + - * search, then the leads-style table with a trend sparkline per form. - * Rendered inside AppShellMock by FeatureSection. - */ -import { nextId } from '../lib/uid'; - -type Status = 'published' | 'draft' | 'archived'; -interface Row { - name: string; - status: Status; - cats: { title: string; color: string }[]; - trend: number[]; - views: number; - starts: number; - subs: number; - identified: number; - created: string; - last: string; -} - -const cat = { - inbound: { title: 'Inbound', color: '#0284c7' }, - enterprise: { title: 'Enterprise', color: '#7c3aed' }, - newsletter: { title: 'Newsletter', color: '#059669' }, - webinar: { title: 'Webinar', color: '#d97706' }, - partners: { title: 'Partners', color: '#db2777' }, -}; - -// Sorted the way the page opens: newest created first. -const rows: Row[] = [ - { name: 'Pricing questions', status: 'published', cats: [cat.inbound], - trend: [2, 5, 9, 14, 18, 24, 31, 27, 36, 41, 39, 44, 52, 58], - views: 1120, starts: 481, subs: 119, identified: 97, created: '6d ago', last: '3h ago' }, - { name: 'Partner program', status: 'draft', cats: [cat.partners], - trend: [], views: 0, starts: 0, subs: 0, identified: 0, created: '9d ago', last: 'never' }, - { name: 'Webinar: Cold email in 2026', status: 'published', cats: [cat.webinar, cat.inbound], - trend: [4, 12, 19, 33, 28, 41, 56, 72, 64, 83, 91, 74, 68, 77], - views: 1976, starts: 1103, subs: 356, identified: 302, created: '8/11/2026', last: '1h ago' }, - { name: 'Book a demo', status: 'published', cats: [cat.enterprise, cat.inbound], - trend: [38, 42, 35, 51, 47, 63, 58, 71, 66, 74, 69, 82, 88, 94], - views: 4812, starts: 2306, subs: 1041, identified: 388, created: '7/2/2026', last: '4m ago' }, - { name: 'Newsletter signup', status: 'published', cats: [cat.newsletter], - trend: [22, 26, 24, 31, 29, 35, 33, 38, 41, 37, 44, 46, 43, 49], - views: 3904, starts: 1822, subs: 762, identified: 141, created: '6/5/2026', last: '22m ago' }, - { name: 'Q1 campaign feedback', status: 'archived', cats: [cat.newsletter], - trend: [14, 11, 9, 8, 6, 5, 4, 3, 2, 2, 1, 1, 0, 0], - views: 668, starts: 220, subs: 40, identified: 40, created: '5/14/2026', last: '7/9/2026' }, -]; - -const pill: Record = { - draft: 'bg-slate-100 text-slate-600', - published: 'bg-emerald-50 text-emerald-700', - archived: 'bg-amber-50 text-amber-700', -}; - -const n = (v: number) => v.toLocaleString('en-US'); -const conv = (r: Row) => (r.views <= 0 ? '–' : `${Math.min(100, (r.subs / r.views) * 100).toFixed(1)}%`); - -const totals = { - forms: rows.length, - live: rows.filter((r) => r.status === 'published').length, - views: rows.reduce((a, r) => a + r.views, 0), - starts: rows.reduce((a, r) => a + r.starts, 0), - subs: rows.reduce((a, r) => a + r.subs, 0), -}; -const totalConv = `${Math.min(100, (totals.subs / totals.views) * 100).toFixed(1)}%`; -const counts = { - all: rows.length, - published: totals.live, - draft: rows.filter((r) => r.status === 'draft').length, - archived: rows.filter((r) => r.status === 'archived').length, -}; - -// Same maths as ui/charts Sparkline at 84x22: 2px pad, straight segments, -// area fill fading to nothing. -const SW = 84, SH = 22, PAD = 2; -function spark(values: number[]) { - const max = Math.max(1, ...values); - const w = SW - PAD * 2, h = SH - PAD * 2; - const step = values.length > 1 ? w / (values.length - 1) : 0; - const pts = values.map((v, i) => [PAD + i * step, PAD + h - (v / max) * h] as const); - const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' '); - const area = `${line} L ${(PAD + (values.length - 1) * step).toFixed(2)} ${PAD + h} L ${PAD} ${PAD + h} Z`; - return { line, area }; -} -const sparkId = nextId('fspark'); - -const th = 'px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em]'; -const num = 'px-3 text-right font-mono text-[12px] tabular-nums whitespace-nowrap'; ---- -
    - -
    - Forms -
    - Hosted lead-capture forms you can embed anywhere -
    - -
    -
    - - -
    - {[ - { label: 'Forms', value: String(totals.forms), sub: `${totals.live} live`, accent: false, last: false }, - { label: 'Views', value: n(totals.views), sub: 'all time', accent: false, last: false }, - { label: 'Starts', value: n(totals.starts), sub: 'began filling', accent: false, last: false }, - { label: 'Submissions', value: n(totals.subs), sub: 'all time', accent: true, last: false }, - { label: 'Conversion', value: totalConv, sub: 'submissions / views', accent: false, last: true }, - ].map((s) => ( -
    -
    - {s.label} - {s.accent && } -
    -
    {s.value}
    -
    {s.sub}
    -
    - ))} -
    - - -
    - All forms - {rows.length} -
    -
    - {[ - { label: 'All', count: counts.all, on: true }, - { label: 'Published', count: counts.published, on: false }, - { label: 'Drafts', count: counts.draft, on: false }, - { label: 'Archived', count: counts.archived, on: false }, - ].map((t) => ( - - {t.label}{t.count} - - ))} -
    -
    - All categories - -
    -
    - - Search forms… -
    -
    -
    - - -
    -
    AccountWarmupMailboxStatus
    -
    -
    -
    - {r.email.slice(0, 2).toUpperCase()} +
    +
    + + {r.provider === 'imap' + ? + : } + +
    +
    + {r.email} + {r.tag && ( + + {r.tag.label} + + )} +
    +
    + {r.name} + · + {providerName[r.provider]}
    - {r.email}
    - {r.warm[0]}/{r.warm[1]} - {r.warmState} - - - {t.pulse && } - - - + + + + {st.label} + {r.sent == null + ? — + : ( + 0 ? 'text-sky-700' : 'text-slate-500'}`}> + + {r.sent}/50 + + )} + + {r.warm[0]}/{r.warm[1]} + + + + + + {ib ? ( + + + {r.inbox}% + + ) : ( + + + {r.collecting} + + )} + + + + {h.label} + + +
    + + +
    +
    - - - - - - - - - - - - - - - - - {rows.map((r, i) => { - const s = r.trend.some((v) => v > 0) ? spark(r.trend) : null; - const gid = `${sparkId}-${i}`; - return ( - - - - - - - - - - - - - - ); - })} - -
    NameViewsSubs
    -
    -
    - -
    -
    -
    - {r.name} - {r.status} -
    -
    Last submission {r.last}
    -
    -
    -
    {n(r.views)}{n(r.subs)} - - - -
    -
    -
    diff --git a/site/src/components/GrainBackdrop.astro b/site/src/components/GrainBackdrop.astro deleted file mode 100644 index eeb122508..000000000 --- a/site/src/components/GrainBackdrop.astro +++ /dev/null @@ -1,153 +0,0 @@ ---- -/** - * Full-bleed grain gradient, from Paper Shaders (paper.design). - * - * The layer drifts slowly; the library already pauses its rAF when the tab is - * hidden or the element is off screen, and reduced-motion pins it to a single - * static render. The canvas only exists while its frame is near the viewport: - * it is released once scrolled past, so a long page never holds more than a - * few GL contexts (iOS Safari kills the tab otherwise). The CSS gradient - * underneath is the same palette, so a machine without WebGL (or one that - * never runs the script) gets the right frame instead of a hole. - * - * The canvas inherits the frame's radius: WebGL paints a rectangle, so without - * this the grain squares off the corners its frame just rounded. - */ -import { GRAIN_TONES, type GrainTone } from '../lib/grain'; - -interface Props { - tone: GrainTone; - class?: string; -} -const { tone, class: className = '' } = Astro.props; -const t = GRAIN_TONES[tone]; ---- - - - diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 44f23e582..e95ec6e77 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -1,6 +1,9 @@ --- import Wordmark from './Wordmark.astro'; import Icon from './Icon.astro'; +import Photo from './Photo.astro'; +import { GITHUB_PATH } from '../lib/marks'; +import { getRepoStars, REPO_URL } from '../lib/github'; interface Props { /** Render as transparent over the sky hero, with light text. Becomes solid once scrolled past the hero. */ @@ -9,46 +12,51 @@ interface Props { const { onSky = false } = Astro.props; const path = Astro.url.pathname; +const stars = await getRepoStars(); -// Featured product: rendered as a full-width row above the grid, with a -// miniature trigger -> if -> action flow as its visual. -const productFeatured = { - icon: 'zap', +// Featured tile on the right of each dropdown, over a photo. +type Featured = { href: string; title: string; desc: string; photo: string; cta: string }; + +const productFeatured: Featured = { href: '/automations/', title: 'Automations', - desc: 'When a reply lands: open the deal, ping Slack, update the CRM. A visual builder for every Warmbly event.', - tag: 'Orchestration', + desc: 'When a reply lands, open the deal, post to Slack or update your CRM.', + photo: 'paint-teal', + cta: 'Explore automations', +}; +const useCaseFeatured: Featured = { + href: 'https://docs.warmbly.com/learn/deliverability/', + title: 'The deliverability handbook', + desc: 'How warmup, inbox placement and sender reputation fit together.', + photo: 'sheer-peach', + cta: 'Read the handbook', }; -// Product mega-menu items (6 meaningful products) const products = [ - { icon: 'flame', href: '/warmup/', title: 'Warmup', desc: 'Reputation-safe pools and gradual ramps that earn placement, not just inflate it.', tag: 'Deliverability' }, - { icon: 'webhook', href: '/developers/', title: 'API & webhooks', desc: 'REST + HMAC-signed webhooks. Idempotency keys. Per-key scopes.', tag: 'Developers' }, - { icon: 'inbox', href: '/inbox/', title: 'Unified inbox', desc: 'Every reply, bounce, OOO and click across every connected mailbox, classified.', tag: 'Triage' }, - { icon: 'workflow', href: '/campaigns/', title: 'Campaigns', desc: 'Multi-step sequences with branching, A/B variants and business-hour scheduling.', tag: 'Outreach' }, - { icon: 'chart', href: '/analytics/', title: 'Analytics', desc: 'Placement, complaints, bounces and warmup health, by mailbox and by worker.', tag: 'Reporting' }, - { icon: 'contact', href: '/crm/', title: 'CRM', desc: 'Pipelines, deals, tasks and a contacts table built around cold-outbound workflow.', tag: 'Workflow' }, + { icon: 'flame', href: '/warmup/', title: 'Warmup', desc: 'Build sender reputation before you send.' }, + { icon: 'workflow', href: '/campaigns/', title: 'Campaigns', desc: 'Sequences with follow-ups and A/B tests.' }, + { icon: 'inbox', href: '/inbox/', title: 'Unified inbox', desc: 'Every reply from every mailbox in one place.' }, + { icon: 'chart', href: '/analytics/', title: 'Analytics', desc: 'Sends, replies, bounces and inbox placement.' }, + { icon: 'contact', href: '/crm/', title: 'CRM', desc: 'Turn interested replies into deals.' }, + { icon: 'webhook', href: '/developers/', title: 'API & webhooks', desc: 'Build on Warmbly with REST and webhooks.' }, ]; -// Use cases mega-menu items const useCases = [ - { icon: 'rocket', href: '/use-cases/founders/', title: 'Founders', desc: 'Run intro sequences without burning founder@.', tag: 'Solo + early' }, - { icon: 'users', href: '/use-cases/agencies/', title: 'Agencies', desc: 'Outbound for many clients on many domains.', tag: 'Multi-tenant' }, - { icon: 'chart', href: '/use-cases/sales/', title: 'Sales teams', desc: 'Distribute sends across reps, surface positives.', tag: 'Growth' }, - { icon: 'contact', href: '/use-cases/recruiters/', title: 'Recruiters', desc: 'Candidate outreach from personal mailboxes.', tag: 'Talent' }, - { icon: 'star', href: '/use-cases/fundraising/', title: 'Fundraising', desc: 'A focused VC sequence that lands in the inbox.', tag: 'Founders' }, + { icon: 'rocket', href: '/use-cases/founders/', title: 'Founders', desc: 'Outreach without risking your own domain.' }, + { icon: 'users', href: '/use-cases/agencies/', title: 'Agencies', desc: 'Many clients and domains in one account.' }, + { icon: 'chart', href: '/use-cases/sales/', title: 'Sales teams', desc: 'Spread sending across reps and mailboxes.' }, + { icon: 'contact', href: '/use-cases/recruiters/', title: 'Recruiters', desc: 'Candidate outreach from personal mailboxes.' }, + { icon: 'star', href: '/use-cases/fundraising/', title: 'Fundraising', desc: 'A focused investor sequence that lands.' }, ]; -type MegaItem = { icon: string; href: string; title: string; desc: string; tag: string }; +type MegaItem = { icon: string; href: string; title: string; desc: string }; type NavItem = { label: string; href: string; match?: string; mega?: boolean; - megaTitle?: string; - megaTagline?: string; megaItems?: MegaItem[]; - megaFeatured?: MegaItem; + megaFeatured?: Featured; }; const nav: NavItem[] = [ @@ -57,8 +65,6 @@ const nav: NavItem[] = [ href: '/warmup/', match: '/warmup|/developers|/inbox|/campaigns|/analytics|/crm|/automations', mega: true, - megaTitle: 'The cold-email platform.', - megaTagline: 'Seven systems designed to work together. Warmup that earns reputation, sending that respects it.', megaItems: products, megaFeatured: productFeatured, }, @@ -67,9 +73,8 @@ const nav: NavItem[] = [ href: '/use-cases/founders/', match: '/use-cases', mega: true, - megaTitle: 'Who uses Warmbly.', - megaTagline: 'Founders, agencies, sales teams, recruiters, fundraising. Pick the closest one.', megaItems: useCases, + megaFeatured: useCaseFeatured, }, { label: 'Deliverability',href: '/deliverability/',match: '/deliverability' }, { label: 'Pricing', href: '/pricing/', match: '/pricing' }, @@ -88,24 +93,24 @@ function active(match?: string) { id="site-header" data-on-sky={onSky ? 'true' : 'false'} class={[ - 'sticky top-0 z-50 transition-colors duration-200', - onSky ? 'header-on-sky' : 'border-b border-border/70 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55', + 'sticky top-0 z-50 transition-[border-color,background-color] duration-200', + onSky ? 'header-on-sky' : 'site-header-solid border-b border-transparent bg-white/85 backdrop-blur-md', ].join(' ')} > -
    +
    -
    + + + Star on GitHub{stars && · {stars}} + Join the Discord @@ -363,8 +315,10 @@ function active(match?: string) { -
    - - diff --git a/site/src/components/MobileAppShowcase.astro b/site/src/components/MobileAppShowcase.astro deleted file mode 100644 index b354b7246..000000000 --- a/site/src/components/MobileAppShowcase.astro +++ /dev/null @@ -1,607 +0,0 @@ ---- -import Cloud from './Cloud.astro'; - -/** - * Landing-page section for the iOS app. A deep-sky band with a compact - * header, a minimal story-progress switcher, and a pixel-faithful iPhone - * whose bottom half is cut off by the section edge. - * - * The screens are 1:1 recreations of the real SwiftUI app in ios/Warmbly: - * the 400px stage maps to a 393pt iPhone, so every pt in the Swift code is - * one px here. Home mirrors HomeView.swift (AirSkyWash hero, AirStatChips - * with the gradient stroke, the 30pt sheet, airCards, 34pt HealthRings). - * Inbox and Campaigns mirror UniboxRootView / CampaignsRootView (44pt search - * pill with the hamburger inside, tracked scope caption, 44pt avatars, - * iOS type ramp: 17 body / 15 subheadline / 13 footnote / 12 caption, red - * unread accents, quiet label chips). Text inside the phone uses the system - * SF stack, not the site font. - */ - -// WTheme.avatarColor palette from ios/Warmbly/DesignSystem/Theme.swift. -// WAvatar fills with a soft top-to-bottom grade of the tint. -const av = (tint: string) => `background: linear-gradient(180deg, ${tint}eb 0%, ${tint} 100%);`; - -const homeMailboxes = [ - { email: 'mia@sunriselabs.io', sub: 'Warming', warming: true, score: 92, tone: '#059669', tint: '#0284C7', initial: 'M' }, - { email: 'sales@northwind.dev', sub: 'Google Workspace', warming: false, score: 84, tone: '#059669', tint: '#7C3AED', initial: 'S' }, - { email: 'ben@coastline.io', sub: 'Warming', warming: true, score: 61, tone: '#D97706', tint: '#DB2777', initial: 'B' }, -]; - -// Tone.background / Tone.color pairs from DesignSystem/Components.swift. -const homeCampaigns = [ - { name: 'Q1 SaaS founders', sent: '1,248 sent', open: '41%', reply: '6.2%', bg: '#ECFDF5', fg: '#059669' }, - { name: 'Berlin startups', sent: '312 sent', open: '44%', reply: '7.1%', bg: '#FFFBEB', fg: '#D97706' }, -]; - -// Quiet label chips from the unibox rows: colored dot, neutral capsule. -const labelDots: Record = { - 'Interested': '#059669', - 'Out of office': '#D97706', - 'Pricing': '#0284C7', - 'Not now': '#64748B', - 'Unsubscribe': '#DC2626', -}; - -const inboxRows = [ - { name: 'Jenna Ortiz', time: '9:12', subject: 'Re: Quick question about Northwind', snippet: 'Thursday 10am works for me, send over an invite and I will get the team in the room.', lines: 2, label: 'Interested', tint: '#0284C7', unread: true, count: 3, live: true }, - { name: 'Marcus Hale', time: '8:47', subject: 'Automatic reply: Following up', snippet: 'I am out of the office until Monday with limited access to email.', lines: 1, label: 'Out of office', tint: '#7C3AED', unread: false, count: 0, live: false }, - { name: 'Priya Nair', time: '8:15', subject: 'Re: Warmbly for agencies', snippet: 'Can you send pricing for around 40 mailboxes? We would move our whole client roster.', lines: 2, label: 'Pricing', tint: '#059669', unread: true, count: 0, live: false }, - { name: 'Tom Becker', time: 'Yesterday', subject: 'Re: Intro from Sunrise Labs', snippet: 'You: Following up here, any thoughts on the trial?', lines: 1, label: '', tint: '#D97706', unread: false, count: 2, live: false, fromMe: true }, - { name: 'Ana Silva', time: 'Yesterday', subject: 'Re: A quicker way to warm inboxes', snippet: 'Interesting. How does the warmup pool actually work under the hood?', lines: 1, label: '', tint: '#DB2777', unread: false, count: 0, live: false }, - { name: 'Leo Fischer', time: 'Tue', subject: 'Re: Berlin startup outreach', snippet: 'Please remove me from this list.', lines: 1, label: 'Unsubscribe', tint: '#4F46E5', unread: false, count: 0, live: false }, -]; - -// statusAvatar symbol + Tone from CampaignsRootView: paperplane running, -// pause paused, checkmark finished, doc draft. -const campaignRows = [ - { name: 'Q1 SaaS founders', time: '2d', stats: '1.2K sent · 486 opens · 78 replies', status: 'Running', cap: '50/day', bg: '#ECFDF5', fg: '#059669', symbol: 'plane', live: true }, - { name: 'Agency outreach', time: '5d', stats: '640 sent · 243 opens · 31 replies', status: 'Running', cap: '40/day', bg: '#ECFDF5', fg: '#059669', symbol: 'plane', live: true }, - { name: 'Berlin startups', time: '1w', stats: '312 sent · 137 opens · 22 replies', status: 'Paused', cap: '30/day', bg: '#FFFBEB', fg: '#D97706', symbol: 'pause', live: false }, - { name: 'Post-demo follow-up', time: '3w', stats: '96 sent · 59 opens · 17 replies', status: 'Finished', cap: '20/day', bg: '#F0F9FF', fg: '#0284C7', symbol: 'check', live: false }, - { name: 'Dormant leads revival', time: '4w', stats: 'No sends yet', status: 'Draft', cap: '50/day', bg: '#F1F5F9', fg: '#64748B', symbol: 'doc', live: false }, -]; - -const campaignSymbols: Record = { - plane: '', - pause: '', - check: '', - doc: '', -}; - -// iOS status bar: one fixed 59pt safe-area strip above the sliding screens -// (it never moves on a device; only its glyph color adapts to the screen). -const statusBar = ` -
    - 9:41 - - - - - -
    `; - -// AirSkyWash from ios/Warmbly/DesignSystem/Air.swift: brand radial + soft bloom. -const skyWash = 'background: radial-gradient(circle 420px at 60% 8%, rgba(186,230,253,0.55) 0%, transparent 60%), radial-gradient(circle 560px at 55% 4%, #7dd3fc 0%, #38bdf8 34%, #0ea5e9 66%, #0284c7 100%);'; - -// The search pill both browser tabs share: hamburger inside, presence on the right. -const searchPill = (placeholder: string) => ` -
    -
    - - - - ${placeholder} - - P - L - -
    -
    `; ---- -
    - - - - -
    -
    -
    iOS app
    -

    - The whole workspace, in your pocket. -

    -

    - Replies classified as they land, campaigns you can pause from anywhere, mailbox health at a glance. SwiftUI, in the same open-source repo, on the same realtime socket as the dashboard. -

    -
    - - -
    - {['Home', 'Inbox', 'Campaigns'].map((label, i) => ( - - ))} -
    - - -
    -
    -
    - - - - - - - -
    - - -
    - -
    - - - - -
    - - - - -
    -
    - -
    -
    - Positive reply - now -
    -

    jenna@northwind.dev: Thursday 10am works for me, send over an invite

    -
    -
    -
    - - -
    - -
    -
    -
    -
    THURSDAY, JULY 16
    -
    Good morning, Mia
    -
    - - SL -
    - -
    - {[ - { value: '2.4K', label: 'Sent this week', icon: 'M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.5.5 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z' }, - { value: '61%', label: 'Open rate', icon: 'M3 9l9-6 9 6v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M3 9.5l9 6 9-6' }, - { value: '148', label: 'Replies', icon: 'M9 17l-5-5 5-5 M20 18v-2a4 4 0 0 0-4-4H4' }, - ].map((s) => ( -
    -
    - - {s.value} -
    -
    {s.label}
    -
    - ))} -
    -
    - - -
    -
    - -
    - Mailboxes - All 12 - - -
    - -
    - {homeMailboxes.map((m, i) => ( - <> -
    - {m.initial} -
    -
    {m.email}
    -
    - {m.warming && ( - - )} - {m.sub} -
    -
    - - - - {m.score} - -
    - {i < homeMailboxes.length - 1 &&
    } - - ))} -
    -
    - -
    -
    - This week - Analytics - - -
    -
    -
    - Sent - Opens -
    - - - - - - - - - - - - - - - MonTueWedThuFriSatSun - - -
    -
    - -
    -
    - Campaigns - See all - - -
    -
    - {homeCampaigns.map((c, i) => ( - <> -
    - - - -
    -
    {c.name}
    -
    {c.sent}
    -
    -
    - {c.open}open - {c.reply}reply -
    -
    - {i < homeCampaigns.length - 1 &&
    } - - ))} -
    -
    -
    -
    - - -
    - - -
    - ALL INBOXES - 1.2K - - 24 unread -
    -
    - {inboxRows.map((r) => ( -
    - - - {r.name.split(' ').map((p) => p[0]).join('')} - {r.live && } - {r.count > 1 && {r.count}} - -
    -
    - {r.name} - {r.time} -
    -
    -
    -
    {r.subject}
    -
    - {r.fromMe && ( - - )} - {r.snippet} -
    -
    - {r.unread && } -
    - {r.label && ( -
    - - - {r.label} - -
    - )} -
    -
    -
    - ))} -
    -
    - - -
    - -
    - ALL CAMPAIGNS - 12 - - 6 sending -
    -
    - {campaignRows.map((c) => ( -
    - - - - -
    -
    - {c.name} - {c.time} -
    -
    {c.stats}
    -
    - - - {c.status} - - · - {c.cap} -
    -
    -
    -
    - ))} -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - - diff --git a/site/src/components/OpenSourceShowcase.astro b/site/src/components/OpenSourceShowcase.astro deleted file mode 100644 index f8c93c395..000000000 --- a/site/src/components/OpenSourceShowcase.astro +++ /dev/null @@ -1,241 +0,0 @@ ---- -/** - * Open-source section. Light, matching the bento's plinth-and-artifact - * anatomy: copy and facts on the left, and on the right a tinted plinth - * holding a repo browser (the real top-level tree) with a dark self-host - * terminal floating over it. The dark band with clouds is gone; the terminal - * is the one dark element, which is what makes it read as a terminal. - */ -const gh = ''; -const arrow = ''; -const folder = ''; - -// The actual top-level tree, with GitHub linguist colors on the dots. -const tree = [ - { path: 'cmd/backend', desc: 'API, the control plane', lang: 'Go', dot: '#00add8' }, - { path: 'cmd/worker', desc: 'Distributed senders', lang: 'Go', dot: '#00add8' }, - { path: 'tracking', desc: 'Open and click pixels', lang: 'Rust', dot: '#dea584' }, - { path: 'realtime', desc: 'Live dashboard fanout', lang: 'Elixir', dot: '#6e4a7e' }, - { path: 'web', desc: 'The dashboard', lang: 'TypeScript', dot: '#3178c6' }, - { path: 'docs', desc: 'docs.warmbly.com', lang: 'MDX', dot: '#fcb32c' }, -]; - -const lines = [ - { kind: 'cmd', text: 'git clone https://github.com/warmbly/warmbly.git' }, - // cd is part of the command: without it make up runs in the parent - // directory, finds no Makefile, and self-host startup fails. - { kind: 'cmd', text: 'cd warmbly && make up' }, - { kind: 'ok', text: 'Warmbly is live on http://localhost:8080' }, - { kind: 'caret' }, -]; - -const facts = [ - { value: '100%', label: 'Open source' }, - { value: '$0', label: 'To self-host' }, - { value: '1', label: 'Command to boot' }, - { value: 'Public', label: 'Issues and PRs' }, -]; - -const links = [ - { label: 'Good first issues', href: 'https://github.com/warmbly/warmbly/labels/good%20first%20issue' }, - { label: 'Discussions', href: 'https://github.com/warmbly/warmbly/discussions' }, - { label: 'Architecture', href: '/trust/' }, - { label: 'Apache 2.0 license', href: 'https://github.com/warmbly/warmbly/blob/main/LICENSE' }, -]; ---- -
    -
    - -
    - - - warmbly/warmbly - Apache 2.0 - - -

    - Read the code. Run it yourself. -

    -

    - See exactly how warmup, sending and deliverability work, self-host the whole platform on your own infra, and send a pull request when something should be better. -

    - - - -
    - {facts.map((f, i) => ( -
    0 && 'sm:border-l sm:border-[color:var(--border)] sm:pl-4']}> -
    {f.value}
    -
    {f.label}
    -
    - ))} -
    - -
    - {links.map((l) => ( - - {l.label} - - - ))} -
    -
    - - -
    -
    - - - -
    -
    - - warmbly/warmbly - - - main - - Go · Rust · Elixir · TS -
    - {tree.map((t, i) => ( -
    - - {t.path} - {t.desc} - - {t.lang} - -
    - ))} -
    - - -
    -
    - - - - self-host -
    -
    - {lines.map((l, i) => ( -
    - {l.kind === 'cmd' && ( - $ {l.text} - )} - {l.kind === 'ok' && ( - - - {l.text} - - )} - {l.kind === 'caret' && ( - $ - )} -
    - ))} -
    -
    -
    - -

    - Self-hosting is free, and you still get the warmup. When Warmbly Cloud launches, link your instance and we warm your mailboxes in the same shared pool the cloud uses: free for up to 10 mailboxes, $15 a month for unlimited. How linking works. -

    -
    -
    -
    - - - - - - diff --git a/site/src/components/OutboundSteps.astro b/site/src/components/OutboundSteps.astro new file mode 100644 index 000000000..09e83cbd8 --- /dev/null +++ b/site/src/components/OutboundSteps.astro @@ -0,0 +1,65 @@ +--- +import AccountsScene from './scenes/AccountsScene.astro'; +import WarmupScene from './scenes/WarmupScene.astro'; +import CampaignScene from './scenes/CampaignScene.astro'; +import DeliverabilityScene from './scenes/DeliverabilityScene.astro'; + +/** + * How it works, in four numbered steps that alternate copy and a live + * dashboard scene. + */ +const steps = [ + { + n: '01', + title: 'Connect your mailboxes', + text: "Add Google Workspace, Microsoft 365 or any SMTP and IMAP account. Warmbly checks your domain's SPF and DMARC records and shows the health of every mailbox.", + link: { text: 'Learn about sending', href: '/sending/' }, + }, + { + n: '02', + title: 'Warm them up', + text: 'Each mailbox exchanges a small amount of real email with other inboxes, starting at 10 a day and growing slowly. This builds the reputation it needs before your first campaign.', + link: { text: 'Learn about warmup', href: '/warmup/' }, + }, + { + n: '03', + title: 'Send your campaigns', + text: 'Build a sequence, add your contacts and choose your sending hours. Warmbly spreads the emails across your mailboxes, with a daily limit and a pause between sends for each one.', + link: { text: 'Learn about campaigns', href: '/campaigns/' }, + }, + { + n: '04', + title: 'Watch your deliverability', + text: 'See how much of your mail lands in the inbox at Gmail, Outlook and Yahoo. If a mailbox starts going to spam, Warmbly lowers its volume before it affects the others.', + link: { text: 'Learn about deliverability', href: '/deliverability/' }, + }, +]; +--- +
    +

    + How it works. + Warmbly keeps your mailboxes healthy while you send. +

    + +
    + {steps.map((s, i) => ( +
    +
    +
    {s.n}
    +

    {s.title}

    +

    {s.text}

    + + {s.link.text} + + +
    +
    + {s.n === '01' && } + {s.n === '02' && } + {s.n === '03' && } + {s.n === '04' && } +
    +
    + ))} +
    +
    diff --git a/site/src/components/PageHero.astro b/site/src/components/PageHero.astro deleted file mode 100644 index 1fe891c71..000000000 --- a/site/src/components/PageHero.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Cloud from './Cloud.astro'; - -interface Props { - eyebrow?: string; - title: string; - description?: string; -} -const { eyebrow, title, description } = Astro.props; ---- - -
    -
    - -
    -
    - -
    - -
    - {eyebrow && ( -
    {eyebrow}
    - )} -

    - {title} -

    - {description && ( -

    {description}

    - )} - -
    -
    diff --git a/site/src/components/PaintedSky.astro b/site/src/components/PaintedSky.astro deleted file mode 100644 index fac8dcdd4..000000000 --- a/site/src/components/PaintedSky.astro +++ /dev/null @@ -1,62 +0,0 @@ ---- -/** - * Painted-sky backdrop with graceful degradation. - * - * The CSS-painted gradient renders behind, and the overlays it - * when present. If the image fails to load (file not yet generated, - * 404, etc.), `onerror` hides the so the CSS gradient shows - * through — no broken-image icon, ever. - * - *
    - * - *
    …content…
    - *
    - */ -interface Props { - /** Path to the painted image under /public. Missing files fall back to the CSS gradient. */ - src?: string; - /** Fallback CSS palette used both as a background and on image error. */ - flavor?: 'dawn' | 'bright' | 'dusk'; - /** Prioritise loading — hero only. */ - eager?: boolean; - /** Decorative cloud puffs on top. */ - clouds?: boolean; - /** Soft horizon shadow at the bottom. */ - horizon?: boolean; - alt?: string; - class?: string; -} -const { - src, - flavor = 'dawn', - eager = false, - clouds = false, - horizon = false, - alt = '', - class: className = '', -} = Astro.props; ---- - - - -{src && ( - {alt} -)} diff --git a/site/src/components/Photo.astro b/site/src/components/Photo.astro new file mode 100644 index 000000000..d733dcea9 --- /dev/null +++ b/site/src/components/Photo.astro @@ -0,0 +1,47 @@ +--- +/** + * A background photo from src/assets/textures, built into AVIF (WebP for the + * few browsers without it) at a few widths, so every screen downloads only + * what it draws. Quality 50 keeps the grain of these photos at a fraction of + * WebP's size. A ~300-byte blurred copy is inlined as the image's own + * background, so the frame is never empty while the real file arrives. + */ +import { Picture } from 'astro:assets'; +import sharp from 'sharp'; +import path from 'node:path'; + +interface Props { + name: string; + /** The rendered width at each breakpoint, as an list. */ + sizes: string; + /** Above the fold: load first instead of lazily. */ + priority?: boolean; + class?: string; +} +const { name, sizes, priority = false, class: className = '' } = Astro.props; + +const images = import.meta.glob<{ default: ImageMetadata }>('../assets/textures/*.webp', { eager: true }); +const image = images[`../assets/textures/${name}.webp`]?.default; +if (!image) throw new Error(`Photo: no texture named "${name}" in src/assets/textures`); + +const lqip = await sharp(path.join(process.cwd(), 'src/assets/textures', `${name}.webp`)) + .resize(24) + .webp({ quality: 50 }) + .toBuffer(); +const placeholder = `background-image:url(data:image/webp;base64,${lqip.toString('base64')});background-size:cover;background-position:center;`; +--- +
    +
    + {rows.map((row, r) => ( +
    +
      + {[...row, ...row].map((it, i) => { + const raw = glyph(it.brand); + const icon = BRAND_ICON[it.brand]; + return ( +
    • = row.length ? 'true' : undefined}> + + {raw ? ( + + ) : icon ? ( + + ) : null} + + + {it.name} + {it.does} + +
    • + ); + })} +
    +
    + ))} +
    + + diff --git a/site/src/components/TemplatingShowcase.astro b/site/src/components/TemplatingShowcase.astro deleted file mode 100644 index 60a83c744..000000000 --- a/site/src/components/TemplatingShowcase.astro +++ /dev/null @@ -1,225 +0,0 @@ ---- -/** - * TemplatingShowcase - the personalization section. One artifact proves the - * pitch (one template renders into a different email per recipient), then a - * feature grid covers the whole engine. Everything shown mirrors the real - * renderer (internal/tasks/template.go, internal/pkg/tmplfuncs, spintax.go): - * - * - merge fields {{.FirstName}} + custom CSV fields, spaces allowed - * - fallbacks: {{.FirstName | default "there"}} - * - conditionals if / else / end, eq, and / or, coercing numeric compares - * - helpers: title, lower, contains, add / sub / mul / div - * - spintax: {Hi|Hey|Hello}, nested, subject and body - * - A/B variants per step, same editor and preview - * - preview runs the real send engine; a broken {{if}} blocks launch; - * a failed render falls back to plain substitution, never a dead send - */ -import SectionHead from './SectionHead.astro'; -import Icon from './Icon.astro'; - -// Three recipients through the same template: the Engineer branch, the else -// branch, and the default fallback for a contact with no first name. Each -// carries chips naming the features that fired. -const results = [ - { - who: 'Alex Rivera', - meta: 'alex@acme.com · role: Engineer', - grad: 'from-sky-400 to-indigo-400', - parts: [ - { t: 'Hey', c: 'spin' }, { t: ' ' }, { t: 'Alex', c: 'field' }, { t: ', we built this for teams like yours.' }, - ], - chips: ['spintax picked Hey', 'if branch: Engineer'], - }, - { - who: 'Jenna Carter', - meta: 'jenna@northwind.dev · role: Founder', - grad: 'from-rose-400 to-amber-400', - parts: [ - { t: 'Hi', c: 'spin' }, { t: ' ' }, { t: 'Jenna', c: 'field' }, { t: ', thought this might be useful for ' }, { t: 'Northwind', c: 'field' }, { t: '.' }, - ], - chips: ['else branch', '{{.Company}}'], - }, - { - who: 'No first name', - meta: 'mail@brightco.io · role: empty', - grad: 'from-slate-300 to-slate-400', - parts: [ - { t: 'Hello', c: 'spin' }, { t: ' ' }, { t: 'there', c: 'fall' }, { t: ', thought this might be useful for ' }, { t: 'Brightco', c: 'field' }, { t: '.' }, - ], - chips: ['default "there"', 'blank never breaks'], - }, -]; - -const features = [ - { - icon: 'database', - title: 'Every field, including yours', - body: 'Standard fields plus every column from your CSV import. Keys with spaces or dashes work everywhere, even inside conditions.', - code: '{{.job title}}', - }, - { - icon: 'cog', - title: 'Helper functions', - body: 'Case and string helpers, arithmetic, and numeric compares that coerce strings, so number-like fields just work.', - code: '{{if gtf .employees 50}}', - }, - { - icon: 'shuffle', - title: 'Spintax, nested', - body: 'Alternation groups resolve innermost first and nest freely. Subjects spin too, so no two sends read identical.', - code: '{Hi|Hey {there|again}}', - }, - { - icon: 'flask', - title: 'A/B variants per step', - body: 'Test variants of any step. The original and every variant share the same editor, preview and content score.', - code: 'Variant B · 50%', - }, - { - icon: 'search', - title: 'Previewed with the real engine', - body: 'The composer renders your template with the actual send engine against sample data. Conditionals, functions and spintax all run.', - code: 'Hey Alex,', - }, - { - icon: 'shield', - title: 'Validated before launch', - body: 'A broken {{if}} blocks the campaign and unresolved tokens get flagged. A failing render falls back to plain substitution, never a lost send.', - code: 'if: missing {{end}}', - }, -]; ---- -
    -
    - - - -
    - - -
    - -
    -
    - Step 1 · template - written once -
    -
    {`{Hi|Hey|Hello}`} {`{{.FirstName`} | default "there"{`}}`},
    -
    -{`{{if eq`} .role "Engineer"{`}}`}we built this for
    -teams like yours.{`{{else}}`}thought this might
    -be useful for {`{{.Company}}`}.{`{{end}}`}
    -
    - - -
    - {results.map((r, i) => ( -
    -
    - - {r.who} - {r.meta} -
    -

    - {r.parts.map((p) => ( - p.c - ? {p.t} - : p.t - ))} -

    -
    - {r.chips.map((c) => ( - {c} - ))} -
    -
    - ))} -
    -
    - -
    - - rendered per recipient, at send time -
    -
    - - -
    - {features.map((f) => ( -
    -
    - - - -

    {f.title}

    -
    -

    {f.body}

    - {f.code} -
    - ))} -
    -
    -
    - - - - - - diff --git a/site/src/components/UniboxMock.astro b/site/src/components/UniboxMock.astro deleted file mode 100644 index 4e774b98b..000000000 --- a/site/src/components/UniboxMock.astro +++ /dev/null @@ -1,554 +0,0 @@ ---- -import DemoCursor from './DemoCursor.astro'; -/** - * Pixel-faithful mock of the Warmbly /app/unibox screen (unified inbox). - * - * Maps 1:1 to the real components: - * - web/src/app/app/unibox/page.tsx - three columns, nothing above them - * - web/src/components/app/unibox/ScopeRail.tsx - left scope rail (w-220, white) - * - web/src/components/app/unibox/ConversationList.tsx - middle list (w-360): title row, search, grouped rows - * - web/src/components/app/unibox/ConversationItem.tsx - list rows (no avatar, unread dot in the gutter) - * - web/src/components/app/unibox/ThreadView.tsx - right thread pane: subject over a meta line, icon actions - * - web/src/components/app/unibox/MessageBubble.tsx - per-message article - * - * Renders only the in-app screen content. The marketing page wraps this - * in its own browser frame, so there is no sidebar / app header / window - * chrome here. The content overflows the frame on purpose; the parent - * crops with overflow-hidden, so the load-bearing content sits top-left. - */ - -// ── Lucide paths (24x24 viewBox, stroke 2) ───────────────────────── -const icons = { - penLine: '', - layers: '', - inbox: '', - mail: '', - reply: '', - sparkles: '', - moon: '', - fileText: '', - send: '', - clock: '', - archive: '', - octagon: '', - trash2: '', - mailOpen: '', - chevron: '', - listFilter: '', - search: '', - tag: '', - mailCheck: '', - trash: '', - user: '', - cornerUp: '', - forward: '', - x: '', - userPlus: '', - info: '', -}; - -// ── Scope rail (left) ────────────────────────────────────────────── -// One visual language for every row: icon, label, bare tabular count. -// `accent` is an unread count (sky, semibold); a plain total stays muted. -type RailRow = { icon: keyof typeof icons; label: string; count?: string; accent?: boolean; active?: boolean }; - -const viewRows: RailRow[] = [ - { icon: 'layers', label: 'All mail', count: '342', active: true }, - { icon: 'inbox', label: 'Inbox', count: '7', accent: true }, - { icon: 'mail', label: 'Unread', count: '7', accent: true }, - { icon: 'reply', label: 'Awaiting reply', count: '3', accent: true }, - { icon: 'sparkles', label: 'Agent drafts', count: '2', accent: true }, - { icon: 'moon', label: 'Snoozed', count: '2' }, -]; - -const folderRows: RailRow[] = [ - { icon: 'fileText', label: 'Drafts', count: '4' }, - { icon: 'send', label: 'Sent' }, - { icon: 'clock', label: 'Scheduled', count: '1', accent: true }, - { icon: 'archive', label: 'Archive' }, - { icon: 'octagon', label: 'Spam' }, - { icon: 'trash2', label: 'Trash' }, -]; - -const mailboxScopes = [ - { email: 'hello@company.com', count: '2' }, - { email: 'support@company.com', count: '5' }, - { email: 'sales@company.com', count: '' }, -]; - -const labelScopes = [ - { color: '#ef4444', label: 'Urgent', count: '1' }, - { color: '#f59e0b', label: 'Clients', count: '3' }, -]; - -const tagScopes = [ - { color: '#3b82f6', label: 'Leads', count: '12' }, -]; - -// ── Conversation list (middle) ───────────────────────────────────── -type Conversation = { - sender: string; subject: string; preview: string; time: string; - unread: boolean; selected: boolean; count?: number; mailbox: string; - labels: { color: string; label: string }[]; -}; - -const groups: { title: string; rows: Conversation[] }[] = [ - { - title: 'Today', - rows: [ - { - sender: 'Sarah Chen', - subject: 'Follow up on Q1 proposal', - preview: "Thanks for sending over the docs. I've reviewed them and have a few questions.", - time: '5m', unread: true, selected: true, count: 3, - mailbox: 'hello@company.com', - labels: [{ color: '#ef4444', label: 'Urgent' }, { color: '#f59e0b', label: 'Clients' }], - }, - { - sender: 'Jordan Smith', - subject: 'Re: Dashboard integration', - preview: 'The API endpoint looks good. When can we expect the staging build?', - time: '42m', unread: false, selected: false, - mailbox: 'support@company.com', - labels: [], - }, - { - sender: 'Alex Morgan', - subject: 'New partnership opportunity', - preview: "Hi there, I'm reaching out about a potential partnership between our teams.", - time: '3h', unread: true, selected: false, - mailbox: 'sales@company.com', - labels: [{ color: '#3b82f6', label: 'Leads' }], - }, - ], - }, - { - title: 'Earlier', - rows: [ - { - sender: 'Casey Reynolds', - subject: '(no subject)', - preview: '(no preview)', - time: 'Jan 15', unread: false, selected: false, - mailbox: 'hello@company.com', - labels: [], - }, - ], - }, -]; - -// ── Thread (right) ───────────────────────────────────────────────── -const messages = [ - { - initials: 'SC', name: 'Sarah Chen', addr: 'sarah@acme.io', - to: 'hello@company.com', time: 'Jan 18, 10:32 AM', collapsed: true, - body: "Thanks for sending over the docs. I've reviewed them and have a few questions. Can we sync up tomorrow morning?", - }, - { - initials: 'CO', name: 'Company', addr: 'hello@company.com', - to: 'sarah@acme.io', time: 'Jan 18, 02:15 PM', collapsed: true, - body: 'Absolutely! How does 10am EST work for you?', - }, - { - initials: 'SC', name: 'Sarah Chen', addr: 'sarah@acme.io', - to: 'hello@company.com', time: 'Jan 18, 02:28 PM', collapsed: false, - body: 'Perfect. See you then!', - }, -]; - -const threadLabels = [ - { color: '#ef4444', rgb: '239,68,68', label: 'Urgent' }, - { color: '#f59e0b', rgb: '245,158,11', label: 'Clients' }, -]; - -// ScopeRail: ROW / ROW_ACTIVE / ROW_IDLE and Count. -const railRow = (active?: boolean) => - `w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2.5 text-left ${active ? 'bg-slate-100 text-slate-900' : 'text-slate-600'}`; -const railIcon = (active?: boolean) => `shrink-0 ${active ? 'text-slate-800' : 'text-slate-400'}`; -const railCount = (accent?: boolean, active?: boolean) => - `shrink-0 tabular-nums text-[11px] ${accent && !active ? 'text-sky-700 font-semibold' : active ? 'text-slate-600' : 'text-slate-400'}`; ---- -
    - -
    - - - - - -
    - -
    - All mail - 4 - - - - - - - - Compose - N - -
    - - -
    -
    - - Search all mail - -
    -
    - - -
    - {groups.map((g) => ( -
    -
    - {g.title} -
    -
    - {g.rows.map((c) => ( -
    - - - {c.unread && } - -
    -
    - {c.sender} - {c.count && {c.count}} - {c.time} -
    -
    - {c.subject} - {c.labels.length > 0 && ( - - {c.labels.map((l) => ( - - - {l.label} - - ))} - - )} -
    -
    - {c.preview} - {c.mailbox} -
    -
    -
    - ))} -
    -
    - ))} -
    -
    - - -
    - -
    - - -
    - -
    -
    -
    Follow up on Q1 proposal
    -
    - 3 messages - · - hello@company.com - -
    -
    -
    - - - - - - - - - - - -
    -
    - - -
    - {messages.map((m) => ( -
    -
    -
    {m.initials}
    -
    -
    - {m.name} - {!m.collapsed && {m.addr}} -
    - {m.collapsed - ?
    {m.body}
    - :
    to {m.to}
    } -
    -
    -
    - - -
    - {m.time} -
    -
    - {!m.collapsed && ( -
    {m.body}
    - )} -
    - ))} -
    - - -
    - - - Reply - - - - Forward - -
    -
    -
    - - - - - - - - -
    - - diff --git a/site/src/components/UseCasePage.astro b/site/src/components/UseCasePage.astro deleted file mode 100644 index fbdfc8a80..000000000 --- a/site/src/components/UseCasePage.astro +++ /dev/null @@ -1,108 +0,0 @@ ---- -import Layout from '../layouts/Layout.astro'; -import Cloud from './Cloud.astro'; -import Icon from './Icon.astro'; -import CTA from './CTA.astro'; - -interface Props { - title: string; - description: string; - pageTitle: string; - intro: string; - challenges: { title: string; body: string }[]; - recipe: { title: string; body: string }[]; - caps: { value: string; label: string }[]; - eyebrow?: string; -} - -const { title, description, pageTitle, intro, challenges, recipe, caps, eyebrow = 'Use case' } = Astro.props; ---- - - -
    -
    - -
    - -
    - - - {eyebrow} - -

    - {title} -

    -

    - {intro} -

    -
    -
    - - -
    -
    -
    - {caps.map((c) => ( -
    -
    {c.label}
    -
    {c.value}
    -
    - ))} -
    -
    -
    - - -
    -
    -
    -
    The pattern
    -

    - What usually goes wrong. -

    -
    -
    - {challenges.map((c, i) => ( -
    -
    ×{String(i + 1).padStart(2, '0')}
    -
    -
    {c.title}
    -

    {c.body}

    -
    -
    - ))} -
    -
    -
    - - -
    -
    -
    Recipe
    -

    - How to set this up in Warmbly. -

    - -
    -
    -
    - {recipe.map((r, i) => ( -
    -
    -
    - {String(i + 1).padStart(2, '0')} -
    -
    -
    -
    {r.title}
    -

    {r.body}

    -
    -
    - ))} -
    -
    -
    -
    - - -
    diff --git a/site/src/components/WarmupMock.astro b/site/src/components/WarmupMock.astro deleted file mode 100644 index 6a608973f..000000000 --- a/site/src/components/WarmupMock.astro +++ /dev/null @@ -1,46 +0,0 @@ ---- -/** Mock of a warmup health panel — ramp chart + KPIs + pool health. */ -const ramp = [10, 11, 12, 14, 16, 18, 21, 24, 27, 30, 33, 36, 38, 40, 40]; ---- -
    - -
    - Warmup -
    - Premium pool · 124 mailboxes -
    - - healthy - -
    -
    - - -
    - {[ - { label: 'Complaint rate', value: '0.02%', sub: '< 0.03 warn', last: false }, - { label: 'Bounce rate', value: '0.7%', sub: '< 1.0 warn', last: false }, - { label: 'Spam placement', value: '3.1%', sub: '< 10 watch', last: false }, - { label: 'Inbox placement',value: '96%', sub: 'rolling 14d', last: true }, - ].map((s) => ( -
    -
    {s.label}
    -
    {s.value}
    -
    {s.sub}
    -
    - ))} -
    - - -
    -
    - Warmup ramp · 15 days - 10 → 40 / day -
    -
    - {ramp.map((v) => ( -
    - ))} -
    -
    -
    diff --git a/site/src/components/WarmupPoolNotice.astro b/site/src/components/WarmupPoolNotice.astro index 3499f5d07..9c0481dc9 100644 --- a/site/src/components/WarmupPoolNotice.astro +++ b/site/src/components/WarmupPoolNotice.astro @@ -1,42 +1,30 @@ --- /** - * Reusable notice: the shared warmup pool is a cloud feature, and a single - * self-hosted instance starts with an empty pool. Drop it on any light- - * background page where a reader might assume self-hosting gives them a - * working warmup pool (warmup, deliverability, open-source sections). - * - * The numbers and behaviour match the warmup pool model in the repo - * (internal/repository/pg_warmup.go, migration 000010). + * The shared warmup pool is a cloud feature: a single self-hosted instance + * starts with an empty pool. Shown wherever a reader might assume self-hosting + * brings a working pool with it. */ import Icon from './Icon.astro'; interface Props { - /** extra margin/utility classes on the outer card */ class?: string; - /** show the "connect your instance" link (default true) */ + /** Show the waitlist link (default true). */ showWaitlist?: boolean; } const { class: className = '', showWaitlist = true } = Astro.props; --- -