Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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