pr_01m47d24b0e6n91zwymwxg0vpx/apps/sudo/app/routes/reach-out.tsx

230 lines10,069 bytesCodeBlame
1import { ChevronRight } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AdminOwner, Signal } from "@g1t/contracts";
5
6import type { Route } from "./+types/reach-out";
7import { Owners } from "~/components/billing";
8import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
9import { Avatar, EmptyState, Notice, PageHeader } from "~/components/ui";
10import { usd } from "~/lib/money";
11import { identity, admin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import { SIGNAL_KINDS, type Who, bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref } from "~/lib/signals";
14import { requireStaff } from "~/lib/staff";
15
16export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17
18export async function loader({ request, context }: Route.LoaderArgs) {
19 const staff = requireStaff(context);
20 const url = new URL(request.url);
21 const rawKind = url.searchParams.get("kind");
22 const kind = isSignalKind(rawKind) ? rawKind : null;
23 const who = parseWho(url.searchParams.get("who"));
24
25 // Owners and names come from identity's list: one call, newest 500.
26 const [signals, workspaces] = await Promise.all([settle(admin.signals()), settle(identity.workspaces())]);
27 const all = signals.ok ? bySignalUrgency(signals.value) : [];
28 const people = new Map(
29 (workspaces.ok ? workspaces.value : []).map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]),
30 );
31 const forWho = filterSignals(all, { kind: null, who, me: staff.email });
32 const rows = filterSignals(forWho, { kind, who: "all", me: staff.email }).map((signal) => ({
33 signal,
34 name: people.get(signal.workspace)?.name ?? null,
35 owners: people.get(signal.workspace)?.owners ?? ([] as AdminOwner[]),
36 }));
37 return {
38 me: staff.email,
39 kind,
40 who,
41 rows,
42 total: all.length,
43 workspaces: new Set(all.map((signal) => signal.workspace)).size,
44 counts: countByKind(forWho),
45 whoCounts: {
46 all: all.length,
47 unassigned: filterSignals(all, { kind: null, who: "unassigned", me: staff.email }).length,
48 mine: filterSignals(all, { kind: null, who: "mine", me: staff.email }).length,
49 },
50 error: signals.ok ? null : signals.error,
51 };
52}
53
54const WHO: { who: Who; label: string }[] = [
55 { who: "all", label: "Everyone's" },
56 { who: "unassigned", label: "Unassigned" },
57 { who: "mine", label: "Mine" },
58];
59
60function Pill({ to, active, children, count }: { to: string; active: boolean; children: React.ReactNode; count?: number }) {
61 return (
62 <Link
63 to={to}
64 aria-current={active ? "page" : undefined}
65 className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
66 active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
67 }`}
68 >
69 {children}
70 {count != null && <span className={`tabular ${active ? "text-merged/80" : "text-faint"}`}>{count}</span>}
71 </Link>
72 );
73}
74
75type Row = { signal: Signal; name: string | null; owners: AdminOwner[] };
76
77function rowHref(signal: Signal) {
78 return `/workspaces/${encodeURIComponent(signal.workspace)}#sales`;
79}
80
81export default function ReachOut({ loaderData }: Route.ComponentProps) {
82 const { me, kind, who, rows, total, workspaces, counts, whoCounts, error } = loaderData;
83 const forWho = Object.values(counts).reduce((sum, count) => sum + count, 0);
84
85 return (
86 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
87 <PageHeader
88 title="Reach out"
89 description={
90 <>
91 Workspaces worth a word now, most urgent first: stopped, declined, close to their limit, spending more, or newly steady.
92 {total > 0 && (
93 <>
94 {" "}
95 {total} signal{total === 1 ? "" : "s"} across {workspaces} workspace{workspaces === 1 ? "" : "s"}.
96 </>
97 )}
98 </>
99 }
100 />
101
102 {error && (
103 <div className="mt-6">
104 <Notice tone="warn">Billing did not answer for signals: {error}</Notice>
105 </div>
106 )}
107
108 <nav aria-label="Filters" className="mt-6 space-y-2.5">
109 <div className="flex flex-wrap items-center gap-1.5">
110 <span className="mr-1 w-10 text-xs text-faint">Whose</span>
111 {WHO.map((option) => (
112 <Pill key={option.who} to={reachOutHref({ kind, who: option.who })} active={who === option.who} count={whoCounts[option.who]}>
113 {option.label}
114 </Pill>
115 ))}
116 </div>
117 <div className="flex flex-wrap items-center gap-1.5">
118 <span className="mr-1 w-10 text-xs text-faint">Why</span>
119 <Pill to={reachOutHref({ who })} active={kind == null} count={forWho}>
120 Any
121 </Pill>
122 {SIGNAL_KINDS.map((entry) => (
123 <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}>
124 {entry.label}
125 </Pill>
126 ))}
127 </div>
128 </nav>
129
130 {rows.length === 0 ? (
131 <div className="mt-5">
132 <EmptyState title={total === 0 ? "Nobody needs a word right now" : "None match"}>
133 {total === 0
134 ? "Workspaces show up here when they stop, come close to their limit, are declined, grow, or pay for the first time."
135 : who === "mine"
136 ? "No signals for workspaces you own. Take one from Unassigned by setting yourself as its owner on its Sales section."
137 : "Try another filter."}
138 </EmptyState>
139 </div>
140 ) : (
141 <>
142 {/* Phones: one card per signal. */}
143 <ul className="mt-5 space-y-2 md:hidden">
144 {rows.map((row, index) => (
145 <li key={`${row.signal.workspace}-${row.signal.kind}-${index}`}>
146 <Link to={rowHref(row.signal)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
147 <div className="flex items-start justify-between gap-3">
148 <WorkspaceCell row={row} />
149 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
150 </div>
151 <div className="mt-3 flex flex-wrap items-center gap-2">
152 <SignalBadge kind={row.signal.kind} />
153 <span className="tabular text-sm text-fg-soft">{usd(row.signal.valueMicros)}</span>
154 </div>
155 <p className="mt-1.5 text-sm text-muted">{row.signal.detail}</p>
156 <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3 text-xs">
157 <StageBadge stage={row.signal.stage} />
158 <StaffName email={row.signal.owner} me={me} />
159 </div>
160 </Link>
161 </li>
162 ))}
163 </ul>
164
165 {/* Wider screens: a table. */}
166 <div className="mt-5 hidden overflow-x-auto rounded-lg border border-line md:block">
167 <table className="w-full text-sm">
168 <thead>
169 <tr className="border-b border-line bg-surface text-left text-xs text-muted">
170 <th className="px-4 py-2.5 font-medium">Workspace</th>
171 <th className="px-4 py-2.5 font-medium">Why</th>
172 <th className="px-4 py-2.5 text-right font-medium">Figure</th>
173 <th className="px-4 py-2.5 font-medium">Stage</th>
174 <th className="px-4 py-2.5 font-medium">Owner at g1t</th>
175 <th className="w-8 px-2 py-2.5" />
176 </tr>
177 </thead>
178 <tbody>
179 {rows.map((row, index) => (
180 <tr key={`${row.signal.workspace}-${row.signal.kind}-${index}`} className="border-b border-line align-top last:border-0 hover:bg-surface/60">
181 <td className="max-w-64 px-4 py-3">
182 <Link to={rowHref(row.signal)} className="group block">
183 <WorkspaceCell row={row} />
184 </Link>
185 </td>
186 <td className="px-4 py-3">
187 <SignalBadge kind={row.signal.kind} />
188 <p className="mt-1 max-w-md text-sm text-muted">{row.signal.detail}</p>
189 </td>
190 <td className="tabular px-4 py-3 text-right whitespace-nowrap text-fg-soft">{usd(row.signal.valueMicros)}</td>
191 <td className="px-4 py-3">
192 <StageBadge stage={row.signal.stage} />
193 </td>
194 <td className="max-w-48 px-4 py-3 text-xs">
195 <StaffName email={row.signal.owner} me={me} />
196 </td>
197 <td className="px-2 py-3">
198 <Link to={rowHref(row.signal)} aria-label={`Open ${row.signal.workspace}'s sales`} className="block text-faint hover:text-fg">
199 <ChevronRight size={16} />
200 </Link>
201 </td>
202 </tr>
203 ))}
204 </tbody>
205 </table>
206 </div>
207 </>
208 )}
209 </main>
210 );
211}
212
213function WorkspaceCell({ row }: { row: Row }) {
214 return (
215 <div className="flex min-w-0 items-start gap-2.5">
216 <span className="mt-0.5">
217 <Avatar name={row.signal.workspace} size={22} />
218 </span>
219 <div className="min-w-0">
220 <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.name ?? row.signal.workspace}</p>
221 <p className="truncate font-mono text-xs text-faint">{row.signal.workspace}</p>
222 {row.owners.length > 0 && (
223 <div className="mt-1.5 text-xs">
224 <Owners owners={row.owners} compact />
225 </div>
226 )}
227 </div>
228 </div>
229 );
230}