A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales
The project menu follows g1t's loop: Work (issues, pull requests, merge queue, outcomes), Code, Agents, Ship (workflows and deployments together), Security, Observe and Insights. Every Soon is now a page from one roadmap catalogue: what it is for, what it will do, and what to use until then, with Board planned the way GitHub Projects works. Actions are called Workflows. sudo has a sidebar of folding sections, an Overview of revenue against cost, a Reach out queue from billing's signals, a Sales section on each workspace (stage, owner, next step, notes), six-month charts and invoices, and Soon pages mapping the in-house CRM and ERP.
31 files+3133−2090/31 viewed
| 1 | 1 | # sudo | |
| 2 | 2 | ||
| 3 | − | g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way | |
| 4 | − | customers know g1t: by **workspace**. | |
| 3 | + | g1t's staff console, at <https://sudo.g1t.sh>: the back office g1t is | |
| 4 | + | building for itself, for sales, support and finance. It is organised the | |
| 5 | + | way customers know g1t: by **workspace**. | |
| 6 | + | ||
| 7 | + | The sidebar (`app/lib/nav.ts`) has Overview and Reach out at the top, then | |
| 8 | + | sections that fold open to their pages: Customers, Revenue, Platform, | |
| 9 | + | Support and Team. Each fold is a `<details>`, drawn open for the section | |
| 10 | + | holding the current page; on a phone the same menu sits behind a "Menu" | |
| 11 | + | button in the top bar. Pages not built yet are marked **Soon**: each is a | |
| 12 | + | real page (`routes/soon.tsx`, made from its entry in `nav.ts`) saying what | |
| 13 | + | it will do, why, and what it will have, so the sidebar doubles as the | |
| 14 | + | roadmap. | |
| 5 | 15 | ||
| 6 | − | - **Workspaces** (the home page): every workspace, newest first, 50 to a | |
| 16 | + | - **Overview** (`/`): this month charged, cost and margin; the last six | |
| 17 | + | months as a chart; this month by kind of usage; paying workspaces; how | |
| 18 | + | many are stopped, near their limit or declined (each a link into Reach | |
| 19 | + | out); open invoices; follow-ups due; and the five most urgent signals. | |
| 20 | + | From billing's `admin_overview` and `admin_signals`. | |
| 21 | + | - **Reach out** (`/reach-out`): every workspace worth a word, most urgent | |
| 22 | + | first (at limit, declined, near limit, high spend, growing, established, | |
| 23 | + | first payment), with its owners, the reason in a sentence, the figure, | |
| 24 | + | and its sales stage and owner at g1t. Filter by why and by whose | |
| 25 | + | (everyone's, unassigned, mine). Each row opens the workspace's Sales. | |
| 26 | + | - **Workspaces** (`/workspaces`): every workspace, newest first, 50 to a | |
| 7 | 27 | page, with its owners, members, who it is billed to, its terms, this | |
| 8 | 28 | month's usage against its limit, what it was charged and what it cost | |
| 9 | 29 | g1t. Search by workspace, owner, email or enterprise (across the whole | |
| 10 | 30 | list); filter to stopped or warning, comped or custom, or on an | |
| 11 | 31 | enterprise. Billing's figures are fetched for exactly the page shown, so | |
| 12 | 32 | the filters, and the totals over the list, cover that page; the page | |
| 13 | − | says so when there is more than one. A workspace's page shows its members, and under | |
| 14 | − | **Billing** its terms, who it is billed to (move it onto or off an | |
| 33 | + | says so when there is more than one. A workspace's page shows its members; | |
| 34 | + | **Sales** (its stage, the staff member who has it, the next step and its | |
| 35 | + | date, and notes, newest first; `admin_sales`, `admin_set_sales`, | |
| 36 | + | `admin_add_note`); and under **Billing** its limit in words (trust, the | |
| 37 | + | owners' own spend limit or the default, the most they may set, how it | |
| 38 | + | grows), its last six months as a chart, its invoices (`admin_workspace_invoices`, | |
| 39 | + | with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an | |
| 15 | 40 | enterprise), a credit form, a Stripe billing link, its ledger and its | |
| 16 | − | audit log. | |
| 41 | + | audit log. If billing does not answer for sales or invoices, the page | |
| 42 | + | still opens and says so in those sections. | |
| 17 | 43 | - **Enterprises**: customers that pay for several workspaces with one | |
| 18 | 44 | bill, one limit and one set of terms. Each has its workspaces (add or | |
| 19 | 45 | remove them), combined usage, terms, credits, ledger and audit log, and | |
| 30 | 56 | it made before, create a new one and keep its signing secret, which no | |
| 31 | 57 | one sees. Do it once per mode, and again after switching to live keys. | |
| 32 | 58 | ||
| 59 | + | Sales changes are not money, so they have no confirmation step; they are | |
| 60 | + | still POSTs from sudo's own pages, recorded with who made them. | |
| 61 | + | ||
| 33 | 62 | Billing's internal account ids (`ws_<slug>` for a workspace's own, | |
| 34 | 63 | `ent_…` for an enterprise) are never shown as names; an enterprise's id | |
| 35 | 64 | appears only as small "Billing account id" text. Old `/accounts/…` links | |
| 111 | 140 | ||
| 112 | 141 | ```sh | |
| 113 | 142 | npm run typecheck -w @g1t/sudo | |
| 114 | − | npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging | |
| 143 | + | npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals | |
| 115 | 144 | npm run build -w @g1t/sudo | |
| 116 | 145 | ``` | |
| 117 | 146 |
| 1 | + | /** | |
| 2 | + | * Charts drawn as SVG on the server. No script and no inline style: every | |
| 3 | + | * size is an SVG attribute and every colour a class or a token. Hovering a | |
| 4 | + | * month shows its figures (an SVG <title>), and each chart has a table. | |
| 5 | + | */ | |
| 6 | + | import type { MonthFigures } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import { axisDollars, barPath, marginOf, monthBars, monthLong, shares } from "~/lib/chart"; | |
| 9 | + | import { usd } from "~/lib/money"; | |
| 10 | + | ||
| 11 | + | /** The two series: what was charged (lavender) beside what it cost g1t (gray). */ | |
| 12 | + | const CHARGED = "var(--g1t-merged)"; | |
| 13 | + | const COST = "#7a7a84"; | |
| 14 | + | ||
| 15 | + | function Legend() { | |
| 16 | + | return ( | |
| 17 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> | |
| 18 | + | <span className="inline-flex items-center gap-1.5"> | |
| 19 | + | <svg width="10" height="10" aria-hidden="true"> | |
| 20 | + | <rect width="10" height="10" rx="2" fill={CHARGED} /> | |
| 21 | + | </svg> | |
| 22 | + | Charged | |
| 23 | + | </span> | |
| 24 | + | <span className="inline-flex items-center gap-1.5"> | |
| 25 | + | <svg width="10" height="10" aria-hidden="true"> | |
| 26 | + | <rect width="10" height="10" rx="2" fill={COST} /> | |
| 27 | + | </svg> | |
| 28 | + | Cost to g1t | |
| 29 | + | </span> | |
| 30 | + | </div> | |
| 31 | + | ); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | function BarsSvg({ months, width, height, label }: { months: MonthFigures[]; width: number; height: number; label: string }) { | |
| 35 | + | const chart = monthBars(months, { width, height, left: width < 400 ? 40 : 52 }); | |
| 36 | + | const { plot } = chart; | |
| 37 | + | return ( | |
| 38 | + | <svg viewBox={`0 0 ${width} ${height}`} className="block h-auto w-full" role="img" aria-label={label}> | |
| 39 | + | {chart.ticks.map((tick) => ( | |
| 40 | + | <g key={tick.value}> | |
| 41 | + | <line | |
| 42 | + | x1={plot.x} | |
| 43 | + | x2={plot.x + plot.width} | |
| 44 | + | y1={tick.y} | |
| 45 | + | y2={tick.y} | |
| 46 | + | stroke="var(--g1t-line)" | |
| 47 | + | strokeWidth="1" | |
| 48 | + | strokeDasharray={tick.value === 0 ? undefined : "2 4"} | |
| 49 | + | /> | |
| 50 | + | <text x={plot.x - 8} y={tick.y + 3.5} textAnchor="end" fontSize="11" className="fill-faint tabular"> | |
| 51 | + | {axisDollars(tick.value)} | |
| 52 | + | </text> | |
| 53 | + | </g> | |
| 54 | + | ))} | |
| 55 | + | {chart.bars.map((bar, index) => { | |
| 56 | + | const margin = marginOf(bar.figures.chargedMicros, bar.figures.costMicros); | |
| 57 | + | const last = index === chart.bars.length - 1; | |
| 58 | + | return ( | |
| 59 | + | <g key={bar.month}> | |
| 60 | + | <title> | |
| 61 | + | {`${monthLong(bar.month)}: charged ${usd(bar.figures.chargedMicros)}, cost ${usd(bar.figures.costMicros)}, margin ${usd(margin.micros)}${margin.percent != null ? ` (${margin.percent}%)` : ""}`} | |
| 62 | + | </title> | |
| 63 | + | {/* The whole column answers to hover, not just the bars. */} | |
| 64 | + | <rect x={bar.slot.x} y={bar.slot.y} width={bar.slot.width} height={bar.slot.height} fill="transparent" className="hover:fill-raised/40" /> | |
| 65 | + | <path d={barPath(bar.charged)} fill={CHARGED} /> | |
| 66 | + | <path d={barPath(bar.cost)} fill={COST} /> | |
| 67 | + | <text | |
| 68 | + | x={bar.slot.x + bar.slot.width / 2} | |
| 69 | + | y={plot.y + plot.height + 17} | |
| 70 | + | textAnchor="middle" | |
| 71 | + | fontSize="11" | |
| 72 | + | className={last ? "fill-fg-soft" : "fill-faint"} | |
| 73 | + | > | |
| 74 | + | {bar.label} | |
| 75 | + | </text> | |
| 76 | + | </g> | |
| 77 | + | ); | |
| 78 | + | })} | |
| 79 | + | </svg> | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** | |
| 84 | + | * Six months of charged against cost, as grouped bars. Drawn twice, for a | |
| 85 | + | * phone and for wider screens, as type in an SVG scales with it. | |
| 86 | + | */ | |
| 87 | + | export function MonthsChart({ | |
| 88 | + | months, | |
| 89 | + | label = "Charged and cost to g1t, by month", | |
| 90 | + | wide = false, | |
| 91 | + | }: { | |
| 92 | + | months: MonthFigures[]; | |
| 93 | + | label?: string; | |
| 94 | + | /** Spans the page: drawn wider and shorter, so its type stays small. */ | |
| 95 | + | wide?: boolean; | |
| 96 | + | }) { | |
| 97 | + | if (months.length === 0) return <p className="text-sm text-muted">No months to show yet.</p>; | |
| 98 | + | return ( | |
| 99 | + | <div> | |
| 100 | + | <Legend /> | |
| 101 | + | <div className="mt-3 sm:hidden"> | |
| 102 | + | <BarsSvg months={months} width={340} height={190} label={label} /> | |
| 103 | + | </div> | |
| 104 | + | <div className="mt-3 hidden sm:block"> | |
| 105 | + | <BarsSvg months={months} width={wide ? 1080 : 640} height={wide ? 230 : 220} label={label} /> | |
| 106 | + | </div> | |
| 107 | + | <details className="group mt-2"> | |
| 108 | + | <summary className="cursor-pointer list-none text-xs text-faint select-none hover:text-muted [&::-webkit-details-marker]:hidden"> | |
| 109 | + | <span className="group-open:hidden">Show as a table</span> | |
| 110 | + | <span className="hidden group-open:inline">Hide the table</span> | |
| 111 | + | </summary> | |
| 112 | + | <div className="mt-2 overflow-x-auto"> | |
| 113 | + | <table className="w-full min-w-[26rem] text-xs"> | |
| 114 | + | <thead> | |
| 115 | + | <tr className="border-b border-line text-left text-muted"> | |
| 116 | + | <th className="py-1.5 pr-3 font-medium">Month</th> | |
| 117 | + | <th className="py-1.5 pr-3 text-right font-medium">Charged</th> | |
| 118 | + | <th className="py-1.5 pr-3 text-right font-medium">Cost</th> | |
| 119 | + | <th className="py-1.5 pr-3 text-right font-medium">Margin</th> | |
| 120 | + | <th className="py-1.5 text-right font-medium">Paid</th> | |
| 121 | + | </tr> | |
| 122 | + | </thead> | |
| 123 | + | <tbody> | |
| 124 | + | {months.map((month) => { | |
| 125 | + | const margin = marginOf(month.chargedMicros, month.costMicros); | |
| 126 | + | return ( | |
| 127 | + | <tr key={month.month} className="tabular border-b border-line last:border-0"> | |
| 128 | + | <td className="py-1.5 pr-3 text-fg-soft">{monthLong(month.month)}</td> | |
| 129 | + | <td className="py-1.5 pr-3 text-right">{usd(month.chargedMicros)}</td> | |
| 130 | + | <td className="py-1.5 pr-3 text-right text-muted">{usd(month.costMicros)}</td> | |
| 131 | + | <td className={`py-1.5 pr-3 text-right ${margin.micros < 0 ? "text-danger" : "text-fg-soft"}`}> | |
| 132 | + | {usd(margin.micros)} | |
| 133 | + | {margin.percent != null && <span className="text-faint"> {margin.percent}%</span>} | |
| 134 | + | </td> | |
| 135 | + | <td className="py-1.5 text-right text-muted">{usd(month.paidMicros)}</td> | |
| 136 | + | </tr> | |
| 137 | + | ); | |
| 138 | + | })} | |
| 139 | + | </tbody> | |
| 140 | + | </table> | |
| 141 | + | </div> | |
| 142 | + | </details> | |
| 143 | + | </div> | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** A thin bar for a share from 0 to 1, as a row of a breakdown. */ | |
| 148 | + | export function ShareBar({ share, label }: { share: number; label: string }) { | |
| 149 | + | const width = Math.max(0, Math.min(1, share)) * 100; | |
| 150 | + | return ( | |
| 151 | + | <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="block h-1.5 w-full" role="img" aria-label={label}> | |
| 152 | + | <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" /> | |
| 153 | + | {width > 0 && <rect x="0" y="0" width={Math.max(1.5, width)} height="4" rx="2" fill={CHARGED} />} | |
| 154 | + | </svg> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | const KIND_LABEL: Record<string, string> = { | |
| 159 | + | models: "Models", | |
| 160 | + | model: "Models", | |
| 161 | + | sandbox: "Sandboxes", | |
| 162 | + | sandboxes: "Sandboxes", | |
| 163 | + | deployments: "Deployments", | |
| 164 | + | builds: "Builds", | |
| 165 | + | plans: "Plans", | |
| 166 | + | orchestration: "Orchestration", | |
| 167 | + | }; | |
| 168 | + | ||
| 169 | + | export function kindLabel(kind: string): string { | |
| 170 | + | return KIND_LABEL[kind] ?? kind.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /** This month by kind of usage: each kind's charge, cost and margin, largest first. */ | |
| 174 | + | export function KindBreakdown({ kinds }: { kinds: { kind: string; chargedMicros: number; costMicros: number }[] }) { | |
| 175 | + | if (kinds.length === 0) return <p className="text-sm text-muted">Nothing charged yet this month.</p>; | |
| 176 | + | const sorted = [...kinds].sort((a, b) => b.chargedMicros - a.chargedMicros || b.costMicros - a.costMicros); | |
| 177 | + | const total = sorted.reduce((sum, row) => sum + row.chargedMicros, 0); | |
| 178 | + | const bars = shares(sorted.map((row) => row.chargedMicros)); | |
| 179 | + | return ( | |
| 180 | + | <ul className="space-y-3.5"> | |
| 181 | + | {sorted.map((row, index) => { | |
| 182 | + | const margin = marginOf(row.chargedMicros, row.costMicros); | |
| 183 | + | const share = total > 0 ? Math.round((row.chargedMicros / total) * 100) : 0; | |
| 184 | + | return ( | |
| 185 | + | <li key={row.kind}> | |
| 186 | + | <div className="flex items-baseline justify-between gap-3 text-sm"> | |
| 187 | + | <span className="text-fg-soft">{kindLabel(row.kind)}</span> | |
| 188 | + | <span className="tabular text-fg">{usd(row.chargedMicros)}</span> | |
| 189 | + | </div> | |
| 190 | + | <div className="mt-1.5"> | |
| 191 | + | <ShareBar share={bars[index]} label={`${kindLabel(row.kind)}: ${share}% of this month's charges`} /> | |
| 192 | + | </div> | |
| 193 | + | <p className="tabular mt-1 flex justify-between gap-3 text-xs text-faint"> | |
| 194 | + | <span> | |
| 195 | + | {share}% of charges · cost {usd(row.costMicros)} | |
| 196 | + | </span> | |
| 197 | + | <span className={margin.micros < 0 ? "text-danger" : undefined}> | |
| 198 | + | margin {usd(margin.micros)} | |
| 199 | + | {margin.percent != null && ` · ${margin.percent}%`} | |
| 200 | + | </span> | |
| 201 | + | </p> | |
| 202 | + | </li> | |
| 203 | + | ); | |
| 204 | + | })} | |
| 205 | + | </ul> | |
| 206 | + | ); | |
| 207 | + | } |
| 1 | + | /** | |
| 2 | + | * Sales on a workspace's page: where it stands, who on the team has it, | |
| 3 | + | * the next step, and notes; and its invoices from g1t. Plain forms. | |
| 4 | + | */ | |
| 5 | + | import { CalendarClock, ExternalLink, FileDown, MessageSquarePlus, Save, UserRound } from "lucide-react"; | |
| 6 | + | import type { ReactNode } from "react"; | |
| 7 | + | ||
| 8 | + | import type { SalesRecord, WorkspaceInvoice } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import { Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 11 | + | import { usd } from "~/lib/money"; | |
| 12 | + | import type { SectionError } from "~/lib/review"; | |
| 13 | + | import { STAGES, signalMeta, stageMeta } from "~/lib/signals"; | |
| 14 | + | ||
| 15 | + | export function SignalBadge({ kind }: { kind: string }) { | |
| 16 | + | const meta = signalMeta(kind); | |
| 17 | + | return ( | |
| 18 | + | <span title={meta.about || undefined}> | |
| 19 | + | <Badge tone={meta.tone}>{meta.label}</Badge> | |
| 20 | + | </span> | |
| 21 | + | ); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function StageBadge({ stage }: { stage: string | null | undefined }) { | |
| 25 | + | const meta = stageMeta(stage); | |
| 26 | + | return <Badge tone={meta.tone}>{meta.label}</Badge>; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** A staff member, as "you" when it is the one looking. */ | |
| 30 | + | export function StaffName({ email, me }: { email: string | null | undefined; me: string }) { | |
| 31 | + | if (!email) return <span className="text-faint">Unassigned</span>; | |
| 32 | + | const mine = email.toLowerCase() === me.toLowerCase(); | |
| 33 | + | return ( | |
| 34 | + | <span title={email} className="inline-flex min-w-0 items-center gap-1"> | |
| 35 | + | <UserRound size={12} className="shrink-0 text-faint" /> | |
| 36 | + | <span className="truncate font-mono text-xs text-fg-soft">{mine ? "you" : email}</span> | |
| 37 | + | </span> | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** Whether a follow-up day (YYYY-MM-DD) is today or past, UTC. */ | |
| 42 | + | function isDue(day: string | null, today = new Date().toISOString().slice(0, 10)): boolean { | |
| 43 | + | return day != null && day.slice(0, 10) <= today; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | export function SalesSection({ | |
| 47 | + | sales, | |
| 48 | + | unavailable, | |
| 49 | + | me, | |
| 50 | + | pathname, | |
| 51 | + | salesError, | |
| 52 | + | noteError, | |
| 53 | + | done, | |
| 54 | + | }: { | |
| 55 | + | sales: SalesRecord | null; | |
| 56 | + | unavailable: string | null; | |
| 57 | + | me: string; | |
| 58 | + | pathname: string; | |
| 59 | + | salesError: SectionError; | |
| 60 | + | noteError: SectionError; | |
| 61 | + | done: string | null; | |
| 62 | + | }) { | |
| 63 | + | const values = salesError?.values; | |
| 64 | + | const record = sales; | |
| 65 | + | return ( | |
| 66 | + | <Section | |
| 67 | + | id="sales" | |
| 68 | + | title="Sales" | |
| 69 | + | description={ | |
| 70 | + | record?.updatedAt ? ( | |
| 71 | + | <> | |
| 72 | + | Where it stands with g1t, and what the team is doing about it. Updated <When at={record.updatedAt} time />. | |
| 73 | + | </> | |
| 74 | + | ) : ( | |
| 75 | + | "Where it stands with g1t, and what the team is doing about it." | |
| 76 | + | ) | |
| 77 | + | } | |
| 78 | + | actions={record && <StageBadge stage={record.stage} />} | |
| 79 | + | > | |
| 80 | + | {unavailable ? ( | |
| 81 | + | <Notice tone="warn">Billing did not answer for sales records: {unavailable}</Notice> | |
| 82 | + | ) : ( | |
| 83 | + | <div className="grid gap-6 lg:grid-cols-2"> | |
| 84 | + | {/* Stage, owner and next step */} | |
| 85 | + | <form method="post" action={`${pathname}#sales`} className="space-y-4"> | |
| 86 | + | <input type="hidden" name="intent" value="sales" /> | |
| 87 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 88 | + | {salesError && <Notice tone="error">{salesError.error}</Notice>} | |
| 89 | + | {record?.nextStep && ( | |
| 90 | + | <div | |
| 91 | + | className={`flex gap-2.5 rounded-md border px-3 py-2.5 text-sm ${ | |
| 92 | + | isDue(record.nextAt) ? "border-warn/40 bg-warn/8" : "border-line bg-bg" | |
| 93 | + | }`} | |
| 94 | + | > | |
| 95 | + | <CalendarClock size={16} className={`mt-0.5 shrink-0 ${isDue(record.nextAt) ? "text-warn" : "text-faint"}`} /> | |
| 96 | + | <div className="min-w-0"> | |
| 97 | + | <p className="text-fg-soft">{record.nextStep}</p> | |
| 98 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 99 | + | {record.nextAt ? ( | |
| 100 | + | <> | |
| 101 | + | {isDue(record.nextAt) ? "Due " : "On "} | |
| 102 | + | <When at={`${record.nextAt.slice(0, 10)}T00:00:00Z`} /> | |
| 103 | + | </> | |
| 104 | + | ) : ( | |
| 105 | + | "No date" | |
| 106 | + | )} | |
| 107 | + | {" · "} | |
| 108 | + | <StaffName email={record.owner} me={me} /> | |
| 109 | + | </p> | |
| 110 | + | </div> | |
| 111 | + | </div> | |
| 112 | + | )} | |
| 113 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 114 | + | <Field label="Stage"> | |
| 115 | + | <Select name="stage" defaultValue={values?.stage ?? record?.stage ?? "none"}> | |
| 116 | + | {STAGES.map((entry) => ( | |
| 117 | + | <option key={entry.stage} value={entry.stage}> | |
| 118 | + | {entry.label} | |
| 119 | + | </option> | |
| 120 | + | ))} | |
| 121 | + | </Select> | |
| 122 | + | </Field> | |
| 123 | + | <Field label="Owner" hint="Who on the team has it. Blank: nobody."> | |
| 124 | + | <Input name="owner" type="email" placeholder="you@g1t.sh" defaultValue={values?.owner ?? record?.owner ?? me} className="font-mono text-xs" /> | |
| 125 | + | </Field> | |
| 126 | + | </div> | |
| 127 | + | <div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_10rem]"> | |
| 128 | + | <Field label="Next step"> | |
| 129 | + | <Input name="nextStep" maxLength={200} placeholder="e.g. Call the owner about an enterprise plan" defaultValue={values?.nextStep ?? record?.nextStep ?? ""} /> | |
| 130 | + | </Field> | |
| 131 | + | <Field label="By" hint="UTC."> | |
| 132 | + | <Input type="date" name="nextAt" defaultValue={values?.nextAt ?? record?.nextAt?.slice(0, 10) ?? ""} /> | |
| 133 | + | </Field> | |
| 134 | + | </div> | |
| 135 | + | <div className="flex justify-end"> | |
| 136 | + | <Button type="submit" variant="quiet"> | |
| 137 | + | <Save size={14} /> | |
| 138 | + | Save | |
| 139 | + | </Button> | |
| 140 | + | </div> | |
| 141 | + | </form> | |
| 142 | + | ||
| 143 | + | {/* Notes */} | |
| 144 | + | <div> | |
| 145 | + | <form method="post" action={`${pathname}#notes`} id="notes" className="scroll-mt-20 space-y-2"> | |
| 146 | + | <input type="hidden" name="intent" value="note" /> | |
| 147 | + | {noteError && <Notice tone="error">{noteError.error}</Notice>} | |
| 148 | + | <Field label="Add a note" hint="Calls, emails, what they asked for. Staff only; signed with your email."> | |
| 149 | + | <Textarea name="text" rows={3} required maxLength={2000} defaultValue={noteError?.values?.text ?? ""} placeholder="e.g. Spoke to the owner: they want annual billing." /> | |
| 150 | + | </Field> | |
| 151 | + | <div className="flex justify-end"> | |
| 152 | + | <Button type="submit" variant="quiet"> | |
| 153 | + | <MessageSquarePlus size={14} /> | |
| 154 | + | Add note | |
| 155 | + | </Button> | |
| 156 | + | </div> | |
| 157 | + | </form> | |
| 158 | + | <NotesList notes={record?.notes ?? []} me={me} /> | |
| 159 | + | </div> | |
| 160 | + | </div> | |
| 161 | + | )} | |
| 162 | + | </Section> | |
| 163 | + | ); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | function NotesList({ notes, me }: { notes: SalesRecord["notes"]; me: string }) { | |
| 167 | + | if (notes.length === 0) return <p className="mt-4 text-sm text-faint">No notes yet.</p>; | |
| 168 | + | const sorted = [...notes].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); | |
| 169 | + | return ( | |
| 170 | + | <ol className="mt-4 space-y-3"> | |
| 171 | + | {sorted.map((note) => ( | |
| 172 | + | <li key={note.id} className="border-l-2 border-merged/40 pl-3"> | |
| 173 | + | <p className="text-sm break-words whitespace-pre-line text-fg-soft">{note.text}</p> | |
| 174 | + | <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-faint"> | |
| 175 | + | <StaffName email={note.by} me={me} /> | |
| 176 | + | <When at={note.createdAt} time /> | |
| 177 | + | </p> | |
| 178 | + | </li> | |
| 179 | + | ))} | |
| 180 | + | </ol> | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // --- Invoices ----------------------------------------------------------------- | |
| 185 | + | ||
| 186 | + | const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = { | |
| 187 | + | open: { label: "Open", tone: "info" }, | |
| 188 | + | paid: { label: "Paid", tone: "mint" }, | |
| 189 | + | failed: { label: "Failed", tone: "danger" }, | |
| 190 | + | overdue: { label: "Overdue", tone: "danger" }, | |
| 191 | + | void: { label: "Void", tone: "plain" }, | |
| 192 | + | }; | |
| 193 | + | ||
| 194 | + | export function InvoiceStatus({ status }: { status: string }) { | |
| 195 | + | const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const }; | |
| 196 | + | return <Badge tone={tone}>{label}</Badge>; | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /** A link to a page on Stripe; https only. */ | |
| 200 | + | function StripeLink({ url, children }: { url: string | null; children: ReactNode }) { | |
| 201 | + | if (!url || !url.startsWith("https://")) return null; | |
| 202 | + | return ( | |
| 203 | + | <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline"> | |
| 204 | + | {children} | |
| 205 | + | </a> | |
| 206 | + | ); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit" }; | |
| 210 | + | ||
| 211 | + | export function WorkspaceInvoicesSection({ invoices, unavailable }: { invoices: WorkspaceInvoice[]; unavailable: string | null }) { | |
| 212 | + | const open = invoices.filter((invoice) => invoice.status === "open" || invoice.status === "failed"); | |
| 213 | + | const owed = open.reduce((sum, invoice) => sum + invoice.amountMicros, 0); | |
| 214 | + | return ( | |
| 215 | + | <Section | |
| 216 | + | id="invoices" | |
| 217 | + | title="Invoices" | |
| 218 | + | description={ | |
| 219 | + | invoices.length > 0 && open.length > 0 ? ( | |
| 220 | + | <> | |
| 221 | + | {open.length} not paid, {usd(owed)} in all. Monthly, and each time it is charged near its limit. | |
| 222 | + | </> | |
| 223 | + | ) : ( | |
| 224 | + | "Monthly, and each time it is charged near its limit. Charged to the card on file." | |
| 225 | + | ) | |
| 226 | + | } | |
| 227 | + | > | |
| 228 | + | {unavailable ? ( | |
| 229 | + | <Notice tone="warn">Billing did not answer for invoices: {unavailable}</Notice> | |
| 230 | + | ) : invoices.length === 0 ? ( | |
| 231 | + | <EmptyState title="No invoices yet" /> | |
| 232 | + | ) : ( | |
| 233 | + | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 234 | + | <table className="w-full min-w-xl text-sm"> | |
| 235 | + | <thead> | |
| 236 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 237 | + | <th className="px-4 py-2 font-medium sm:pl-5">Period</th> | |
| 238 | + | <th className="px-4 py-2 font-medium">Status</th> | |
| 239 | + | <th className="px-4 py-2 font-medium">Lines</th> | |
| 240 | + | <th className="px-4 py-2 text-right font-medium">Amount</th> | |
| 241 | + | <th className="px-4 py-2 font-medium sm:pr-5">On Stripe</th> | |
| 242 | + | </tr> | |
| 243 | + | </thead> | |
| 244 | + | <tbody> | |
| 245 | + | {invoices.map((invoice) => ( | |
| 246 | + | <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0"> | |
| 247 | + | <td className="px-4 py-2.5 sm:pl-5"> | |
| 248 | + | <p className="text-fg-soft">{invoice.period}</p> | |
| 249 | + | <p className="text-xs text-faint"> | |
| 250 | + | {REASON[invoice.reason] ?? invoice.reason} · <When at={invoice.createdAt} /> | |
| 251 | + | </p> | |
| 252 | + | </td> | |
| 253 | + | <td className="px-4 py-2.5"> | |
| 254 | + | <InvoiceStatus status={invoice.status} /> | |
| 255 | + | </td> | |
| 256 | + | <td className="px-4 py-2.5"> | |
| 257 | + | {invoice.lines.length === 0 ? ( | |
| 258 | + | <span className="text-xs text-faint">—</span> | |
| 259 | + | ) : ( | |
| 260 | + | <details className="group"> | |
| 261 | + | <summary className="cursor-pointer list-none text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden"> | |
| 262 | + | {invoice.lines.length} line{invoice.lines.length === 1 ? "" : "s"} | |
| 263 | + | <span className="text-faint group-open:hidden"> · show</span> | |
| 264 | + | </summary> | |
| 265 | + | <ul className="mt-1.5 space-y-0.5 text-xs"> | |
| 266 | + | {invoice.lines.map((line, index) => ( | |
| 267 | + | <li key={index} className="tabular flex justify-between gap-3"> | |
| 268 | + | <span className="text-fg-soft">{line.description}</span> | |
| 269 | + | <span className="text-faint">{usd(line.amountMicros)}</span> | |
| 270 | + | </li> | |
| 271 | + | ))} | |
| 272 | + | </ul> | |
| 273 | + | </details> | |
| 274 | + | )} | |
| 275 | + | </td> | |
| 276 | + | <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td> | |
| 277 | + | <td className="px-4 py-2.5 text-xs sm:pr-5"> | |
| 278 | + | <span className="flex flex-wrap gap-x-3 gap-y-1"> | |
| 279 | + | <StripeLink url={invoice.hostedUrl}> | |
| 280 | + | Page | |
| 281 | + | <ExternalLink size={12} /> | |
| 282 | + | </StripeLink> | |
| 283 | + | <StripeLink url={invoice.pdfUrl}> | |
| 284 | + | ||
| 285 | + | <FileDown size={12} /> | |
| 286 | + | </StripeLink> | |
| 287 | + | {!invoice.hostedUrl && !invoice.pdfUrl && <span className="text-faint">—</span>} | |
| 288 | + | </span> | |
| 289 | + | </td> | |
| 290 | + | </tr> | |
| 291 | + | ))} | |
| 292 | + | </tbody> | |
| 293 | + | </table> | |
| 294 | + | </div> | |
| 295 | + | )} | |
| 296 | + | </Section> | |
| 297 | + | ); | |
| 298 | + | } |
| 1 | + | /** | |
| 2 | + | * sudo's frame. On wide screens, a sidebar: Overview and Reach out as | |
| 3 | + | * links, then sections that fold open to their pages, as GitLab and | |
| 4 | + | * Vercel do theirs (apps/web's project sidebar is the same pattern). On a | |
| 5 | + | * phone, a top bar whose menu holds the same. sudo ships no JavaScript, | |
| 6 | + | * so every fold is a <details>, drawn open on the server for the section | |
| 7 | + | * holding the current page. All of it comes from lib/nav.ts. | |
| 8 | + | */ | |
| 9 | + | import { | |
| 10 | + | Bot, | |
| 11 | + | Building2, | |
| 12 | + | Boxes, | |
| 13 | + | ChevronRight, | |
| 14 | + | CircleDollarSign, | |
| 15 | + | Cloud, | |
| 16 | + | CreditCard, | |
| 17 | + | Eye, | |
| 18 | + | FileText, | |
| 19 | + | Gauge, | |
| 20 | + | HandCoins, | |
| 21 | + | Inbox, | |
| 22 | + | LayoutDashboard, | |
| 23 | + | LifeBuoy, | |
| 24 | + | type LucideIcon, | |
| 25 | + | Megaphone, | |
| 26 | + | Menu, | |
| 27 | + | ScrollText, | |
| 28 | + | Server, | |
| 29 | + | ShieldAlert, | |
| 30 | + | ShieldCheck, | |
| 31 | + | Tags, | |
| 32 | + | UserCog, | |
| 33 | + | Users, | |
| 34 | + | UsersRound, | |
| 35 | + | X, | |
| 36 | + | Zap, | |
| 37 | + | } from "lucide-react"; | |
| 38 | + | import { Link, useLocation } from "react-router"; | |
| 39 | + | ||
| 40 | + | import { Logo } from "~/components/logo"; | |
| 41 | + | import { NAV, type NavGroup, type NavIcon, type NavItem, holdsCurrent, isCurrent } from "~/lib/nav"; | |
| 42 | + | ||
| 43 | + | const ICONS: Record<NavIcon, LucideIcon> = { | |
| 44 | + | overview: LayoutDashboard, | |
| 45 | + | workspaces: Boxes, | |
| 46 | + | enterprises: Building2, | |
| 47 | + | "reach-out": Zap, | |
| 48 | + | people: Users, | |
| 49 | + | invoices: FileText, | |
| 50 | + | prices: Tags, | |
| 51 | + | credits: HandCoins, | |
| 52 | + | usage: Gauge, | |
| 53 | + | stripe: CreditCard, | |
| 54 | + | costs: Cloud, | |
| 55 | + | agents: Bot, | |
| 56 | + | abuse: ShieldAlert, | |
| 57 | + | inbox: Inbox, | |
| 58 | + | "view-as": Eye, | |
| 59 | + | announcements: Megaphone, | |
| 60 | + | staff: UserCog, | |
| 61 | + | audit: ScrollText, | |
| 62 | + | customers: UsersRound, | |
| 63 | + | revenue: CircleDollarSign, | |
| 64 | + | platform: Server, | |
| 65 | + | support: LifeBuoy, | |
| 66 | + | team: UserCog, | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | /** A page's or section's icon, as the sidebar shows it. */ | |
| 70 | + | export function NavGlyph({ icon, size = 15, className }: { icon: NavIcon; size?: number; className?: string }) { | |
| 71 | + | const Icon = ICONS[icon]; | |
| 72 | + | return <Icon size={size} className={className} />; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | function SoonPill() { | |
| 76 | + | return ( | |
| 77 | + | <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 78 | + | Soon | |
| 79 | + | </span> | |
| 80 | + | ); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** A top-level link, with its icon. */ | |
| 84 | + | function TopLink({ item, pathname }: { item: NavItem; pathname: string }) { | |
| 85 | + | const current = isCurrent(item, pathname); | |
| 86 | + | return ( | |
| 87 | + | <Link | |
| 88 | + | to={item.to} | |
| 89 | + | title={item.about} | |
| 90 | + | aria-current={current ? "page" : undefined} | |
| 91 | + | className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 92 | + | current ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 93 | + | }`} | |
| 94 | + | > | |
| 95 | + | <NavGlyph icon={item.icon} className={`shrink-0 ${current ? "text-merged" : "text-faint group-hover:text-muted"}`} /> | |
| 96 | + | <span className="min-w-0 grow truncate">{item.label}</span> | |
| 97 | + | {item.soon && <SoonPill />} | |
| 98 | + | </Link> | |
| 99 | + | ); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /** A page inside a section: indented, no icon, the current one marked in lavender. */ | |
| 103 | + | function SubLink({ item, pathname }: { item: NavItem; pathname: string }) { | |
| 104 | + | const current = isCurrent(item, pathname); | |
| 105 | + | return ( | |
| 106 | + | <Link | |
| 107 | + | to={item.to} | |
| 108 | + | title={item.about} | |
| 109 | + | aria-current={current ? "page" : undefined} | |
| 110 | + | className={`relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${ | |
| 111 | + | current | |
| 112 | + | ? "bg-raised font-medium text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-merged" | |
| 113 | + | : item.soon | |
| 114 | + | ? "text-faint hover:bg-raised/60 hover:text-muted" | |
| 115 | + | : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 116 | + | }`} | |
| 117 | + | > | |
| 118 | + | <span className="min-w-0 grow truncate">{item.label}</span> | |
| 119 | + | {item.soon && <SoonPill />} | |
| 120 | + | </Link> | |
| 121 | + | ); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** A section: one row that folds open to its pages. Open when it holds the current page. */ | |
| 125 | + | function Section({ group, pathname }: { group: NavGroup & { title: string }; pathname: string }) { | |
| 126 | + | const current = holdsCurrent(group, pathname); | |
| 127 | + | const allSoon = group.items.every((item) => item.soon); | |
| 128 | + | return ( | |
| 129 | + | <details open={current} className="group/section"> | |
| 130 | + | <summary | |
| 131 | + | className={`group flex h-8 cursor-pointer list-none items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors select-none hover:bg-raised/60 [&::-webkit-details-marker]:hidden ${ | |
| 132 | + | current ? "text-fg" : allSoon ? "text-faint hover:text-muted" : "text-muted hover:text-fg" | |
| 133 | + | }`} | |
| 134 | + | > | |
| 135 | + | {group.icon && <NavGlyph icon={group.icon} className={`shrink-0 ${current ? "text-muted" : "text-faint group-hover:text-muted"}`} />} | |
| 136 | + | <span className={`min-w-0 grow truncate ${current ? "font-medium" : ""}`}>{group.title}</span> | |
| 137 | + | {allSoon && ( | |
| 138 | + | <span className="group-open/section:hidden"> | |
| 139 | + | <SoonPill /> | |
| 140 | + | </span> | |
| 141 | + | )} | |
| 142 | + | <ChevronRight size={14} aria-hidden="true" className="shrink-0 text-faint transition-transform duration-150 group-open/section:rotate-90" /> | |
| 143 | + | </summary> | |
| 144 | + | <ul className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line"> | |
| 145 | + | {group.items.map((item) => ( | |
| 146 | + | <li key={item.to}> | |
| 147 | + | <SubLink item={item} pathname={pathname} /> | |
| 148 | + | </li> | |
| 149 | + | ))} | |
| 150 | + | </ul> | |
| 151 | + | </details> | |
| 152 | + | ); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | function NavTree({ pathname }: { pathname: string }) { | |
| 156 | + | return ( | |
| 157 | + | <div className="space-y-px"> | |
| 158 | + | {NAV.map((group, index) => | |
| 159 | + | group.title ? ( | |
| 160 | + | <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} /> | |
| 161 | + | ) : ( | |
| 162 | + | <ul key={index} className={`space-y-px ${index === 0 ? "" : "pt-2"} ${index < NAV.length - 1 ? "pb-2" : ""}`}> | |
| 163 | + | {group.items.map((item) => ( | |
| 164 | + | <li key={item.to}> | |
| 165 | + | <TopLink item={item} pathname={pathname} /> | |
| 166 | + | </li> | |
| 167 | + | ))} | |
| 168 | + | </ul> | |
| 169 | + | ), | |
| 170 | + | )} | |
| 171 | + | </div> | |
| 172 | + | ); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /** Who is signed in: a panel of its own at the foot of the menu. */ | |
| 176 | + | function SignedIn({ email }: { email: string | null | undefined }) { | |
| 177 | + | return ( | |
| 178 | + | <div className="border-t border-line bg-raised/40 px-4 py-3"> | |
| 179 | + | {email && ( | |
| 180 | + | <p title="Signed in through Cloudflare Access" className="flex items-center gap-2 text-[0.8125rem]"> | |
| 181 | + | <ShieldCheck size={15} className="shrink-0 text-merged" /> | |
| 182 | + | <span className="min-w-0 truncate font-mono text-xs text-fg-soft">{email}</span> | |
| 183 | + | </p> | |
| 184 | + | )} | |
| 185 | + | <p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">g1t staff only. Every change is recorded with who made it.</p> | |
| 186 | + | </div> | |
| 187 | + | ); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** The sidebar, from `lg` up. */ | |
| 191 | + | export function Sidebar({ email }: { email: string | null | undefined }) { | |
| 192 | + | const { pathname } = useLocation(); | |
| 193 | + | return ( | |
| 194 | + | <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex"> | |
| 195 | + | <div className="flex h-14 shrink-0 items-center px-4"> | |
| 196 | + | <Link to="/" aria-label="sudo overview"> | |
| 197 | + | <Logo /> | |
| 198 | + | </Link> | |
| 199 | + | </div> | |
| 200 | + | <nav aria-label="sudo" className="grow overflow-y-auto px-2 pt-2 pb-6"> | |
| 201 | + | <NavTree pathname={pathname} /> | |
| 202 | + | </nav> | |
| 203 | + | <div className="shrink-0"> | |
| 204 | + | <SignedIn email={email} /> | |
| 205 | + | </div> | |
| 206 | + | </aside> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /** The top bar and its menu, below `lg`. */ | |
| 211 | + | export function MobileBar({ email }: { email: string | null | undefined }) { | |
| 212 | + | const { pathname } = useLocation(); | |
| 213 | + | return ( | |
| 214 | + | <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden"> | |
| 215 | + | <div className="flex h-14 items-center justify-between gap-2 px-4"> | |
| 216 | + | <Link to="/" aria-label="sudo overview"> | |
| 217 | + | <Logo /> | |
| 218 | + | </Link> | |
| 219 | + | {/* A details element: it opens and closes with no script. A link | |
| 220 | + | loads a new page, which arrives with the menu closed. */} | |
| 221 | + | <details className="group/menu"> | |
| 222 | + | <summary | |
| 223 | + | aria-label="Menu" | |
| 224 | + | className="flex h-9 cursor-pointer list-none items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted transition-colors select-none hover:border-line-strong hover:text-fg group-open/menu:border-line-strong group-open/menu:text-fg [&::-webkit-details-marker]:hidden" | |
| 225 | + | > | |
| 226 | + | <Menu size={16} className="group-open/menu:hidden" /> | |
| 227 | + | <X size={16} className="hidden group-open/menu:block" /> | |
| 228 | + | Menu | |
| 229 | + | </summary> | |
| 230 | + | <div className="absolute inset-x-0 top-14 max-h-[calc(100dvh-3.5rem)] overflow-y-auto border-b border-line bg-bg shadow-2xl shadow-black/50"> | |
| 231 | + | <nav aria-label="sudo" className="px-2 pt-3 pb-4"> | |
| 232 | + | <NavTree pathname={pathname} /> | |
| 233 | + | </nav> | |
| 234 | + | <SignedIn email={email} /> | |
| 235 | + | </div> | |
| 236 | + | </details> | |
| 237 | + | </div> | |
| 238 | + | </header> | |
| 239 | + | ); | |
| 240 | + | } |
| 156 | 156 | return <Badge>Standard</Badge>; | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | − | const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info" }> = { | |
| 160 | − | new: { label: "New", tone: "plain" }, | |
| 161 | − | paid: { label: "Paid", tone: "info" }, | |
| 162 | − | reviewed: { label: "Reviewed", tone: "mint" }, | |
| 159 | + | const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = { | |
| 160 | + | new: { label: "New", tone: "plain", about: "New: has not paid g1t yet, so the limit is small." }, | |
| 161 | + | paid: { label: "Paid", tone: "info", about: "Paid: has paid g1t, and the limit grows with what it has paid." }, | |
| 162 | + | established: { | |
| 163 | + | label: "Established", | |
| 164 | + | tone: "mint", | |
| 165 | + | about: "Established: the limit follows their monthly spend, up to $10,000.", | |
| 166 | + | }, | |
| 167 | + | reviewed: { label: "Reviewed", tone: "mint", about: "Reviewed: g1t set the limit by hand, after talking to them." }, | |
| 163 | 168 | // Comped accounts: g1t covers their usage, with no ceiling. | |
| 164 | − | internal: { label: "Comped", tone: "lavender" }, | |
| 169 | + | internal: { label: "Comped", tone: "lavender", about: "Comped: g1t covers their usage, with no limit." }, | |
| 165 | 170 | }; | |
| 166 | 171 | ||
| 167 | 172 | export function TrustBadge({ trust }: { trust: Trust }) { | |
| 168 | − | const { label, tone } = TRUST[trust] ?? { label: trust, tone: "plain" }; | |
| 169 | − | return <Badge tone={tone}>{label}</Badge>; | |
| 173 | + | const { label, tone, about } = TRUST[trust] ?? { label: trust, tone: "plain", about: "" }; | |
| 174 | + | return ( | |
| 175 | + | <span title={about || undefined}> | |
| 176 | + | <Badge tone={tone}>{label}</Badge> | |
| 177 | + | </span> | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /** What a trust level means for the limit, in a sentence. */ | |
| 182 | + | export function trustAbout(trust: Trust): string { | |
| 183 | + | return TRUST[trust]?.about ?? ""; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /** A page's title, a line about it, and anything to its right. */ | |
| 187 | + | export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) { | |
| 188 | + | return ( | |
| 189 | + | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 190 | + | <div className="min-w-0"> | |
| 191 | + | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> | |
| 192 | + | {description && <p className="mt-1 text-sm text-muted">{description}</p>} | |
| 193 | + | </div> | |
| 194 | + | {actions} | |
| 195 | + | </div> | |
| 196 | + | ); | |
| 170 | 197 | } | |
| 171 | 198 | ||
| 172 | 199 | const STATE = { |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { axisDollars, barPath, change, marginOf, monthBars, monthLong, monthShort, niceCeiling, shares, ticks } from "./chart.ts"; | |
| 5 | + | ||
| 6 | + | const M = 1_000_000; | |
| 7 | + | const month = (m: string, charged: number, cost: number) => ({ month: m, chargedMicros: charged, costMicros: cost, paidMicros: 0 }); | |
| 8 | + | ||
| 9 | + | test("niceCeiling rounds up to 1, 2, 2.5 or 5 times a power of ten", () => { | |
| 10 | + | assert.equal(niceCeiling(0), 1 * M); | |
| 11 | + | assert.equal(niceCeiling(1 * M), 1 * M); | |
| 12 | + | assert.equal(niceCeiling(1.2 * M), 2 * M); | |
| 13 | + | assert.equal(niceCeiling(2.2 * M), 2.5 * M); | |
| 14 | + | assert.equal(niceCeiling(3 * M), 5 * M); | |
| 15 | + | assert.equal(niceCeiling(7 * M), 10 * M); | |
| 16 | + | assert.equal(niceCeiling(420 * M), 500 * M); | |
| 17 | + | assert.equal(niceCeiling(10_001 * M), 20_000 * M); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("ticks run from zero to the top", () => { | |
| 21 | + | assert.deepEqual(ticks(100, 4), [0, 25, 50, 75, 100]); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("months are named", () => { | |
| 25 | + | assert.equal(monthShort("2026-10"), "Oct"); | |
| 26 | + | assert.equal(monthShort("2026-01"), "Jan"); | |
| 27 | + | assert.equal(monthShort("soon"), "soon"); | |
| 28 | + | assert.equal(monthLong("2026-10"), "October 2026"); | |
| 29 | + | assert.equal(monthLong("2026-13"), "2026-13"); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("margin and change", () => { | |
| 33 | + | assert.deepEqual(marginOf(100 * M, 80 * M), { micros: 20 * M, percent: 20 }); | |
| 34 | + | assert.deepEqual(marginOf(0, 5 * M), { micros: -5 * M, percent: null }); | |
| 35 | + | assert.equal(change(100, 150), 50); | |
| 36 | + | assert.equal(change(0, 150), null); | |
| 37 | + | assert.equal(change(200, 100), -50); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("monthBars scales both series to one axis from zero", () => { | |
| 41 | + | const chart = monthBars([month("2026-09", 400 * M, 300 * M), month("2026-10", 100 * M, 0)], { width: 300, height: 120, left: 40, right: 0, top: 0, bottom: 20 }); | |
| 42 | + | assert.equal(chart.top, 500 * M); | |
| 43 | + | assert.deepEqual(chart.plot, { x: 40, y: 0, width: 260, height: 100 }); | |
| 44 | + | const [sep, oct] = chart.bars; | |
| 45 | + | assert.equal(sep.label, "Sep"); | |
| 46 | + | // 400 of 500 is 80 of the plot's 100. | |
| 47 | + | assert.equal(Math.round(sep.charged.height), 80); | |
| 48 | + | assert.equal(Math.round(sep.charged.y), 20); | |
| 49 | + | assert.equal(Math.round(sep.cost.height), 60); | |
| 50 | + | // Bars stand on the baseline. | |
| 51 | + | assert.equal(sep.charged.y + sep.charged.height, 100); | |
| 52 | + | assert.equal(oct.cost.height, 0); | |
| 53 | + | // Charged on the left, cost on the right, 2px apart, inside the slot. | |
| 54 | + | assert.equal(Math.round((sep.cost.x - (sep.charged.x + sep.charged.width)) * 100) / 100, 2); | |
| 55 | + | assert.ok(sep.charged.x >= sep.slot.x && sep.cost.x + sep.cost.width <= sep.slot.x + sep.slot.width); | |
| 56 | + | assert.equal(chart.ticks[0].y, 100); | |
| 57 | + | assert.equal(chart.ticks.at(-1)?.y, 0); | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("a tiny month still shows, and an empty chart has an axis", () => { | |
| 61 | + | const chart = monthBars([month("2026-09", 1000 * M, 0), month("2026-10", 1, 0)]); | |
| 62 | + | assert.ok(chart.bars[1].charged.height >= 1.5); | |
| 63 | + | const empty = monthBars([month("2026-10", 0, 0)]); | |
| 64 | + | assert.equal(empty.top, 1 * M); | |
| 65 | + | assert.equal(empty.bars[0].charged.height, 0); | |
| 66 | + | assert.equal(monthBars([]).bars.length, 0); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("negative figures draw nothing below the baseline", () => { | |
| 70 | + | const chart = monthBars([month("2026-10", -5 * M, 2 * M)]); | |
| 71 | + | assert.equal(chart.bars[0].charged.height, 0); | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | test("barPath rounds the top and keeps the foot square", () => { | |
| 75 | + | assert.equal(barPath({ x: 0, y: 0, width: 10, height: 20 }), "M0 20 V4 Q0 0 4 0 H6 Q10 0 10 4 V20 Z"); | |
| 76 | + | assert.equal(barPath({ x: 0, y: 0, width: 10, height: 0 }), ""); | |
| 77 | + | // Never rounder than the bar is tall. | |
| 78 | + | assert.equal(barPath({ x: 0, y: 9, width: 10, height: 1 }), "M0 10 V10 Q0 9 1 9 H9 Q10 9 10 10 V10 Z"); | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | test("axis labels are short", () => { | |
| 82 | + | assert.equal(axisDollars(0), "$0"); | |
| 83 | + | assert.equal(axisDollars(250 * M), "$250"); | |
| 84 | + | assert.equal(axisDollars(1500 * M), "$1.5k"); | |
| 85 | + | assert.equal(axisDollars(20_000 * M), "$20k"); | |
| 86 | + | assert.equal(axisDollars(1_200_000 * M), "$1.2M"); | |
| 87 | + | assert.equal(axisDollars(0.25 * M), "$0.25"); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test("shares are against the largest", () => { | |
| 91 | + | assert.deepEqual(shares([50, 100, 0, -5]), [0.5, 1, 0, 0]); | |
| 92 | + | assert.deepEqual(shares([0, 0]), [0, 0]); | |
| 93 | + | }); |
| 1 | + | /** | |
| 2 | + | * The arithmetic behind sudo's charts, kept apart from the SVG so it can | |
| 3 | + | * be tested under Node. Money is in micros throughout. | |
| 4 | + | */ | |
| 5 | + | import type { MonthFigures } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; | |
| 8 | + | const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; | |
| 9 | + | ||
| 10 | + | /** `2026-10` → `Oct`; anything else as it came. */ | |
| 11 | + | export function monthShort(month: string): string { | |
| 12 | + | const match = /^(\d{4})-(\d{2})$/.exec(month); | |
| 13 | + | const index = match ? Number(match[2]) - 1 : -1; | |
| 14 | + | return MONTHS[index] ?? month; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /** `2026-10` → `October 2026`. */ | |
| 18 | + | export function monthLong(month: string): string { | |
| 19 | + | const match = /^(\d{4})-(\d{2})$/.exec(month); | |
| 20 | + | const index = match ? Number(match[2]) - 1 : -1; | |
| 21 | + | return match && MONTHS_LONG[index] ? `${MONTHS_LONG[index]} ${match[1]}` : month; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * The top of an axis for values up to `max`: the next 1, 2, 2.5 or 5 | |
| 26 | + | * times a power of ten, so its ticks are round numbers. A dollar when | |
| 27 | + | * there is nothing to show, so an empty chart still has an axis. | |
| 28 | + | */ | |
| 29 | + | export function niceCeiling(max: number, floor = 1_000_000): number { | |
| 30 | + | const value = Math.max(max, floor); | |
| 31 | + | const power = 10 ** Math.floor(Math.log10(value)); | |
| 32 | + | for (const step of [1, 2, 2.5, 5, 10]) { | |
| 33 | + | if (step * power >= value) return step * power; | |
| 34 | + | } | |
| 35 | + | return 10 * power; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** Evenly spaced ticks from zero to `top`, inclusive. */ | |
| 39 | + | export function ticks(top: number, count = 4): number[] { | |
| 40 | + | return Array.from({ length: count + 1 }, (_, index) => (top / count) * index); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /** A margin and its share of what was charged; no share when nothing was. */ | |
| 44 | + | export function marginOf(chargedMicros: number, costMicros: number): { micros: number; percent: number | null } { | |
| 45 | + | const micros = chargedMicros - costMicros; | |
| 46 | + | return { micros, percent: chargedMicros > 0 ? Math.round((micros / chargedMicros) * 100) : null }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** The change from one figure to the next, as a whole percent; none from zero. */ | |
| 50 | + | export function change(before: number, after: number): number | null { | |
| 51 | + | if (before <= 0) return null; | |
| 52 | + | return Math.round(((after - before) / before) * 100); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | export type Rect = { x: number; y: number; width: number; height: number }; | |
| 56 | + | ||
| 57 | + | export type MonthBars = { | |
| 58 | + | month: string; | |
| 59 | + | label: string; | |
| 60 | + | /** Where the month's slot is, for its label and hover target. */ | |
| 61 | + | slot: Rect; | |
| 62 | + | charged: Rect; | |
| 63 | + | cost: Rect; | |
| 64 | + | figures: MonthFigures; | |
| 65 | + | }; | |
| 66 | + | ||
| 67 | + | export type BarChart = { | |
| 68 | + | width: number; | |
| 69 | + | height: number; | |
| 70 | + | /** The plot area, inside the axis labels. */ | |
| 71 | + | plot: Rect; | |
| 72 | + | top: number; | |
| 73 | + | /** Each tick's value and its y. */ | |
| 74 | + | ticks: { value: number; y: number }[]; | |
| 75 | + | bars: MonthBars[]; | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | /** | |
| 79 | + | * Two bars a month, charged beside cost, scaled to one axis from zero. | |
| 80 | + | * Bars keep a 2px gap between them and a minimum visible height when | |
| 81 | + | * their value is above zero, so a small month still shows. | |
| 82 | + | */ | |
| 83 | + | export function monthBars( | |
| 84 | + | months: MonthFigures[], | |
| 85 | + | { width = 600, height = 220, left = 52, right = 8, top: padTop = 10, bottom = 26, count = 4 } = {}, | |
| 86 | + | ): BarChart { | |
| 87 | + | const plot = { x: left, y: padTop, width: Math.max(1, width - left - right), height: Math.max(1, height - padTop - bottom) }; | |
| 88 | + | const top = niceCeiling(Math.max(0, ...months.flatMap((month) => [month.chargedMicros, month.costMicros]))); | |
| 89 | + | const baseline = plot.y + plot.height; | |
| 90 | + | const scale = (value: number) => { | |
| 91 | + | const clamped = Math.max(0, value); | |
| 92 | + | const h = (clamped / top) * plot.height; | |
| 93 | + | return clamped > 0 ? Math.max(1.5, h) : 0; | |
| 94 | + | }; | |
| 95 | + | const slotWidth = months.length > 0 ? plot.width / months.length : plot.width; | |
| 96 | + | const gap = 2; | |
| 97 | + | const barWidth = Math.max(2, Math.min(28, slotWidth * 0.3)); | |
| 98 | + | const bars = months.map((figures, index) => { | |
| 99 | + | const slotX = plot.x + index * slotWidth; | |
| 100 | + | const center = slotX + slotWidth / 2; | |
| 101 | + | const chargedHeight = scale(figures.chargedMicros); | |
| 102 | + | const costHeight = scale(figures.costMicros); | |
| 103 | + | return { | |
| 104 | + | month: figures.month, | |
| 105 | + | label: monthShort(figures.month), | |
| 106 | + | slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height }, | |
| 107 | + | charged: { x: center - gap / 2 - barWidth, y: baseline - chargedHeight, width: barWidth, height: chargedHeight }, | |
| 108 | + | cost: { x: center + gap / 2, y: baseline - costHeight, width: barWidth, height: costHeight }, | |
| 109 | + | figures, | |
| 110 | + | }; | |
| 111 | + | }); | |
| 112 | + | return { | |
| 113 | + | width, | |
| 114 | + | height, | |
| 115 | + | plot, | |
| 116 | + | top, | |
| 117 | + | ticks: ticks(top, count).map((value) => ({ value, y: baseline - (value / top) * plot.height })), | |
| 118 | + | bars, | |
| 119 | + | }; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /** | |
| 123 | + | * A bar's outline with its top corners rounded and its foot square on | |
| 124 | + | * the baseline. Nothing for an empty bar. | |
| 125 | + | */ | |
| 126 | + | export function barPath({ x, y, width, height }: Rect, radius = 4): string { | |
| 127 | + | if (height <= 0 || width <= 0) return ""; | |
| 128 | + | const r = Math.min(radius, width / 2, height); | |
| 129 | + | const bottom = y + height; | |
| 130 | + | const round = (value: number) => Math.round(value * 100) / 100; | |
| 131 | + | return [ | |
| 132 | + | `M${round(x)} ${round(bottom)}`, | |
| 133 | + | `V${round(y + r)}`, | |
| 134 | + | `Q${round(x)} ${round(y)} ${round(x + r)} ${round(y)}`, | |
| 135 | + | `H${round(x + width - r)}`, | |
| 136 | + | `Q${round(x + width)} ${round(y)} ${round(x + width)} ${round(y + r)}`, | |
| 137 | + | `V${round(bottom)}`, | |
| 138 | + | "Z", | |
| 139 | + | ].join(" "); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /** An axis label: `$0`, `$250`, `$1.5k`, `$20k`, `$1.2M`. */ | |
| 143 | + | export function axisDollars(micros: number): string { | |
| 144 | + | const dollars = micros / 1_000_000; | |
| 145 | + | const trim = (value: number) => String(Number(value.toFixed(1))); | |
| 146 | + | if (dollars >= 1_000_000) return `$${trim(dollars / 1_000_000)}M`; | |
| 147 | + | if (dollars >= 1_000) return `$${trim(dollars / 1_000)}k`; | |
| 148 | + | if (dollars >= 1 || dollars === 0) return `$${trim(dollars)}`; | |
| 149 | + | return `$${dollars.toFixed(2)}`; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** Each value's share of the largest, from 0 to 1, for a row of bars. */ | |
| 153 | + | export function shares(values: number[]): number[] { | |
| 154 | + | const max = Math.max(0, ...values); | |
| 155 | + | return values.map((value) => (max > 0 ? Math.max(0, value) / max : 0)); | |
| 156 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { parseCredit, parseEmail, parseSlugList, parseTerms } from "./forms.ts"; | |
| 4 | + | import { parseCredit, parseEmail, parseSales, parseSalesNote, parseSlugList, parseTerms } from "./forms.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const NOW = new Date("2026-10-04T12:00:00Z"); | |
| 7 | 7 | ||
| 62 | 62 | assert.equal(parseCredit("0").ok, false); | |
| 63 | 63 | assert.equal(parseCredit("10000.01").ok, false); | |
| 64 | 64 | }); | |
| 65 | + | ||
| 66 | + | test("a sales record: a stage, an owner's email or nobody, a next step and its day", () => { | |
| 67 | + | assert.deepEqual(parseSales(form({ stage: "contacted", owner: " Me@G1t.sh ", nextStep: "Call about\n terms", nextAt: "2026-10-09" })), { | |
| 68 | + | ok: true, | |
| 69 | + | value: { stage: "contacted", owner: "me@g1t.sh", nextStep: "Call about terms", nextAt: "2026-10-09" }, | |
| 70 | + | }); | |
| 71 | + | assert.deepEqual(parseSales(form({ stage: "none", owner: "", nextStep: "", nextAt: "" })), { | |
| 72 | + | ok: true, | |
| 73 | + | value: { stage: "none", owner: null, nextStep: null, nextAt: null }, | |
| 74 | + | }); | |
| 75 | + | assert.equal(parseSales(form({ stage: "closed" })).ok, false); | |
| 76 | + | assert.equal(parseSales(form({ stage: "lead", owner: "not an email" })).ok, false); | |
| 77 | + | assert.equal(parseSales(form({ stage: "lead", nextStep: "x".repeat(201) })).ok, false); | |
| 78 | + | assert.equal(parseSales(form({ stage: "lead", nextStep: "Call", nextAt: "2026-02-30" })).ok, false); | |
| 79 | + | assert.equal(parseSales(form({ stage: "lead", nextStep: "Call", nextAt: "next week" })).ok, false); | |
| 80 | + | // A date needs a step to go with it. | |
| 81 | + | assert.equal(parseSales(form({ stage: "lead", nextAt: "2026-10-09" })).ok, false); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test("a sales note is required, and not endless", () => { | |
| 85 | + | assert.deepEqual(parseSalesNote("Spoke to Ana; wants an enterprise quote."), { ok: true, value: "Spoke to Ana; wants an enterprise quote." }); | |
| 86 | + | assert.equal(parseSalesNote("").ok, false); | |
| 87 | + | assert.equal(parseSalesNote("x".repeat(2001)).ok, false); | |
| 88 | + | }); |
| 2 | 2 | * Reading sudo's forms. Everything typed is checked here before it goes | |
| 3 | 3 | * to the billing service, which checks it again. | |
| 4 | 4 | */ | |
| 5 | − | import type { Terms } from "@g1t/contracts"; | |
| 5 | + | import type { SalesStage, Terms } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { MICROS_PER_DOLLAR, parseDollars } from "./money.ts"; | |
| 8 | + | import { isStage } from "./signals.ts"; | |
| 8 | 9 | ||
| 9 | 10 | /** A workspace slug, as identity allows them (GitHub's rules). */ | |
| 10 | 11 | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 123 | 124 | return ok ? { ok: true, value: email } : { ok: false, error: "Enter one email address, such as billing@acme.com." }; | |
| 124 | 125 | } | |
| 125 | 126 | ||
| 127 | + | export type SalesUpdate = { stage: SalesStage; owner: string | null; nextStep: string | null; nextAt: string | null }; | |
| 128 | + | ||
| 129 | + | const MAX_NEXT_STEP = 200; | |
| 130 | + | const MAX_SALES_NOTE = 2000; | |
| 131 | + | ||
| 132 | + | /** | |
| 133 | + | * A workspace's sales record from its form. The owner is a staff member's | |
| 134 | + | * email, or nobody; a next step may have a date (a day, UTC) or not. | |
| 135 | + | */ | |
| 136 | + | export function parseSales(form: FormData): Parsed<SalesUpdate> { | |
| 137 | + | const stage = text(form, "stage"); | |
| 138 | + | if (!isStage(stage)) return { ok: false, error: "Choose a stage." }; | |
| 139 | + | ||
| 140 | + | let owner: string | null = null; | |
| 141 | + | const rawOwner = text(form, "owner"); | |
| 142 | + | if (rawOwner !== "") { | |
| 143 | + | const email = parseEmail(rawOwner); | |
| 144 | + | if (!email.ok) return { ok: false, error: "The owner is a staff member's email, such as you@g1t.sh, or blank for nobody." }; | |
| 145 | + | owner = email.value; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | const nextStep = text(form, "nextStep").replace(/\s+/g, " "); | |
| 149 | + | if (nextStep.length > MAX_NEXT_STEP) return { ok: false, error: `Keep the next step under ${MAX_NEXT_STEP} characters.` }; | |
| 150 | + | ||
| 151 | + | let nextAt: string | null = null; | |
| 152 | + | const rawDate = text(form, "nextAt"); | |
| 153 | + | if (rawDate !== "") { | |
| 154 | + | const day = /^\d{4}-\d{2}-\d{2}$/.test(rawDate) ? new Date(`${rawDate}T00:00:00Z`) : null; | |
| 155 | + | if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== rawDate) { | |
| 156 | + | return { ok: false, error: "The follow-up date is not a date." }; | |
| 157 | + | } | |
| 158 | + | nextAt = rawDate; | |
| 159 | + | } | |
| 160 | + | if (nextAt && !nextStep) return { ok: false, error: "Say what the next step is, as well as when." }; | |
| 161 | + | ||
| 162 | + | return { ok: true, value: { stage, owner, nextStep: nextStep || null, nextAt } }; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /** A note on a workspace's sales record. */ | |
| 166 | + | export function parseSalesNote(raw: string): Parsed<string> { | |
| 167 | + | if (!raw) return { ok: false, error: "Write the note first." }; | |
| 168 | + | if (raw.length > MAX_SALES_NOTE) return { ok: false, error: `Keep a note under ${MAX_SALES_NOTE} characters.` }; | |
| 169 | + | return { ok: true, value: raw }; | |
| 170 | + | } | |
| 171 | + | ||
| 126 | 172 | /** A credit's amount: more than nothing, and no more than the cap. */ | |
| 127 | 173 | export function parseCredit(raw: string): Parsed<number> { | |
| 128 | 174 | const micros = parseDollars(raw); |
| 29 | 29 | credit: "Credit issued.", | |
| 30 | 30 | created: "Enterprise created.", | |
| 31 | 31 | "billing-email": "Saved where the enterprise's invoices go.", | |
| 32 | + | sales: "Sales record saved.", | |
| 33 | + | note: "Note added.", | |
| 32 | 34 | }; | |
| 33 | 35 | ||
| 36 | + | /** The `?done=` key, if it is one sudo knows. */ | |
| 37 | + | export function doneKey(url: string): string | null { | |
| 38 | + | const done = new URL(url).searchParams.get("done"); | |
| 39 | + | return done && DONE[done] ? done : null; | |
| 40 | + | } | |
| 41 | + | ||
| 34 | 42 | export function doneMessage(url: string): string | null { | |
| 35 | 43 | const done = new URL(url).searchParams.get("done"); | |
| 36 | 44 | return done && DONE[done] ? DONE[done] : null; |
| 1 | + | /** | |
| 2 | + | * Changes to a workspace's sales record: its stage, owner and next step, | |
| 3 | + | * and notes. Not money, so no confirmation step; still POSTs from sudo's | |
| 4 | + | * own pages only (the worker checks), each recorded with who made it. | |
| 5 | + | */ | |
| 6 | + | import { data, redirect } from "react-router"; | |
| 7 | + | ||
| 8 | + | import { fields, parseSales, parseSalesNote, text } from "./forms"; | |
| 9 | + | import type { ActionData } from "./review"; | |
| 10 | + | import { admin } from "./services.server"; | |
| 11 | + | import type { Staff } from "./staff"; | |
| 12 | + | ||
| 13 | + | export const SALES_INTENTS = new Set(["sales", "note"]); | |
| 14 | + | ||
| 15 | + | function failed(section: string, error: string, values?: Record<string, string>) { | |
| 16 | + | return data<ActionData>({ error, section, values }, { status: 422 }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** Unexpected trouble from billing, as a message for the form. */ | |
| 20 | + | function reason(error: unknown): string { | |
| 21 | + | return error instanceof Error ? error.message : "Billing did not answer."; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export async function salesAction(form: FormData, staff: Staff, workspace: string, path: string) { | |
| 25 | + | const intent = text(form, "intent"); | |
| 26 | + | const back = (done: string, anchor: string) => redirect(`${path}?done=${done}#${anchor}`); | |
| 27 | + | ||
| 28 | + | if (intent === "sales") { | |
| 29 | + | const values = fields(form, "stage", "owner", "nextStep", "nextAt"); | |
| 30 | + | const parsed = parseSales(form); | |
| 31 | + | if (!parsed.ok) return failed("sales", parsed.error, values); | |
| 32 | + | try { | |
| 33 | + | const result = await admin.setSales(workspace, parsed.value, staff.email); | |
| 34 | + | if (!result.ok) return failed("sales", result.error.message, values); | |
| 35 | + | } catch (error) { | |
| 36 | + | return failed("sales", reason(error), values); | |
| 37 | + | } | |
| 38 | + | return back("sales", "sales"); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if (intent === "note") { | |
| 42 | + | const values = fields(form, "text"); | |
| 43 | + | const note = parseSalesNote(values.text); | |
| 44 | + | if (!note.ok) return failed("note", note.error, values); | |
| 45 | + | try { | |
| 46 | + | const result = await admin.addNote(workspace, note.value, staff.email); | |
| 47 | + | if (!result.ok) return failed("note", result.error.message, values); | |
| 48 | + | } catch (error) { | |
| 49 | + | return failed("note", reason(error), values); | |
| 50 | + | } | |
| 51 | + | return back("note", "notes"); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | return failed("top", "Unknown action."); | |
| 55 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { billingAdminClient, identityAdminClient } from "@g1t/contracts"; | |
| 3 | + | import { billingAdminClient, billingClient, identityAdminClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | /** Staff-only billing, on the billing service. */ | |
| 6 | 6 | export const admin = billingAdminClient(env.BILLING); | |
| 7 | 7 | ||
| 8 | + | /** The public price book, read-only, from the same service. */ | |
| 9 | + | export const priceBook = () => billingClient(env.BILLING).prices(); | |
| 10 | + | ||
| 8 | 11 | /** Staff-only identity: every workspace, its owners and members. */ | |
| 9 | 12 | export const identity = identityAdminClient(env.IDENTITY); |
| 1 | + | /** | |
| 2 | + | * A call whose failure should not take the whole page down: the page | |
| 3 | + | * shows what it has and says what did not answer. No Workers imports. | |
| 4 | + | */ | |
| 5 | + | export type Settled<T> = { ok: true; value: T } | { ok: false; error: string }; | |
| 6 | + | ||
| 7 | + | export async function settle<T>(promise: Promise<T>): Promise<Settled<T>> { | |
| 8 | + | try { | |
| 9 | + | return { ok: true, value: await promise }; | |
| 10 | + | } catch (error) { | |
| 11 | + | return { ok: false, error: error instanceof Error ? error.message : String(error) }; | |
| 12 | + | } | |
| 13 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Signal } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta } from "./signals.ts"; | |
| 7 | + | ||
| 8 | + | function signal(workspace: string, kind: string, valueMicros = 0, owner: string | null = null): Signal { | |
| 9 | + | return { workspace, kind: kind as Signal["kind"], detail: `${workspace} ${kind}`, valueMicros, stage: null, owner }; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | test("most urgent first: by kind, then the larger figure, else as billing sent them", () => { | |
| 13 | + | const sorted = bySignalUrgency([ | |
| 14 | + | signal("a", "growing", 10), | |
| 15 | + | signal("b", "first_payment", 99), | |
| 16 | + | signal("c", "near_ceiling", 5), | |
| 17 | + | signal("d", "at_limit", 1), | |
| 18 | + | signal("e", "near_ceiling", 50), | |
| 19 | + | signal("f", "something_new", 1000), | |
| 20 | + | signal("g", "declined", 0), | |
| 21 | + | signal("h", "growing", 10), | |
| 22 | + | ]); | |
| 23 | + | assert.deepEqual( | |
| 24 | + | sorted.map((s) => s.workspace), | |
| 25 | + | ["d", "g", "e", "c", "a", "h", "b", "f"], | |
| 26 | + | ); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("sorting leaves the input alone", () => { | |
| 30 | + | const input = [signal("a", "growing"), signal("b", "at_limit")]; | |
| 31 | + | bySignalUrgency(input); | |
| 32 | + | assert.equal(input[0].workspace, "a"); | |
| 33 | + | }); | |
| 34 | + | ||
| 35 | + | test("filter by kind, and by whose they are", () => { | |
| 36 | + | const all = [ | |
| 37 | + | signal("a", "at_limit", 0, "Me@g1t.sh"), | |
| 38 | + | signal("b", "at_limit", 0, null), | |
| 39 | + | signal("c", "growing", 0, "other@g1t.sh"), | |
| 40 | + | signal("d", "growing", 0, ""), | |
| 41 | + | ]; | |
| 42 | + | const me = "me@g1t.sh"; | |
| 43 | + | assert.deepEqual(filterSignals(all, { kind: null, who: "all", me }).map((s) => s.workspace), ["a", "b", "c", "d"]); | |
| 44 | + | assert.deepEqual(filterSignals(all, { kind: "at_limit", who: "all", me }).map((s) => s.workspace), ["a", "b"]); | |
| 45 | + | assert.deepEqual(filterSignals(all, { kind: null, who: "unassigned", me }).map((s) => s.workspace), ["b", "d"]); | |
| 46 | + | assert.deepEqual(filterSignals(all, { kind: null, who: "mine", me }).map((s) => s.workspace), ["a"]); | |
| 47 | + | assert.deepEqual(filterSignals(all, { kind: "growing", who: "mine", me }), []); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | test("counts by kind", () => { | |
| 51 | + | assert.deepEqual(countByKind([signal("a", "at_limit"), signal("b", "at_limit"), signal("c", "growing")]), { at_limit: 2, growing: 1 }); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("query values are checked", () => { | |
| 55 | + | assert.equal(parseWho("mine"), "mine"); | |
| 56 | + | assert.equal(parseWho("unassigned"), "unassigned"); | |
| 57 | + | assert.equal(parseWho("everyone"), "all"); | |
| 58 | + | assert.equal(parseWho(null), "all"); | |
| 59 | + | assert.ok(isSignalKind("near_ceiling")); | |
| 60 | + | assert.ok(!isSignalKind("nearly")); | |
| 61 | + | assert.ok(!isSignalKind(null)); | |
| 62 | + | }); | |
| 63 | + | ||
| 64 | + | test("links into the queue leave out the defaults", () => { | |
| 65 | + | assert.equal(reachOutHref({}), "/reach-out"); | |
| 66 | + | assert.equal(reachOutHref({ kind: "at_limit" }), "/reach-out?kind=at_limit"); | |
| 67 | + | assert.equal(reachOutHref({ kind: "declined", who: "mine" }), "/reach-out?kind=declined&who=mine"); | |
| 68 | + | assert.equal(reachOutHref({ kind: null, who: "all" }), "/reach-out"); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("names for kinds and stages, and for ones sudo does not know", () => { | |
| 72 | + | assert.equal(signalMeta("at_limit").label, "At limit"); | |
| 73 | + | assert.equal(signalMeta("at_limit").tone, "danger"); | |
| 74 | + | assert.equal(signalMeta("brand_new").label, "brand new"); | |
| 75 | + | assert.equal(stageMeta(null).label, "No stage"); | |
| 76 | + | assert.equal(stageMeta("churn_risk").label, "Churn risk"); | |
| 77 | + | assert.equal(stageMeta("odd").label, "odd"); | |
| 78 | + | }); |
| 1 | + | /** | |
| 2 | + | * Reach out: why a workspace is worth a word, how urgent that is, and what | |
| 3 | + | * staff are doing about it. Billing finds the signals and keeps the sales | |
| 4 | + | * records; this orders, filters and names them. No Workers imports, so it | |
| 5 | + | * can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import type { SalesStage, Signal, SignalKind } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | export type Tone = "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; | |
| 10 | + | ||
| 11 | + | /** Each kind of signal, most urgent first. */ | |
| 12 | + | export const SIGNAL_KINDS: { kind: SignalKind; label: string; tone: Tone; about: string }[] = [ | |
| 13 | + | { kind: "at_limit", label: "At limit", tone: "danger", about: "Work is stopped: it reached its limit or its owners' spend limit." }, | |
| 14 | + | { kind: "declined", label: "Declined", tone: "danger", about: "Its card was declined or a payment disputed." }, | |
| 15 | + | { kind: "near_ceiling", label: "Near limit", tone: "warn", about: "Past 80% of what is available to it; about to need more." }, | |
| 16 | + | { kind: "high_spend", label: "High spend", tone: "lavender", about: "Spending enough that custom terms or an enterprise may suit it." }, | |
| 17 | + | { kind: "growing", label: "Growing", tone: "mint", about: "This month is well ahead of last month." }, | |
| 18 | + | { kind: "established", label: "Established", tone: "info", about: "Became Established: its limit now follows its spend." }, | |
| 19 | + | { kind: "first_payment", label: "First payment", tone: "mint", about: "Paid g1t for the first time." }, | |
| 20 | + | ]; | |
| 21 | + | ||
| 22 | + | const RANK = new Map(SIGNAL_KINDS.map((entry, index) => [entry.kind as string, index])); | |
| 23 | + | ||
| 24 | + | export function signalMeta(kind: string): { label: string; tone: Tone; about: string } { | |
| 25 | + | return SIGNAL_KINDS.find((entry) => entry.kind === kind) ?? { label: kind.replace(/_/g, " "), tone: "plain", about: "" }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export function isSignalKind(value: string | null | undefined): value is SignalKind { | |
| 29 | + | return value != null && RANK.has(value); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * Most urgent first: by kind, then the larger figure. Stable, so billing's | |
| 34 | + | * own order holds between equals. A kind sudo does not know goes last. | |
| 35 | + | */ | |
| 36 | + | export function bySignalUrgency(signals: Signal[]): Signal[] { | |
| 37 | + | return signals | |
| 38 | + | .map((signal, index) => ({ signal, index })) | |
| 39 | + | .sort( | |
| 40 | + | (a, b) => | |
| 41 | + | (RANK.get(a.signal.kind) ?? RANK.size) - (RANK.get(b.signal.kind) ?? RANK.size) || | |
| 42 | + | b.signal.valueMicros - a.signal.valueMicros || | |
| 43 | + | a.index - b.index, | |
| 44 | + | ) | |
| 45 | + | .map(({ signal }) => signal); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** Whose signals to show: everyone's, nobody's yet, or the signed-in staff member's. */ | |
| 49 | + | export type Who = "all" | "unassigned" | "mine"; | |
| 50 | + | ||
| 51 | + | export function parseWho(value: string | null): Who { | |
| 52 | + | return value === "unassigned" || value === "mine" ? value : "all"; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function sameEmail(a: string | null | undefined, b: string): boolean { | |
| 56 | + | return a != null && a.trim().toLowerCase() === b.trim().toLowerCase(); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** The signals that match a kind (or any) and whose they are. */ | |
| 60 | + | export function filterSignals(signals: Signal[], { kind, who, me }: { kind: SignalKind | null; who: Who; me: string }): Signal[] { | |
| 61 | + | return signals.filter((signal) => { | |
| 62 | + | if (kind && signal.kind !== kind) return false; | |
| 63 | + | if (who === "unassigned") return !signal.owner; | |
| 64 | + | if (who === "mine") return sameEmail(signal.owner, me); | |
| 65 | + | return true; | |
| 66 | + | }); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** How many signals of each kind, for the filter's counts. */ | |
| 70 | + | export function countByKind(signals: Signal[]): Record<string, number> { | |
| 71 | + | const counts: Record<string, number> = {}; | |
| 72 | + | for (const signal of signals) counts[signal.kind] = (counts[signal.kind] ?? 0) + 1; | |
| 73 | + | return counts; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** A link to the queue with these filters; the defaults are left out. */ | |
| 77 | + | export function reachOutHref({ kind, who }: { kind?: string | null; who?: Who }): string { | |
| 78 | + | const params = new URLSearchParams(); | |
| 79 | + | if (kind) params.set("kind", kind); | |
| 80 | + | if (who && who !== "all") params.set("who", who); | |
| 81 | + | const query = params.toString(); | |
| 82 | + | return query ? `/reach-out?${query}` : "/reach-out"; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** The sales stages, in the order a deal moves through them. */ | |
| 86 | + | export const STAGES: { stage: SalesStage; label: string; tone: Tone }[] = [ | |
| 87 | + | { stage: "none", label: "No stage", tone: "plain" }, | |
| 88 | + | { stage: "lead", label: "Lead", tone: "info" }, | |
| 89 | + | { stage: "contacted", label: "Contacted", tone: "lavender" }, | |
| 90 | + | { stage: "negotiating", label: "Negotiating", tone: "warn" }, | |
| 91 | + | { stage: "won", label: "Won", tone: "mint" }, | |
| 92 | + | { stage: "lost", label: "Lost", tone: "plain" }, | |
| 93 | + | { stage: "churn_risk", label: "Churn risk", tone: "danger" }, | |
| 94 | + | ]; | |
| 95 | + | ||
| 96 | + | export function stageMeta(stage: string | null | undefined): { label: string; tone: Tone } { | |
| 97 | + | return STAGES.find((entry) => entry.stage === (stage ?? "none")) ?? { label: String(stage), tone: "plain" }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | export function isStage(value: string): value is SalesStage { | |
| 101 | + | return STAGES.some((entry) => entry.stage === value); | |
| 102 | + | } |
| 1 | − | import { Boxes, Building2, CreditCard, ShieldCheck } from "lucide-react"; | |
| 2 | − | import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router"; | |
| 1 | + | import { isRouteErrorResponse, Links, Meta, Outlet, useRouteLoaderData } from "react-router"; | |
| 3 | 2 | ||
| 4 | 3 | import type { Route } from "./+types/root"; | |
| 5 | 4 | import "./app.css"; | |
| 6 | − | import { Logo } from "./components/logo"; | |
| 5 | + | import { MobileBar, Sidebar } from "./components/shell"; | |
| 7 | 6 | import { ButtonLink } from "./components/ui"; | |
| 8 | 7 | import { requireStaff } from "./lib/staff"; | |
| 9 | 8 | ||
| 26 | 25 | return { email: requireStaff(context).email }; | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | − | /** A top-level section; it stays lit on the pages beneath it. */ | |
| 30 | − | function NavItem({ to, active, children }: { to: string; active: (path: string) => boolean; children: React.ReactNode }) { | |
| 31 | − | const { pathname } = useLocation(); | |
| 32 | − | const isActive = active(pathname); | |
| 33 | − | return ( | |
| 34 | − | <Link | |
| 35 | − | to={to} | |
| 36 | − | aria-current={isActive ? "page" : undefined} | |
| 37 | − | className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${ | |
| 38 | − | isActive ? "bg-raised/60 text-fg" : "text-muted" | |
| 39 | − | }`} | |
| 40 | − | > | |
| 41 | − | {children} | |
| 42 | − | </Link> | |
| 43 | − | ); | |
| 44 | − | } | |
| 45 | − | ||
| 46 | 28 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 47 | 29 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 48 | 30 | return ( | |
| 54 | 36 | <Meta /> | |
| 55 | 37 | <Links /> | |
| 56 | 38 | </head> | |
| 57 | − | <body className="flex min-h-screen flex-col"> | |
| 58 | − | <header className="sticky top-0 z-40 border-b border-line bg-surface/90 backdrop-blur"> | |
| 59 | − | <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4"> | |
| 60 | − | <Link to="/" aria-label="sudo home" className="mr-1 sm:mr-3"> | |
| 61 | − | <Logo /> | |
| 62 | − | </Link> | |
| 63 | − | <nav className="flex items-center gap-0.5"> | |
| 64 | − | <NavItem to="/" active={(path) => path === "/" || path.startsWith("/workspaces")}> | |
| 65 | − | <Boxes size={14} className="hidden sm:block" /> | |
| 66 | − | Workspaces | |
| 67 | − | </NavItem> | |
| 68 | − | <NavItem to="/enterprises" active={(path) => path.startsWith("/enterprises")}> | |
| 69 | − | <Building2 size={14} className="hidden sm:block" /> | |
| 70 | − | Enterprises | |
| 71 | − | </NavItem> | |
| 72 | − | <NavItem to="/stripe" active={(path) => path.startsWith("/stripe")}> | |
| 73 | − | <CreditCard size={14} className="hidden sm:block" /> | |
| 74 | − | Stripe | |
| 75 | − | </NavItem> | |
| 76 | − | </nav> | |
| 77 | − | {root?.email && ( | |
| 78 | − | <span | |
| 79 | − | title="Signed in through Cloudflare Access" | |
| 80 | − | className="ml-auto hidden items-center gap-1.5 truncate rounded-md border border-line px-2 py-1 font-mono text-xs text-muted md:inline-flex" | |
| 81 | − | > | |
| 82 | − | <ShieldCheck size={13} className="text-merged" /> | |
| 83 | − | {root.email} | |
| 84 | − | </span> | |
| 85 | − | )} | |
| 86 | − | </div> | |
| 87 | − | </header> | |
| 88 | − | <div className="grow">{children}</div> | |
| 89 | − | <footer className="border-t border-line"> | |
| 90 | − | <p className="mx-auto max-w-6xl px-4 py-5 text-xs text-faint"> | |
| 91 | − | g1t staff only. Every change is recorded with who made it. | |
| 92 | − | {root?.email && <span className="md:hidden"> Signed in as {root.email}.</span>} | |
| 93 | − | </p> | |
| 94 | − | </footer> | |
| 39 | + | <body className="min-h-screen"> | |
| 40 | + | <Sidebar email={root?.email} /> | |
| 41 | + | <MobileBar email={root?.email} /> | |
| 42 | + | <div className="lg:pl-60">{children}</div> | |
| 95 | 43 | {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */} | |
| 96 | 44 | </body> | |
| 97 | 45 | </html> | |
| 118 | 66 | <p className="mt-3 break-words text-muted">{details}</p> | |
| 119 | 67 | <div className="mt-8"> | |
| 120 | 68 | <ButtonLink to="/" variant="quiet"> | |
| 121 | − | Back to workspaces | |
| 69 | + | Back to the overview | |
| 122 | 70 | </ButtonLink> | |
| 123 | 71 | </div> | |
| 124 | 72 | </main> |
| 1 | 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; | |
| 2 | 2 | ||
| 3 | + | import { soonItems } from "./lib/nav.ts"; | |
| 4 | + | ||
| 3 | 5 | export default [ | |
| 4 | − | index("routes/workspaces.tsx"), | |
| 5 | − | route("workspaces", "routes/workspaces-redirect.tsx"), | |
| 6 | + | index("routes/overview.tsx"), | |
| 7 | + | route("workspaces", "routes/workspaces.tsx"), | |
| 6 | 8 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 7 | 9 | route("enterprises", "routes/enterprises.tsx"), | |
| 8 | 10 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 9 | 11 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 12 | + | route("reach-out", "routes/reach-out.tsx"), | |
| 10 | 13 | route("stripe", "routes/stripe.tsx"), | |
| 14 | + | // What sudo will have: one placeholder page each, from lib/nav.ts. | |
| 15 | + | ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })), | |
| 11 | 16 | // Before sudo was organised around workspaces, everything was an account. | |
| 12 | 17 | route("accounts/*", "routes/legacy-accounts.tsx"), | |
| 13 | 18 | ] satisfies RouteConfig; |
| 11 | 11 | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 12 | 12 | requireStaff(context); | |
| 13 | 13 | const id = (params["*"] ?? "").replace(/\/+$/, ""); | |
| 14 | − | if (!id) return redirect(`/${new URL(request.url).search}`, 301); | |
| 14 | + | if (!id) return redirect(`/workspaces${new URL(request.url).search}`, 301); | |
| 15 | 15 | const path = legacyAccountPath(id); | |
| 16 | 16 | if (!path) throw data("That is not a workspace or an enterprise.", { status: 404 }); | |
| 17 | 17 | return redirect(path, 301); |
| 1 | + | import { AlertOctagon, ArrowRight, CalendarClock, CreditCard, FileText, Gauge } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 3 | + | import { Link, redirect } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/overview"; | |
| 6 | + | import { KindBreakdown, MonthsChart } from "~/components/charts"; | |
| 7 | + | import { SignalBadge, StaffName, StageBadge } from "~/components/sales"; | |
| 8 | + | import { Avatar, Notice, PageHeader, Section, Stat } from "~/components/ui"; | |
| 9 | + | import { change, marginOf, monthLong } from "~/lib/chart"; | |
| 10 | + | import { usd } from "~/lib/money"; | |
| 11 | + | import { admin } from "~/lib/services.server"; | |
| 12 | + | import { settle } from "~/lib/settle"; | |
| 13 | + | import { bySignalUrgency, reachOutHref } from "~/lib/signals"; | |
| 14 | + | import { requireStaff } from "~/lib/staff"; | |
| 15 | + | ||
| 16 | + | export const meta: Route.MetaFunction = () => [{ title: "Overview · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 17 | + | ||
| 18 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 19 | + | const staff = requireStaff(context); | |
| 20 | + | // The workspaces list used to live here: old links with a search go on to it. | |
| 21 | + | const { search, searchParams } = new URL(request.url); | |
| 22 | + | if (searchParams.has("q") || searchParams.has("show") || searchParams.has("page")) throw redirect(`/workspaces${search}`); | |
| 23 | + | ||
| 24 | + | const [overview, signals] = await Promise.all([settle(admin.overview()), settle(admin.signals())]); | |
| 25 | + | return { | |
| 26 | + | me: staff.email, | |
| 27 | + | overview: overview.ok ? overview.value : null, | |
| 28 | + | overviewError: overview.ok ? null : overview.error, | |
| 29 | + | signals: signals.ok ? bySignalUrgency(signals.value) : [], | |
| 30 | + | signalCount: signals.ok ? signals.value.length : 0, | |
| 31 | + | signalsError: signals.ok ? null : signals.error, | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export default function Overview({ loaderData }: Route.ComponentProps) { | |
| 36 | + | const { me, overview, overviewError, signals, signalCount, signalsError } = loaderData; | |
| 37 | + | const months = overview?.months ?? []; | |
| 38 | + | const current = months.find((month) => month.month === overview?.month) ?? months.at(-1) ?? null; | |
| 39 | + | const previous = current ? months[months.indexOf(current) - 1] ?? null : null; | |
| 40 | + | const charged = current?.chargedMicros ?? overview?.byKind.reduce((sum, row) => sum + row.chargedMicros, 0) ?? 0; | |
| 41 | + | const cost = current?.costMicros ?? overview?.byKind.reduce((sum, row) => sum + row.costMicros, 0) ?? 0; | |
| 42 | + | const margin = marginOf(charged, cost); | |
| 43 | + | const growth = previous ? change(previous.chargedMicros, charged) : null; | |
| 44 | + | ||
| 45 | + | return ( | |
| 46 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 47 | + | <PageHeader | |
| 48 | + | title="Overview" | |
| 49 | + | description={overview ? <>The business in {monthLong(overview.month)}, so far, and who needs a word.</> : "The business at a glance."} | |
| 50 | + | /> | |
| 51 | + | ||
| 52 | + | {overviewError && ( | |
| 53 | + | <div className="mt-6"> | |
| 54 | + | <Notice tone="warn">Billing did not answer for the overview: {overviewError}</Notice> | |
| 55 | + | </div> | |
| 56 | + | )} | |
| 57 | + | ||
| 58 | + | {overview && ( | |
| 59 | + | <> | |
| 60 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 61 | + | <Stat | |
| 62 | + | label="Charged this month" | |
| 63 | + | value={usd(charged)} | |
| 64 | + | hint={growth == null ? "Nothing last month to compare" : `${growth >= 0 ? "+" : "−"}${Math.abs(growth)}% on last month`} | |
| 65 | + | tone={growth != null && growth > 0 ? "mint" : undefined} | |
| 66 | + | /> | |
| 67 | + | <Stat label="Cost to g1t" value={usd(cost)} hint="What the usage cost on Cloudflare and model providers" /> | |
| 68 | + | <Stat | |
| 69 | + | label="Margin" | |
| 70 | + | value={usd(margin.micros)} | |
| 71 | + | hint={margin.percent == null ? "Nothing charged yet" : `${margin.percent}% of what was charged`} | |
| 72 | + | tone={margin.micros < 0 ? "danger" : undefined} | |
| 73 | + | /> | |
| 74 | + | <Stat label="Paying workspaces" value={String(overview.payingWorkspaces)} hint="Charged something this month" /> | |
| 75 | + | </div> | |
| 76 | + | ||
| 77 | + | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 78 | + | <Section title="Last six months" description="Charged against what it cost g1t, every workspace together."> | |
| 79 | + | <MonthsChart months={months} /> | |
| 80 | + | </Section> | |
| 81 | + | <Section title="This month by kind" description="Where the money comes from, and what each earns."> | |
| 82 | + | <KindBreakdown kinds={overview.byKind} /> | |
| 83 | + | </Section> | |
| 84 | + | </div> | |
| 85 | + | ||
| 86 | + | <h2 className="mt-10 text-lg font-semibold tracking-tight">Needs attention</h2> | |
| 87 | + | <div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-5"> | |
| 88 | + | <AttentionCard | |
| 89 | + | to={reachOutHref({ kind: "at_limit" })} | |
| 90 | + | icon={<AlertOctagon size={15} />} | |
| 91 | + | label="Stopped" | |
| 92 | + | value={overview.stopped} | |
| 93 | + | hint="At their limit; work is stopped" | |
| 94 | + | tone={overview.stopped > 0 ? "danger" : undefined} | |
| 95 | + | /> | |
| 96 | + | <AttentionCard | |
| 97 | + | to={reachOutHref({ kind: "near_ceiling" })} | |
| 98 | + | icon={<Gauge size={15} />} | |
| 99 | + | label="Near limit" | |
| 100 | + | value={overview.nearCeiling} | |
| 101 | + | hint="Past 80% of what is available" | |
| 102 | + | tone={overview.nearCeiling > 0 ? "warn" : undefined} | |
| 103 | + | /> | |
| 104 | + | <AttentionCard | |
| 105 | + | to={reachOutHref({ kind: "declined" })} | |
| 106 | + | icon={<CreditCard size={15} />} | |
| 107 | + | label="Declined" | |
| 108 | + | value={overview.declined} | |
| 109 | + | hint="Card declined or payment disputed" | |
| 110 | + | tone={overview.declined > 0 ? "danger" : undefined} | |
| 111 | + | /> | |
| 112 | + | <AttentionCard | |
| 113 | + | to="/invoices" | |
| 114 | + | icon={<FileText size={15} />} | |
| 115 | + | label="Open invoices" | |
| 116 | + | value={usd(overview.openInvoicesMicros)} | |
| 117 | + | hint="Sent and not yet paid" | |
| 118 | + | /> | |
| 119 | + | <AttentionCard | |
| 120 | + | to={reachOutHref({})} | |
| 121 | + | icon={<CalendarClock size={15} />} | |
| 122 | + | label="Follow-ups due" | |
| 123 | + | value={overview.followUpsDue} | |
| 124 | + | hint="Next steps due today or earlier" | |
| 125 | + | tone={overview.followUpsDue > 0 ? "warn" : undefined} | |
| 126 | + | /> | |
| 127 | + | </div> | |
| 128 | + | </> | |
| 129 | + | )} | |
| 130 | + | ||
| 131 | + | <Section | |
| 132 | + | title="Reach out" | |
| 133 | + | description={signalsError ? undefined : signalCount === 0 ? "Nobody needs a word right now." : `The most urgent of ${signalCount}.`} | |
| 134 | + | actions={ | |
| 135 | + | signalCount > 0 && ( | |
| 136 | + | <Link to="/reach-out" className="inline-flex items-center gap-1 text-sm text-merged hover:underline hover:underline-offset-4"> | |
| 137 | + | All {signalCount} | |
| 138 | + | <ArrowRight size={14} /> | |
| 139 | + | </Link> | |
| 140 | + | ) | |
| 141 | + | } | |
| 142 | + | className="mt-10" | |
| 143 | + | > | |
| 144 | + | {signalsError ? ( | |
| 145 | + | <Notice tone="warn">Billing did not answer for signals: {signalsError}</Notice> | |
| 146 | + | ) : signals.length === 0 ? ( | |
| 147 | + | <p className="text-sm text-muted">No signals. Workspaces show up here when they stop, get close to their limit, are declined, or grow.</p> | |
| 148 | + | ) : ( | |
| 149 | + | <ul className="-mx-4 -my-4 divide-y divide-line sm:-mx-5 sm:-my-5"> | |
| 150 | + | {signals.slice(0, 5).map((signal, index) => ( | |
| 151 | + | <li key={`${signal.workspace}-${signal.kind}-${index}`}> | |
| 152 | + | <Link | |
| 153 | + | to={`/workspaces/${encodeURIComponent(signal.workspace)}#sales`} | |
| 154 | + | className="flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/40 sm:flex-row sm:items-center sm:gap-4 sm:px-5" | |
| 155 | + | > | |
| 156 | + | <span className="flex min-w-0 items-center gap-2.5 sm:w-48 sm:shrink-0"> | |
| 157 | + | <Avatar name={signal.workspace} size={20} /> | |
| 158 | + | <span className="truncate font-mono text-sm text-fg">{signal.workspace}</span> | |
| 159 | + | </span> | |
| 160 | + | <span className="flex min-w-0 grow flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> | |
| 161 | + | <span className="shrink-0"> | |
| 162 | + | <SignalBadge kind={signal.kind} /> | |
| 163 | + | </span> | |
| 164 | + | <span className="min-w-0 text-sm text-muted">{signal.detail}</span> | |
| 165 | + | </span> | |
| 166 | + | <span className="flex shrink-0 items-center gap-3 text-xs sm:w-56 sm:justify-end"> | |
| 167 | + | <span className="tabular text-fg-soft">{usd(signal.valueMicros)}</span> | |
| 168 | + | {signal.stage && signal.stage !== "none" && <StageBadge stage={signal.stage} />} | |
| 169 | + | <StaffName email={signal.owner} me={me} /> | |
| 170 | + | </span> | |
| 171 | + | </Link> | |
| 172 | + | </li> | |
| 173 | + | ))} | |
| 174 | + | </ul> | |
| 175 | + | )} | |
| 176 | + | </Section> | |
| 177 | + | </main> | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | function AttentionCard({ | |
| 182 | + | to, | |
| 183 | + | icon, | |
| 184 | + | label, | |
| 185 | + | value, | |
| 186 | + | hint, | |
| 187 | + | tone, | |
| 188 | + | }: { | |
| 189 | + | to: string; | |
| 190 | + | icon: ReactNode; | |
| 191 | + | label: string; | |
| 192 | + | value: ReactNode; | |
| 193 | + | hint: string; | |
| 194 | + | tone?: "danger" | "warn"; | |
| 195 | + | }) { | |
| 196 | + | const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-fg"; | |
| 197 | + | return ( | |
| 198 | + | <Link to={to} className="group rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-line-strong"> | |
| 199 | + | <p className="flex items-center gap-1.5 text-xs text-muted"> | |
| 200 | + | <span className={tone ? color : "text-faint"}>{icon}</span> | |
| 201 | + | {label} | |
| 202 | + | <ArrowRight size={12} className="ml-auto text-faint opacity-0 transition-opacity group-hover:opacity-100" /> | |
| 203 | + | </p> | |
| 204 | + | <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p> | |
| 205 | + | <p className="mt-0.5 text-xs text-faint">{hint}</p> | |
| 206 | + | </Link> | |
| 207 | + | ); | |
| 208 | + | } |
| 1 | + | import { ChevronRight } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminOwner, Signal } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/reach-out"; | |
| 7 | + | import { Owners } from "~/components/billing"; | |
| 8 | + | import { SignalBadge, StaffName, StageBadge } from "~/components/sales"; | |
| 9 | + | import { Avatar, EmptyState, Notice, PageHeader } from "~/components/ui"; | |
| 10 | + | import { usd } from "~/lib/money"; | |
| 11 | + | import { identity, admin } from "~/lib/services.server"; | |
| 12 | + | import { settle } from "~/lib/settle"; | |
| 13 | + | import { SIGNAL_KINDS, type Who, bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref } from "~/lib/signals"; | |
| 14 | + | import { requireStaff } from "~/lib/staff"; | |
| 15 | + | ||
| 16 | + | export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 17 | + | ||
| 18 | + | export 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 | + | ||
| 54 | + | const WHO: { who: Who; label: string }[] = [ | |
| 55 | + | { who: "all", label: "Everyone's" }, | |
| 56 | + | { who: "unassigned", label: "Unassigned" }, | |
| 57 | + | { who: "mine", label: "Mine" }, | |
| 58 | + | ]; | |
| 59 | + | ||
| 60 | + | function 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 | + | ||
| 75 | + | type Row = { signal: Signal; name: string | null; owners: AdminOwner[] }; | |
| 76 | + | ||
| 77 | + | function rowHref(signal: Signal) { | |
| 78 | + | return `/workspaces/${encodeURIComponent(signal.workspace)}#sales`; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | export 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 | + | ||
| 213 | + | function 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 | + | } |
| 1 | + | import { ArrowRight, Check } from "lucide-react"; | |
| 2 | + | import { data, Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/soon"; | |
| 5 | + | import { NavGlyph } from "~/components/shell"; | |
| 6 | + | import { Badge, Notice, Section, When } from "~/components/ui"; | |
| 7 | + | import { soonFor } from "~/lib/nav"; | |
| 8 | + | import { priceBook } from "~/lib/services.server"; | |
| 9 | + | import { settle } from "~/lib/settle"; | |
| 10 | + | import { requireStaff } from "~/lib/staff"; | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * A page sudo will have. It says what it will do and why, and what it | |
| 14 | + | * will have, so the sidebar doubles as the roadmap for g1t's own back | |
| 15 | + | * office. Each is made from its entry in lib/nav.ts. | |
| 16 | + | */ | |
| 17 | + | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 18 | + | { title: `${loaderData?.label ?? "Soon"} · sudo` }, | |
| 19 | + | { name: "robots", content: "noindex, nofollow" }, | |
| 20 | + | ]; | |
| 21 | + | ||
| 22 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 23 | + | requireStaff(context); | |
| 24 | + | const page = soonFor(new URL(request.url).pathname); | |
| 25 | + | if (!page) throw data("Not found.", { status: 404 }); | |
| 26 | + | // The price book's editor is to come; what it holds can be read today. | |
| 27 | + | const prices = page.to === "/prices" ? await settle(priceBook()) : null; | |
| 28 | + | return { | |
| 29 | + | label: page.label, | |
| 30 | + | about: page.about, | |
| 31 | + | icon: page.icon, | |
| 32 | + | soon: page.soon, | |
| 33 | + | prices: prices?.ok ? prices.value : null, | |
| 34 | + | pricesError: prices && !prices.ok ? prices.error : null, | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** A unit price, which can be millionths of a dollar: `$0.000021`. */ | |
| 39 | + | function unitPrice(micros: number): string { | |
| 40 | + | const dollars = micros / 1_000_000; | |
| 41 | + | if (dollars >= 0.01) return `$${dollars.toFixed(dollars >= 100 ? 0 : 2)}`; | |
| 42 | + | return `$${dollars.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")}`; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export default function Soon({ loaderData }: Route.ComponentProps) { | |
| 46 | + | const { label, about, icon, soon, prices, pricesError } = loaderData; | |
| 47 | + | return ( | |
| 48 | + | <main className="mx-auto max-w-3xl px-4 py-8 sm:py-14"> | |
| 49 | + | <div className="flex items-center gap-3"> | |
| 50 | + | <span className="flex size-10 items-center justify-center rounded-lg bg-merged/10 text-merged ring-1 ring-merged/30 ring-inset"> | |
| 51 | + | <NavGlyph icon={icon} size={18} /> | |
| 52 | + | </span> | |
| 53 | + | <Badge tone="lavender">Soon</Badge> | |
| 54 | + | </div> | |
| 55 | + | <h1 className="mt-5 text-2xl font-semibold tracking-tight sm:text-3xl">{label}</h1> | |
| 56 | + | <p className="mt-2 text-[0.9375rem] text-fg-soft">{about}</p> | |
| 57 | + | ||
| 58 | + | <div className="mt-6 space-y-4 text-sm leading-relaxed text-muted"> | |
| 59 | + | {soon.summary.map((paragraph) => ( | |
| 60 | + | <p key={paragraph.slice(0, 40)}>{paragraph}</p> | |
| 61 | + | ))} | |
| 62 | + | </div> | |
| 63 | + | ||
| 64 | + | <section className="mt-8 rounded-lg border border-line bg-surface p-4 sm:p-5"> | |
| 65 | + | <h2 className="text-xs font-medium tracking-wide text-faint uppercase">What it will have</h2> | |
| 66 | + | <ul className="mt-3 space-y-2.5"> | |
| 67 | + | {soon.plans.map((plan) => ( | |
| 68 | + | <li key={plan} className="flex gap-2.5 text-sm text-fg-soft"> | |
| 69 | + | <Check size={15} className="mt-0.5 shrink-0 text-merged" /> | |
| 70 | + | <span>{plan}</span> | |
| 71 | + | </li> | |
| 72 | + | ))} | |
| 73 | + | </ul> | |
| 74 | + | </section> | |
| 75 | + | ||
| 76 | + | {soon.meanwhile && ( | |
| 77 | + | <div className="mt-4"> | |
| 78 | + | <Notice tone="info"> | |
| 79 | + | <span> | |
| 80 | + | Meanwhile: {soon.meanwhile.text} | |
| 81 | + | {soon.meanwhile.to && ( | |
| 82 | + | <> | |
| 83 | + | {" "} | |
| 84 | + | <Link to={soon.meanwhile.to} className="inline-flex items-center gap-1 text-merged hover:underline hover:underline-offset-4"> | |
| 85 | + | {soon.meanwhile.link ?? "Open"} | |
| 86 | + | <ArrowRight size={13} /> | |
| 87 | + | </Link> | |
| 88 | + | </> | |
| 89 | + | )} | |
| 90 | + | </span> | |
| 91 | + | </Notice> | |
| 92 | + | </div> | |
| 93 | + | )} | |
| 94 | + | ||
| 95 | + | {(prices || pricesError) && ( | |
| 96 | + | <Section | |
| 97 | + | title="The price book today" | |
| 98 | + | description={ | |
| 99 | + | prices ? `Read-only. Models are charged at cost plus ${prices.modelMarginPercent}%.` : undefined | |
| 100 | + | } | |
| 101 | + | className="mt-8" | |
| 102 | + | > | |
| 103 | + | {pricesError ? ( | |
| 104 | + | <Notice tone="warn">Billing did not answer for prices: {pricesError}</Notice> | |
| 105 | + | ) : prices && prices.prices.length > 0 ? ( | |
| 106 | + | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 107 | + | <table className="w-full min-w-xl text-sm"> | |
| 108 | + | <thead> | |
| 109 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 110 | + | <th className="px-4 py-2 font-medium sm:pl-5">Meter</th> | |
| 111 | + | <th className="px-4 py-2 text-right font-medium">Cost</th> | |
| 112 | + | <th className="px-4 py-2 text-right font-medium">Markup</th> | |
| 113 | + | <th className="px-4 py-2 text-right font-medium">Price</th> | |
| 114 | + | <th className="px-4 py-2 font-medium sm:pr-5">Cost from</th> | |
| 115 | + | </tr> | |
| 116 | + | </thead> | |
| 117 | + | <tbody> | |
| 118 | + | {prices.prices.map((price) => ( | |
| 119 | + | <tr key={price.meter} className="border-b border-line align-top last:border-0"> | |
| 120 | + | <td className="px-4 py-2.5 sm:pl-5"> | |
| 121 | + | <p className="text-fg-soft">{price.title}</p> | |
| 122 | + | <p className="text-xs text-faint">per {price.unit}</p> | |
| 123 | + | </td> | |
| 124 | + | <td className="tabular px-4 py-2.5 text-right text-muted">{unitPrice(price.costMicros)}</td> | |
| 125 | + | <td className="tabular px-4 py-2.5 text-right text-muted">{price.markupPercent}%</td> | |
| 126 | + | <td className="tabular px-4 py-2.5 text-right text-fg">{unitPrice(price.priceMicros)}</td> | |
| 127 | + | <td className="px-4 py-2.5 text-xs text-muted sm:pr-5"> | |
| 128 | + | {price.source === "cloudflare" ? "Cloudflare's bill" : price.source === "list" ? "List price" : price.source} | |
| 129 | + | {price.checkedAt && ( | |
| 130 | + | <span className="block text-faint"> | |
| 131 | + | checked <When at={price.checkedAt} /> | |
| 132 | + | </span> | |
| 133 | + | )} | |
| 134 | + | </td> | |
| 135 | + | </tr> | |
| 136 | + | ))} | |
| 137 | + | </tbody> | |
| 138 | + | </table> | |
| 139 | + | </div> | |
| 140 | + | ) : ( | |
| 141 | + | <p className="text-sm text-muted">No metered prices yet.</p> | |
| 142 | + | )} | |
| 143 | + | </Section> | |
| 144 | + | )} | |
| 145 | + | ||
| 146 | + | <p className="mt-10 border-t border-line pt-5 text-xs text-faint"> | |
| 147 | + | Part of the back office g1t is building for itself: one place for sales, support and finance, on the same data as the | |
| 148 | + | product, with every change recorded. | |
| 149 | + | </p> | |
| 150 | + | </main> | |
| 151 | + | ); | |
| 152 | + | } |
| 1 | 1 | import { ArrowLeft, Building2, LogOut } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 2 | 3 | import { data, Link, useLocation } from "react-router"; | |
| 3 | 4 | ||
| 4 | − | import { type AccountSummary, type AdminAction, httpStatus } from "@g1t/contracts"; | |
| 5 | + | import { type AccountSummary, type AdminAction, type Limit, type Terms, httpStatus } from "@g1t/contracts"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/workspace"; | |
| 7 | 8 | import { | |
| 14 | 15 | ReviewPanel, | |
| 15 | 16 | TermsForm, | |
| 16 | 17 | } from "~/components/billing"; | |
| 17 | − | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 18 | + | import { MonthsChart } from "~/components/charts"; | |
| 19 | + | import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales"; | |
| 20 | + | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When, trustAbout } from "~/components/ui"; | |
| 18 | 21 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 19 | − | import { parseSlug } from "~/lib/forms"; | |
| 22 | + | import { parseSlug, text } from "~/lib/forms"; | |
| 20 | 23 | import { usd } from "~/lib/money"; | |
| 21 | − | import { type ActionData, type SectionError, doneMessage } from "~/lib/review"; | |
| 24 | + | import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review"; | |
| 25 | + | import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server"; | |
| 22 | 26 | import { admin, identity } from "~/lib/services.server"; | |
| 27 | + | import { settle } from "~/lib/settle"; | |
| 23 | 28 | import { requireStaff } from "~/lib/staff"; | |
| 24 | 29 | import type { Enterprise } from "~/lib/workspaces"; | |
| 25 | 30 | ||
| 49 | 54 | return { slug, person, detail, billedTo, subject }; | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 57 | + | /** Sales and invoices are newer billing methods; a page still opens without them. */ | |
| 58 | + | const SALES_DONE = new Set(["sales", "note"]); | |
| 59 | + | ||
| 52 | 60 | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 53 | − | requireStaff(context); | |
| 61 | + | const staff = requireStaff(context); | |
| 54 | 62 | const { slug, person, detail, billedTo, subject } = await load(params.slug); | |
| 55 | 63 | const { summary } = detail; | |
| 56 | 64 | // On an enterprise, the limit is the enterprise's and the figures are | |
| 60 | 68 | const figures = billedTo | |
| 61 | 69 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 62 | 70 | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| 63 | − | const enterprises = billedTo | |
| 64 | − | ? [] | |
| 65 | − | : (await admin.accounts()) | |
| 66 | − | .filter((row: AccountSummary) => row.account.kind === "enterprise") | |
| 67 | − | .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })); | |
| 71 | + | const [enterprises, sales, invoices] = await Promise.all([ | |
| 72 | + | billedTo | |
| 73 | + | ? Promise.resolve([]) | |
| 74 | + | : admin | |
| 75 | + | .accounts() | |
| 76 | + | .then((rows: AccountSummary[]) => | |
| 77 | + | rows | |
| 78 | + | .filter((row) => row.account.kind === "enterprise") | |
| 79 | + | .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })), | |
| 80 | + | ), | |
| 81 | + | settle(admin.sales(slug)), | |
| 82 | + | settle(admin.workspaceInvoices(slug)), | |
| 83 | + | ]); | |
| 84 | + | const done = doneKey(request.url); | |
| 68 | 85 | return { | |
| 86 | + | me: staff.email, | |
| 69 | 87 | slug, | |
| 70 | 88 | name: person?.name ?? slug, | |
| 71 | 89 | person, | |
| 73 | 91 | terms: subject.terms, | |
| 74 | 92 | limit, | |
| 75 | 93 | figures, | |
| 94 | + | months: summary.months ?? [], | |
| 76 | 95 | enterprises, | |
| 96 | + | sales: sales.ok ? sales.value : null, | |
| 97 | + | salesError: sales.ok ? null : sales.error, | |
| 98 | + | invoices: invoices.ok ? invoices.value : [], | |
| 99 | + | invoicesError: invoices.ok ? null : invoices.error, | |
| 77 | 100 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 78 | 101 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 79 | − | done: doneMessage(request.url), | |
| 102 | + | done: done && !SALES_DONE.has(done) ? DONE[done] : null, | |
| 103 | + | salesDone: done && SALES_DONE.has(done) ? DONE[done] : null, | |
| 80 | 104 | }; | |
| 81 | 105 | } | |
| 82 | 106 | ||
| 84 | 108 | const staff = requireStaff(context); | |
| 85 | 109 | // What is acted on comes from billing and identity, not from the form. | |
| 86 | 110 | const { slug, subject } = await load(params.slug); | |
| 87 | − | return billingAction(request, staff, subject, `/workspaces/${encodeURIComponent(slug)}`); | |
| 111 | + | const path = `/workspaces/${encodeURIComponent(slug)}`; | |
| 112 | + | const form = await request.clone().formData(); | |
| 113 | + | if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path); | |
| 114 | + | return billingAction(request, staff, subject, path); | |
| 88 | 115 | } | |
| 89 | 116 | ||
| 117 | + | const SECTIONS = [ | |
| 118 | + | { id: "members", label: "Members" }, | |
| 119 | + | { id: "sales", label: "Sales" }, | |
| 120 | + | { id: "billing", label: "Billing" }, | |
| 121 | + | { id: "invoices", label: "Invoices" }, | |
| 122 | + | { id: "ledger", label: "Ledger" }, | |
| 123 | + | { id: "audit", label: "Audit log" }, | |
| 124 | + | ]; | |
| 125 | + | ||
| 90 | 126 | export default function Workspace({ loaderData, actionData }: Route.ComponentProps) { | |
| 91 | − | const { slug, name, person, billedTo, terms, limit, figures, enterprises, ledger, audit, done } = loaderData; | |
| 127 | + | const { me, slug, name, person, billedTo, terms, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } = | |
| 128 | + | loaderData; | |
| 92 | 129 | const { pathname } = useLocation(); | |
| 93 | 130 | const result = actionData as ActionData | undefined; | |
| 94 | 131 | const review = result && "review" in result ? result.review : null; | |
| 98 | 135 | ||
| 99 | 136 | return ( | |
| 100 | 137 | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 101 | − | <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 138 | + | <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 102 | 139 | <ArrowLeft size={14} /> | |
| 103 | 140 | Workspaces | |
| 104 | 141 | </Link> | |
| 123 | 160 | <TermsBadge terms={terms} /> | |
| 124 | 161 | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 125 | 162 | <StateBadge state={limit.state} /> | |
| 163 | + | {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />} | |
| 126 | 164 | </div> | |
| 127 | 165 | </div> | |
| 128 | 166 | </div> | |
| 134 | 172 | )} | |
| 135 | 173 | </div> | |
| 136 | 174 | ||
| 137 | − | <div className="mt-6 space-y-3"> | |
| 175 | + | <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4"> | |
| 176 | + | <ul className="flex gap-1 border-b border-line text-sm"> | |
| 177 | + | {SECTIONS.map((section) => ( | |
| 178 | + | <li key={section.id}> | |
| 179 | + | <a href={`#${section.id}`} className="block border-b-2 border-transparent px-2.5 py-2 whitespace-nowrap text-muted transition-colors hover:border-line-strong hover:text-fg"> | |
| 180 | + | {section.label} | |
| 181 | + | </a> | |
| 182 | + | </li> | |
| 183 | + | ))} | |
| 184 | + | </ul> | |
| 185 | + | </nav> | |
| 186 | + | ||
| 187 | + | <div className="mt-6 space-y-3 empty:hidden"> | |
| 138 | 188 | {done && <Notice tone="ok">{done}</Notice>} | |
| 139 | 189 | {error("top") && <Notice tone="error">{error("top")?.error}</Notice>} | |
| 140 | 190 | {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>} | |
| 142 | 192 | </div> | |
| 143 | 193 | ||
| 144 | 194 | {/* People */} | |
| 145 | − | <Section title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6"> | |
| 195 | + | <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6"> | |
| 146 | 196 | {!person ? ( | |
| 147 | 197 | <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice> | |
| 148 | 198 | ) : person.members.length === 0 ? ( | |
| 180 | 230 | )} | |
| 181 | 231 | </Section> | |
| 182 | 232 | ||
| 233 | + | {/* Sales */} | |
| 234 | + | <div className="mt-6"> | |
| 235 | + | <SalesSection | |
| 236 | + | sales={sales} | |
| 237 | + | unavailable={salesError} | |
| 238 | + | me={me} | |
| 239 | + | pathname={pathname} | |
| 240 | + | salesError={error("sales")} | |
| 241 | + | noteError={error("note")} | |
| 242 | + | done={salesDone} | |
| 243 | + | /> | |
| 244 | + | </div> | |
| 245 | + | ||
| 183 | 246 | {/* Billing */} | |
| 184 | − | <h2 className="mt-10 text-lg font-semibold tracking-tight">Billing</h2> | |
| 247 | + | <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight"> | |
| 248 | + | Billing | |
| 249 | + | </h2> | |
| 185 | 250 | <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 186 | − | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 187 | − | <p className="mb-2 text-xs text-muted"> | |
| 188 | − | Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>} | |
| 189 | − | </p> | |
| 190 | − | <ExposureBar limit={limit} wide /> | |
| 191 | − | <p className="mt-2 text-xs text-faint"> | |
| 192 | − | {billedTo ? <>Limit via {billedTo.name}</> : <>Limit from trust {usd(limit.trustCeilingMicros)}</>} · owner's spend limit{" "} | |
| 193 | − | {usd(limit.spendLimitMicros)} | |
| 194 | − | {terms.ceilingMicros != null && <> · custom limit {usd(terms.ceilingMicros)}</>} | |
| 195 | − | </p> | |
| 196 | − | </div> | |
| 251 | + | <LimitCard limit={limit} terms={terms} billedTo={billedTo} /> | |
| 197 | 252 | <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} /> | |
| 198 | 253 | <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} /> | |
| 199 | 254 | </div> | |
| 200 | 255 | ||
| 256 | + | <Section | |
| 257 | + | title="Last six months" | |
| 258 | + | description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."} | |
| 259 | + | className="mt-6" | |
| 260 | + | > | |
| 261 | + | <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} /> | |
| 262 | + | </Section> | |
| 263 | + | ||
| 201 | 264 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 202 | 265 | <div className="space-y-6"> | |
| 203 | 266 | <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} /> | |
| 213 | 276 | ) : ( | |
| 214 | 277 | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 215 | 278 | )} | |
| 216 | − | <LedgerSection | |
| 217 | − | ledger={ledger} | |
| 218 | − | description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."} | |
| 219 | − | /> | |
| 279 | + | <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} /> | |
| 280 | + | <div id="ledger" className="scroll-mt-20"> | |
| 281 | + | <LedgerSection | |
| 282 | + | ledger={ledger} | |
| 283 | + | description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."} | |
| 284 | + | /> | |
| 285 | + | </div> | |
| 220 | 286 | </div> | |
| 221 | 287 | ||
| 222 | 288 | <div className="space-y-6"> | |
| 223 | 289 | <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} /> | |
| 224 | 290 | <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} /> | |
| 225 | − | <AuditSection | |
| 226 | − | audit={audit} | |
| 227 | − | description={ | |
| 228 | − | billedTo ? ( | |
| 229 | − | <> | |
| 230 | − | Changes about this workspace in {billedTo.name}'s log.{" "} | |
| 231 | − | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline"> | |
| 232 | − | Full log | |
| 233 | − | </Link> | |
| 234 | − | </> | |
| 235 | − | ) : undefined | |
| 236 | − | } | |
| 237 | − | /> | |
| 291 | + | <div id="audit" className="scroll-mt-20"> | |
| 292 | + | <AuditSection | |
| 293 | + | audit={audit} | |
| 294 | + | description={ | |
| 295 | + | billedTo ? ( | |
| 296 | + | <> | |
| 297 | + | Changes about this workspace in {billedTo.name}'s log.{" "} | |
| 298 | + | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline"> | |
| 299 | + | Full log | |
| 300 | + | </Link> | |
| 301 | + | </> | |
| 302 | + | ) : undefined | |
| 303 | + | } | |
| 304 | + | /> | |
| 305 | + | </div> | |
| 238 | 306 | </div> | |
| 239 | 307 | </div> | |
| 240 | 308 | </main> | |
| 241 | 309 | ); | |
| 242 | 310 | } | |
| 243 | 311 | ||
| 312 | + | /** | |
| 313 | + | * The limit, and in words how it is made: trust, the owners' own spend | |
| 314 | + | * limit (or the default), the most they may set, and how it grows. | |
| 315 | + | */ | |
| 316 | + | function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) { | |
| 317 | + | const lines: { label: string; value: ReactNode }[] = []; | |
| 318 | + | if (billedTo) { | |
| 319 | + | lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> }); | |
| 320 | + | } else if (terms.kind !== "comped") { | |
| 321 | + | lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> }); | |
| 322 | + | } | |
| 323 | + | if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) }); | |
| 324 | + | if (!billedTo && terms.kind !== "comped") { | |
| 325 | + | lines.push({ | |
| 326 | + | label: "Owners' limit", | |
| 327 | + | value: limit.defaultSpendLimit ? ( | |
| 328 | + | <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</> | |
| 329 | + | ) : limit.spendLimitMicros == null ? ( | |
| 330 | + | "None of their own: they use everything available" | |
| 331 | + | ) : ( | |
| 332 | + | <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</> | |
| 333 | + | ), | |
| 334 | + | }); | |
| 335 | + | if (limit.availableMicros !== undefined) { | |
| 336 | + | lines.push({ | |
| 337 | + | label: "Available", | |
| 338 | + | value: | |
| 339 | + | limit.availableMicros == null ? ( | |
| 340 | + | "No ceiling" | |
| 341 | + | ) : ( | |
| 342 | + | <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</> | |
| 343 | + | ), | |
| 344 | + | }); | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | if (limit.growth) lines.push({ label: "Grows", value: limit.growth }); | |
| 348 | + | ||
| 349 | + | return ( | |
| 350 | + | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 351 | + | <p className="mb-2 text-xs text-muted"> | |
| 352 | + | Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>} | |
| 353 | + | </p> | |
| 354 | + | <ExposureBar limit={limit} wide /> | |
| 355 | + | {lines.length > 0 && ( | |
| 356 | + | <dl className="mt-3 space-y-1 text-xs"> | |
| 357 | + | {lines.map((line) => ( | |
| 358 | + | <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2"> | |
| 359 | + | <dt className="text-faint">{line.label}</dt> | |
| 360 | + | <dd className="text-fg-soft">{line.value}</dd> | |
| 361 | + | </div> | |
| 362 | + | ))} | |
| 363 | + | </dl> | |
| 364 | + | )} | |
| 365 | + | </div> | |
| 366 | + | ); | |
| 367 | + | } | |
| 368 | + | ||
| 244 | 369 | function BilledToSection({ | |
| 245 | 370 | billedTo, | |
| 246 | 371 | enterprises, |
| 1 | − | import { redirect } from "react-router"; | |
| 2 | − | ||
| 3 | − | import type { Route } from "./+types/workspaces-redirect"; | |
| 4 | − | import { requireStaff } from "~/lib/staff"; | |
| 5 | − | ||
| 6 | − | /** The workspaces list lives at `/`; `/workspaces` goes there too. */ | |
| 7 | − | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 8 | − | requireStaff(context); | |
| 9 | − | return redirect(`/${new URL(request.url).search}`); | |
| 10 | − | } | |
| 11 | − | ||
| 12 | − | export default function WorkspacesRedirect() { | |
| 13 | − | return null; | |
| 14 | − | } |
| 79 | 79 | for (const filter of show) params.append("show", filter); | |
| 80 | 80 | if (page > 1) params.set("page", String(page)); | |
| 81 | 81 | const query = params.toString(); | |
| 82 | − | return query ? `/?${query}` : "/"; | |
| 82 | + | return query ? `/workspaces?${query}` : "/workspaces"; | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | 85 | function workspaceHref(row: WorkspaceRow) { | |
| 115 | 115 | /> | |
| 116 | 116 | </div> | |
| 117 | 117 | ||
| 118 | − | <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center"> | |
| 118 | + | <form method="get" action="/workspaces" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center"> | |
| 119 | 119 | <div className="relative grow"> | |
| 120 | 120 | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 121 | 121 | <input | |
| 144 | 144 | Apply | |
| 145 | 145 | </Button> | |
| 146 | 146 | {filtered && ( | |
| 147 | − | <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 147 | + | <Link to="/workspaces" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 148 | 148 | Clear | |
| 149 | 149 | </Link> | |
| 150 | 150 | )} |
| 53 | 53 | DropdownMenuSeparator, | |
| 54 | 54 | DropdownMenuTrigger, | |
| 55 | 55 | } from "./ui/dropdown-menu"; | |
| 56 | + | import { type RoadmapItem, roadmapIn } from "../lib/roadmap"; | |
| 56 | 57 | ||
| 57 | 58 | /** What the sidebar needs, worked out by the root loader for a signed-in person. */ | |
| 58 | 59 | export type ShellData = { | |
| 266 | 267 | ); | |
| 267 | 268 | } | |
| 268 | 269 | ||
| 269 | − | /** A page a section will have: shown so people can see where g1t is going. */ | |
| 270 | − | function SidebarSubSoon({ about, children }: { about: string; children: ReactNode }) { | |
| 270 | + | /** | |
| 271 | + | * A page a section will have: a link to a page saying what it will be, | |
| 272 | + | * so people can see where g1t is going, and read about it. | |
| 273 | + | */ | |
| 274 | + | function SidebarSubSoon({ to, about, children }: { to: string; about: string; children: ReactNode }) { | |
| 271 | 275 | return ( | |
| 272 | − | <div | |
| 276 | + | <NavLink | |
| 277 | + | to={to} | |
| 273 | 278 | title={about} | |
| 274 | − | aria-disabled="true" | |
| 275 | − | className="flex h-8 cursor-default items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] text-faint" | |
| 279 | + | prefetch="intent" | |
| 280 | + | className={({ isActive }) => | |
| 281 | + | `relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${ | |
| 282 | + | isActive | |
| 283 | + | ? "bg-raised text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent" | |
| 284 | + | : "text-faint hover:bg-raised/60 hover:text-muted" | |
| 285 | + | }` | |
| 286 | + | } | |
| 276 | 287 | > | |
| 277 | 288 | <span className="grow truncate">{children}</span> | |
| 278 | 289 | <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line"> | |
| 279 | 290 | Soon | |
| 280 | 291 | </span> | |
| 281 | − | </div> | |
| 292 | + | </NavLink> | |
| 282 | 293 | ); | |
| 283 | 294 | } | |
| 284 | 295 | ||
| 296 | + | /** A section's Soon pages, from the roadmap. */ | |
| 297 | + | function SoonItems({ base, section }: { base: string; section: RoadmapItem["section"] }) { | |
| 298 | + | return ( | |
| 299 | + | <> | |
| 300 | + | {roadmapIn(section).map((item) => ( | |
| 301 | + | <SidebarSubSoon key={item.key} to={`${base}/soon/${item.key}`} about={item.summary}> | |
| 302 | + | {item.title} | |
| 303 | + | </SidebarSubSoon> | |
| 304 | + | ))} | |
| 305 | + | </> | |
| 306 | + | ); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /** A section's own pages and its Soon pages, for opening it when current. */ | |
| 310 | + | function sectionPaths(base: string, section: RoadmapItem["section"], own: string[]): string[] { | |
| 311 | + | return [...own, ...roadmapIn(section).map((item) => `${base}/soon/${item.key}`)]; | |
| 312 | + | } | |
| 313 | + | ||
| 285 | 314 | function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { | |
| 286 | 315 | return ( | |
| 287 | 316 | <section className="mt-6"> | |
| 543 | 572 | Overview | |
| 544 | 573 | </SidebarLink> | |
| 545 | 574 | ||
| 546 | − | <SidebarSection title="Plan" icon={<Kanban size={15} />} paths={[`${base}/issues`, `${base}/plans`]}> | |
| 575 | + | <SidebarSection | |
| 576 | + | title="Work" | |
| 577 | + | icon={<Kanban size={15} />} | |
| 578 | + | paths={sectionPaths(base, "Work", [`${base}/issues`, `${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/plans`])} | |
| 579 | + | > | |
| 547 | 580 | <SidebarSubLink to={`${base}/issues`} count={repo.issues}> | |
| 548 | 581 | Issues | |
| 549 | 582 | </SidebarSubLink> | |
| 583 | + | <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}> | |
| 584 | + | Pull requests | |
| 585 | + | </SidebarSubLink> | |
| 586 | + | <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink> | |
| 550 | 587 | {repo.member && <SidebarSubLink to={`${base}/plans`}>Outcomes</SidebarSubLink>} | |
| 551 | − | <SidebarSubSoon about="Issues and pull requests on boards, by state, owner or outcome.">Boards</SidebarSubSoon> | |
| 552 | − | <SidebarSubSoon about="Dates to land outcomes by, with what is left and what is at risk.">Milestones</SidebarSubSoon> | |
| 553 | − | <SidebarSubSoon about="Pages about the project that agents keep current as the code changes.">Wiki</SidebarSubSoon> | |
| 588 | + | <SoonItems base={base} section="Work" /> | |
| 554 | 589 | </SidebarSection> | |
| 555 | 590 | ||
| 556 | 591 | <SidebarSection | |
| 557 | 592 | title="Code" | |
| 558 | 593 | icon={<Code2 size={15} />} | |
| 559 | − | paths={[`${base}/pulls`, `${base}/pull`, `${base}/queue`, `${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`]} | |
| 594 | + | paths={sectionPaths(base, "Code", [`${base}/code`, `${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`])} | |
| 560 | 595 | > | |
| 561 | − | <SidebarSubLink to={`${base}/pulls`} also={`${base}/pull`} count={repo.pulls}> | |
| 562 | − | Pull requests | |
| 563 | − | </SidebarSubLink> | |
| 564 | − | <SidebarSubLink to={`${base}/queue`}>Merge queue</SidebarSubLink> | |
| 565 | 596 | <SidebarSubLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`]}> | |
| 566 | 597 | Files | |
| 567 | 598 | </SidebarSubLink> | |
| 568 | 599 | <SidebarSubLink to={`${base}/commits`} also={`${base}/commit`}> | |
| 569 | 600 | Commits | |
| 570 | 601 | </SidebarSubLink> | |
| 571 | − | <SidebarSubSoon about="Every branch, who and what is on it, how far behind it is, and its preview.">Branches</SidebarSubSoon> | |
| 572 | − | <SidebarSubSoon about="Tags, and the releases made from them.">Tags</SidebarSubSoon> | |
| 573 | − | </SidebarSection> | |
| 574 | − | ||
| 575 | − | <SidebarSection title="Agents" icon={<Bot size={15} />} soon> | |
| 576 | − | <SidebarSubSoon about="Every agent at work on this project now: what it is doing, what it knows, and a way to steer it."> | |
| 577 | − | At work | |
| 578 | − | </SidebarSubSoon> | |
| 579 | − | <SidebarSubSoon about="Every agent session that changed this project, searchable, with why-blame back to the lines it wrote."> | |
| 580 | − | Sessions | |
| 581 | − | </SidebarSubSoon> | |
| 582 | − | <SidebarSubSoon about="How agents should work here: conventions, commands and checks, kept with the code."> | |
| 583 | − | Playbooks | |
| 584 | − | </SidebarSubSoon> | |
| 602 | + | <SoonItems base={base} section="Code" /> | |
| 585 | 603 | </SidebarSection> | |
| 586 | 604 | ||
| 587 | − | <SidebarSection title="Build" icon={<PlayCircle size={15} />} paths={[`${base}/actions`]}> | |
| 588 | − | <SidebarSubLink to={`${base}/actions`}>Actions</SidebarSubLink> | |
| 589 | − | <SidebarSubSoon about="g1t's machines, or your own, that run workflow jobs and checks.">Runners</SidebarSubSoon> | |
| 590 | − | <SidebarSubSoon about="What builds and workflow jobs produce, kept and downloadable.">Artifacts</SidebarSubSoon> | |
| 591 | − | <SidebarSubSoon about="Workflows that run on a timetable, and their history.">Schedules</SidebarSubSoon> | |
| 605 | + | <SidebarSection title="Agents" icon={<Bot size={15} />} paths={sectionPaths(base, "Agents", [])} soon> | |
| 606 | + | <SoonItems base={base} section="Agents" /> | |
| 592 | 607 | </SidebarSection> | |
| 593 | 608 | ||
| 594 | − | <SidebarSection title="Deploy" icon={<Rocket size={15} />} paths={repo.member ? [`${base}/deployments`] : []}> | |
| 609 | + | <SidebarSection | |
| 610 | + | title="Ship" | |
| 611 | + | icon={<Rocket size={15} />} | |
| 612 | + | paths={sectionPaths(base, "Ship", [`${base}/actions`, ...(repo.member ? [`${base}/deployments`] : [])])} | |
| 613 | + | > | |
| 614 | + | <SidebarSubLink to={`${base}/actions`}>Workflows</SidebarSubLink> | |
| 595 | 615 | {repo.member && <SidebarSubLink to={`${base}/deployments`}>Deployments</SidebarSubLink>} | |
| 596 | − | <SidebarSubSoon about="Staging and other environments, with required approvers and branch rules.">Environments</SidebarSubSoon> | |
| 597 | − | <SidebarSubSoon about="Tagged releases with notes written from what landed.">Releases</SidebarSubSoon> | |
| 598 | − | <SidebarSubSoon about="npm, container and other packages published from the project.">Packages</SidebarSubSoon> | |
| 599 | − | <SidebarSubSoon about="Turn features on per environment or per user, without a deploy.">Feature flags</SidebarSubSoon> | |
| 616 | + | <SoonItems base={base} section="Ship" /> | |
| 600 | 617 | </SidebarSection> | |
| 601 | 618 | ||
| 602 | − | <SidebarSection title="Secure" icon={<ShieldCheck size={15} />} soon> | |
| 603 | − | <SidebarSubSoon about="Every finding in one place, by severity, with the agent fixing each.">Security overview</SidebarSubSoon> | |
| 604 | − | <SidebarSubSoon about="Keys and tokens found in the code or its history, revoked and removed by an agent."> | |
| 605 | − | Secret scanning | |
| 606 | − | </SidebarSubSoon> | |
| 607 | − | <SidebarSubSoon about="Outdated and vulnerable packages, updated by agents, tested and landed through the queue."> | |
| 608 | − | Dependency updates | |
| 609 | − | </SidebarSubSoon> | |
| 610 | − | <SidebarSubSoon about="Code scanned for vulnerabilities on every change, each finding fixed through the queue."> | |
| 611 | − | Code scanning | |
| 612 | − | </SidebarSubSoon> | |
| 619 | + | <SidebarSection title="Security" icon={<ShieldCheck size={15} />} paths={sectionPaths(base, "Security", [])} soon> | |
| 620 | + | <SoonItems base={base} section="Security" /> | |
| 613 | 621 | </SidebarSection> | |
| 614 | 622 | ||
| 615 | − | <SidebarSection title="Operate" icon={<Activity size={15} />} soon> | |
| 616 | − | <SidebarSubSoon about="Requests, errors and CPU time of each deployment, and its logs.">Logs and metrics</SidebarSubSoon> | |
| 617 | − | <SidebarSubSoon about="Errors and incidents from the running app, each becoming an issue an agent can take."> | |
| 618 | − | Errors and incidents | |
| 619 | − | </SidebarSubSoon> | |
| 620 | − | <SidebarSubSoon about="Checks that the app answers, from around the world, and who is told when it does not.">Uptime</SidebarSubSoon> | |
| 623 | + | <SidebarSection title="Observe" icon={<Activity size={15} />} paths={sectionPaths(base, "Observe", [])} soon> | |
| 624 | + | <SoonItems base={base} section="Observe" /> | |
| 621 | 625 | </SidebarSection> | |
| 622 | 626 | ||
| 623 | − | <SidebarSection title="Analyze" icon={<BarChart3 size={15} />} soon> | |
| 624 | − | <SidebarSubSoon about="How work flows: lead time, review time, and the share of changes agents make.">Insights</SidebarSubSoon> | |
| 625 | − | <SidebarSubSoon about="What the project costs, by agent run, sandbox, build and app.">Costs</SidebarSubSoon> | |
| 626 | − | <SidebarSubSoon about="Deploy frequency, lead time, change failure rate and time to restore.">Delivery metrics</SidebarSubSoon> | |
| 627 | + | <SidebarSection title="Insights" icon={<BarChart3 size={15} />} paths={sectionPaths(base, "Insights", [])} soon> | |
| 628 | + | <SoonItems base={base} section="Insights" /> | |
| 627 | 629 | </SidebarSection> | |
| 628 | 630 | ||
| 629 | 631 | {repo.member && ( | |
| 854 | 856 | pulls: "Pull requests", | |
| 855 | 857 | queue: "Merge queue", | |
| 856 | 858 | commits: "Commits", | |
| 857 | − | plans: "Plan", | |
| 858 | − | actions: "Actions", | |
| 859 | + | plans: "Outcomes", | |
| 860 | + | actions: "Workflows", | |
| 861 | + | soon: "Soon", | |
| 859 | 862 | deployments: "Deployments", | |
| 860 | 863 | repository: "Repository", | |
| 861 | 864 | dependencies: "Dependencies", | |
| 862 | − | code: "Code", | |
| 865 | + | code: "Files", | |
| 863 | 866 | secrets: "Secrets and variables", | |
| 864 | 867 | settings: "Settings", | |
| 865 | 868 | people: "Members", |
| 1 | + | /** | |
| 2 | + | * What a project will have and does not yet: the pages the project menu | |
| 3 | + | * shows as Soon, each with a page of its own saying what it will be. One | |
| 4 | + | * place, so the menu and those pages never disagree. | |
| 5 | + | * | |
| 6 | + | * Each entry says what it is for, what it will do, and what to use today, | |
| 7 | + | * so a Soon is a promise someone can read, not a greyed-out word. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | export type RoadmapItem = { | |
| 11 | + | /** In the address: `/<owner>/<project>/soon/<key>`. */ | |
| 12 | + | key: string; | |
| 13 | + | title: string; | |
| 14 | + | /** The menu section it sits in. */ | |
| 15 | + | section: "Work" | "Code" | "Agents" | "Ship" | "Security" | "Observe" | "Insights"; | |
| 16 | + | /** One line, for the menu's tooltip and the page's lead. */ | |
| 17 | + | summary: string; | |
| 18 | + | /** Why it matters, in two or three sentences. */ | |
| 19 | + | why: string; | |
| 20 | + | /** What it will do. */ | |
| 21 | + | plans: string[]; | |
| 22 | + | /** What to use until it exists: a project page, by its path under the project. */ | |
| 23 | + | today?: { label: string; path: string }; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | export const ROADMAP: RoadmapItem[] = [ | |
| 27 | + | // --- Work --------------------------------------------------------------- | |
| 28 | + | { | |
| 29 | + | key: "board", | |
| 30 | + | title: "Board", | |
| 31 | + | section: "Work", | |
| 32 | + | summary: "Issues and pull requests as a board, a table or a roadmap, with fields of your own.", | |
| 33 | + | why: "Like GitHub Projects: one view of everything in flight, arranged the way your team thinks about it. Agents move the cards as they work, so the board is never out of date.", | |
| 34 | + | plans: [ | |
| 35 | + | "Board, table and roadmap views of the same items, saved and shared", | |
| 36 | + | "Custom fields: status, priority, size, iteration, dates, anything", | |
| 37 | + | "Group and filter by owner, outcome, label, agent or state", | |
| 38 | + | "Cards move by themselves as agents open, revise and land changes", | |
| 39 | + | "Boards across projects, for a workspace's whole portfolio", | |
| 40 | + | ], | |
| 41 | + | today: { label: "Issues", path: "issues" }, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | key: "roadmap", | |
| 45 | + | title: "Roadmap", | |
| 46 | + | section: "Work", | |
| 47 | + | summary: "Outcomes on a timeline, with their dependencies across projects.", | |
| 48 | + | why: "An outcome is what should be true when the work is done. On a roadmap you see when each is expected, what it waits on in other projects, and how much has landed.", | |
| 49 | + | plans: [ | |
| 50 | + | "Outcomes as bars on a timeline, from start to target", | |
| 51 | + | "Progress from merged work, not from guesses", | |
| 52 | + | "Dependencies across projects, drawn from the project graph", | |
| 53 | + | "Slip warnings when the work left outgrows the time left", | |
| 54 | + | ], | |
| 55 | + | today: { label: "Outcomes", path: "plans" }, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | key: "milestones", | |
| 59 | + | title: "Milestones", | |
| 60 | + | section: "Work", | |
| 61 | + | summary: "Dates to land outcomes by, with what is left and what is at risk.", | |
| 62 | + | why: "A milestone groups the issues that must land together, by a date. g1t shows what remains and which agent or person holds each part.", | |
| 63 | + | plans: [ | |
| 64 | + | "Issues and outcomes grouped under a due date", | |
| 65 | + | "Burn-down from merged pull requests", | |
| 66 | + | "At-risk items flagged before the date, not after", | |
| 67 | + | ], | |
| 68 | + | today: { label: "Issues", path: "issues" }, | |
| 69 | + | }, | |
| 70 | + | ||
| 71 | + | // --- Code --------------------------------------------------------------- | |
| 72 | + | { | |
| 73 | + | key: "branches", | |
| 74 | + | title: "Branches", | |
| 75 | + | section: "Code", | |
| 76 | + | summary: "Every branch: who is on it, how far behind it is, and its preview.", | |
| 77 | + | why: "With agents opening branches by the dozen, a list of names is not enough. Each branch shows its pull request, its checks, its live preview and how stale it is.", | |
| 78 | + | plans: [ | |
| 79 | + | "Branches with their pull request, checks and preview address", | |
| 80 | + | "Ahead and behind the default branch, with one-click catch-up by an agent", | |
| 81 | + | "Protection rules: required checks, reviews and the merge queue", | |
| 82 | + | "Stale branches cleaned up on a schedule you set", | |
| 83 | + | ], | |
| 84 | + | today: { label: "Pull requests", path: "pulls" }, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | key: "tags", | |
| 88 | + | title: "Tags", | |
| 89 | + | section: "Code", | |
| 90 | + | summary: "Tags, and the releases made from them.", | |
| 91 | + | why: "A tag marks a version of the code. Each links to its release notes and to the deployment that shipped it.", | |
| 92 | + | plans: ["Tags with their commit, release and deployment", "Signed tags verified", "Rules for who may create and move them"], | |
| 93 | + | today: { label: "Commits", path: "commits" }, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | key: "compare", | |
| 97 | + | title: "Compare", | |
| 98 | + | section: "Code", | |
| 99 | + | summary: "Any two branches, tags or commits, side by side.", | |
| 100 | + | why: "See exactly what changed between two points, with the sessions and pull requests that changed it.", | |
| 101 | + | plans: ["Diff any two refs", "The pull requests and agent sessions between them", "Open a pull request from the comparison"], | |
| 102 | + | today: { label: "Commits", path: "commits" }, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | key: "docs", | |
| 106 | + | title: "Docs", | |
| 107 | + | section: "Code", | |
| 108 | + | summary: "Pages about the project that agents keep current as the code changes.", | |
| 109 | + | why: "A wiki goes stale the day it is written. g1t's docs live with the code, and when a change makes a page wrong, an agent proposes the fix in the same pull request.", | |
| 110 | + | plans: [ | |
| 111 | + | "Pages written in Markdown, kept in the repository", | |
| 112 | + | "Agents update pages a change makes wrong, in the same pull request", | |
| 113 | + | "Architecture pages drawn from the code itself", | |
| 114 | + | "Search across every project's docs", | |
| 115 | + | ], | |
| 116 | + | today: { label: "Files", path: "code" }, | |
| 117 | + | }, | |
| 118 | + | ||
| 119 | + | // --- Agents ------------------------------------------------------------- | |
| 120 | + | { | |
| 121 | + | key: "agents", | |
| 122 | + | title: "At work", | |
| 123 | + | section: "Agents", | |
| 124 | + | summary: "Every agent at work on this project now, and a way to steer it.", | |
| 125 | + | why: "Agents are the project's busiest contributors. See each one live: what issue it holds, what it is doing this minute, what it has spent, and send it a word mid-run.", | |
| 126 | + | plans: [ | |
| 127 | + | "Live view of each running agent, with its current step", | |
| 128 | + | "Steer, pause or stop a run, and hand it to a person", | |
| 129 | + | "What each run has cost so far", | |
| 130 | + | "Your own agents and g1t's side by side", | |
| 131 | + | ], | |
| 132 | + | today: { label: "Pull requests", path: "pulls" }, | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | key: "sessions", | |
| 136 | + | title: "Sessions", | |
| 137 | + | section: "Agents", | |
| 138 | + | summary: "Every agent session that changed this project, searchable.", | |
| 139 | + | why: "When an agent writes a line, its reasoning is worth keeping. Every session is kept, searchable, and linked from the lines it wrote, so why-blame answers why the code is the way it is.", | |
| 140 | + | plans: [ | |
| 141 | + | "Every session, with its prompt, steps, tools and result", | |
| 142 | + | "Search by file, issue, agent or words", | |
| 143 | + | "Why-blame: from any line to the session that wrote it", | |
| 144 | + | "Sessions as context for the next agent on the same code", | |
| 145 | + | ], | |
| 146 | + | today: { label: "Commits", path: "commits" }, | |
| 147 | + | }, | |
| 148 | + | { | |
| 149 | + | key: "playbooks", | |
| 150 | + | title: "Playbooks", | |
| 151 | + | section: "Agents", | |
| 152 | + | summary: "How agents should work here: conventions, commands and checks.", | |
| 153 | + | why: "Every project has its own way of doing things. A playbook tells every agent how this one builds, tests, names things and what it must never touch, kept with the code and versioned with it.", | |
| 154 | + | plans: [ | |
| 155 | + | "Instructions per kind of work: fixes, features, reviews, upgrades", | |
| 156 | + | "Commands to build and test, run before every change", | |
| 157 | + | "Paths agents may not touch without a person", | |
| 158 | + | "Learned from reviews: what people corrected becomes a rule", | |
| 159 | + | ], | |
| 160 | + | today: { label: "Settings", path: "settings" }, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | key: "memory", | |
| 164 | + | title: "Memory", | |
| 165 | + | section: "Agents", | |
| 166 | + | summary: "What agents have learned about this project, kept and curated.", | |
| 167 | + | why: "Agents that forget repeat mistakes. g1t keeps what they learn about the code, its quirks and its people, and lets you see, edit and delete it.", | |
| 168 | + | plans: ["Facts agents learned, with where they learned them", "Edit or remove anything", "Shared across every agent on the project"], | |
| 169 | + | }, | |
| 170 | + | ||
| 171 | + | // --- Ship --------------------------------------------------------------- | |
| 172 | + | { | |
| 173 | + | key: "environments", | |
| 174 | + | title: "Environments", | |
| 175 | + | section: "Ship", | |
| 176 | + | summary: "Staging, production and others, with approvers and branch rules.", | |
| 177 | + | why: "Production and previews exist today. Environments add the rest: staging, QA, per-customer, each with its own variables, approvers and rules for what may deploy there.", | |
| 178 | + | plans: [ | |
| 179 | + | "Named environments with their own variables and secrets", | |
| 180 | + | "Required approvers before a deploy", | |
| 181 | + | "Branch rules: what may deploy where", | |
| 182 | + | "Promote a deployment from one environment to the next", | |
| 183 | + | ], | |
| 184 | + | today: { label: "Deployments", path: "deployments" }, | |
| 185 | + | }, | |
| 186 | + | { | |
| 187 | + | key: "releases", | |
| 188 | + | title: "Releases", | |
| 189 | + | section: "Ship", | |
| 190 | + | summary: "Tagged releases with notes written from what landed.", | |
| 191 | + | why: "Release notes from the pull requests and sessions that made the release: what changed, why, and who or what changed it.", | |
| 192 | + | plans: ["Notes drafted from merged work", "Assets and checksums attached", "Published to the project's page and a feed"], | |
| 193 | + | today: { label: "Commits", path: "commits" }, | |
| 194 | + | }, | |
| 195 | + | { | |
| 196 | + | key: "packages", | |
| 197 | + | title: "Packages", | |
| 198 | + | section: "Ship", | |
| 199 | + | summary: "npm, container and other packages published from the project.", | |
| 200 | + | why: "Publish packages from workflows to g1t's registry, with the same access as the code.", | |
| 201 | + | plans: ["npm, OCI containers, Cargo, PyPI and Go modules", "Published from workflows", "Private packages for the workspace"], | |
| 202 | + | today: { label: "Workflows", path: "actions" }, | |
| 203 | + | }, | |
| 204 | + | { | |
| 205 | + | key: "flags", | |
| 206 | + | title: "Feature flags", | |
| 207 | + | section: "Ship", | |
| 208 | + | summary: "Turn features on per environment or per user, without a deploy.", | |
| 209 | + | why: "Ship code dark and turn it on when ready, for some users first, at the edge, with no deploy.", | |
| 210 | + | plans: ["Flags read at the edge by deployed apps", "Rollouts by percentage, user or environment", "Flags cleaned up by agents when fully on"], | |
| 211 | + | today: { label: "Deployments", path: "deployments" }, | |
| 212 | + | }, | |
| 213 | + | ||
| 214 | + | // --- Security ------------------------------------------------------------- | |
| 215 | + | { | |
| 216 | + | key: "security", | |
| 217 | + | title: "Overview", | |
| 218 | + | section: "Security", | |
| 219 | + | summary: "Every security finding in one place, with the agent fixing each.", | |
| 220 | + | why: "Findings are worth something only when they are fixed. Each one here becomes an issue an agent takes, tested and landed through the merge queue.", | |
| 221 | + | plans: ["Findings by severity across scanners", "Each fixed by an agent through the queue", "A security policy and how to report to you"], | |
| 222 | + | }, | |
| 223 | + | { | |
| 224 | + | key: "secret-scanning", | |
| 225 | + | title: "Secret scanning", | |
| 226 | + | section: "Security", | |
| 227 | + | summary: "Keys and tokens found in the code or its history, revoked and removed.", | |
| 228 | + | why: "A leaked key is an incident. g1t blocks pushes that carry one, finds old ones in history, and helps revoke them.", | |
| 229 | + | plans: ["Push protection for known key formats", "History scanned for leaks", "Revocation with the provider, where it allows"], | |
| 230 | + | }, | |
| 231 | + | { | |
| 232 | + | key: "dependency-updates", | |
| 233 | + | title: "Dependency updates", | |
| 234 | + | section: "Security", | |
| 235 | + | summary: "Outdated and vulnerable packages, updated by agents.", | |
| 236 | + | why: "Like Dependabot, but the agent also fixes what the upgrade breaks, so updates land instead of piling up.", | |
| 237 | + | plans: ["Vulnerable packages first, then outdated ones", "Breaking changes fixed in the same pull request", "Grouped, scheduled and landed through the queue"], | |
| 238 | + | }, | |
| 239 | + | { | |
| 240 | + | key: "code-scanning", | |
| 241 | + | title: "Code scanning", | |
| 242 | + | section: "Security", | |
| 243 | + | summary: "Code scanned for vulnerabilities on every change.", | |
| 244 | + | why: "Scanning on every pull request, with findings explained in the review and fixed by the author's agent.", | |
| 245 | + | plans: ["Static analysis on every pull request", "Findings in the review, with a fix", "Baselines so only new findings block"], | |
| 246 | + | }, | |
| 247 | + | { | |
| 248 | + | key: "firewall", | |
| 249 | + | title: "Firewall", | |
| 250 | + | section: "Security", | |
| 251 | + | summary: "Rules for who may reach the project's deployed apps.", | |
| 252 | + | why: "Deployed apps run on Cloudflare. Rate limits, bot protection and IP rules, per app and environment.", | |
| 253 | + | plans: ["Rate limits and bot protection", "IP and country rules", "Password-protected previews"], | |
| 254 | + | today: { label: "Deployments", path: "deployments" }, | |
| 255 | + | }, | |
| 256 | + | ||
| 257 | + | // --- Observe ------------------------------------------------------------ | |
| 258 | + | { | |
| 259 | + | key: "logs", | |
| 260 | + | title: "Logs and metrics", | |
| 261 | + | section: "Observe", | |
| 262 | + | summary: "Requests, errors and CPU time of each deployment, and its logs.", | |
| 263 | + | why: "Every deployed app's logs and numbers, by deployment, so a regression points at the change that caused it.", | |
| 264 | + | plans: ["Live and searchable logs", "Requests, errors, latency and CPU by deployment", "Compare a preview with production"], | |
| 265 | + | today: { label: "Deployments", path: "deployments" }, | |
| 266 | + | }, | |
| 267 | + | { | |
| 268 | + | key: "errors", | |
| 269 | + | title: "Errors and incidents", | |
| 270 | + | section: "Observe", | |
| 271 | + | summary: "Errors from the running app, each becoming an issue an agent can take.", | |
| 272 | + | why: "An error in production should become a fix, not a dashboard. Each new error is grouped, explained, and turned into an issue with the context an agent needs.", | |
| 273 | + | plans: ["Errors grouped with stack and request", "One click to an issue for an agent", "Incidents with a timeline and who was told"], | |
| 274 | + | today: { label: "Issues", path: "issues" }, | |
| 275 | + | }, | |
| 276 | + | { | |
| 277 | + | key: "uptime", | |
| 278 | + | title: "Uptime", | |
| 279 | + | section: "Observe", | |
| 280 | + | summary: "Checks that the app answers, from around the world.", | |
| 281 | + | why: "Know the app is down before your users tell you, and who was told.", | |
| 282 | + | plans: ["Checks from many places", "Alerts by email and webhook", "A public status page"], | |
| 283 | + | }, | |
| 284 | + | { | |
| 285 | + | key: "analytics", | |
| 286 | + | title: "Web analytics", | |
| 287 | + | section: "Observe", | |
| 288 | + | summary: "Who visits the deployed apps, without cookies.", | |
| 289 | + | why: "Visits, pages and referrers for each app, private by design, from Cloudflare's own analytics.", | |
| 290 | + | plans: ["Visits, pages, referrers and countries", "No cookies, no personal data", "Per deployment and per preview"], | |
| 291 | + | }, | |
| 292 | + | ||
| 293 | + | // --- Insights ------------------------------------------------------------- | |
| 294 | + | { | |
| 295 | + | key: "delivery", | |
| 296 | + | title: "Delivery", | |
| 297 | + | section: "Insights", | |
| 298 | + | summary: "Lead time, deploy frequency, change failure rate and time to restore.", | |
| 299 | + | why: "The four numbers that say how well a team ships, measured from what actually happened in g1t.", | |
| 300 | + | plans: ["DORA metrics from merges and deployments", "Review and queue time", "Trends by week and by project"], | |
| 301 | + | }, | |
| 302 | + | { | |
| 303 | + | key: "costs", | |
| 304 | + | title: "Costs", | |
| 305 | + | section: "Insights", | |
| 306 | + | summary: "What the project costs, by agent run, sandbox, build and app.", | |
| 307 | + | why: "Usage is charged by the workspace; here it is broken down for one project, so you can see what each part of the work costs.", | |
| 308 | + | plans: ["Cost by kind of work and by issue", "Cost per merged change", "Budgets per project"], | |
| 309 | + | }, | |
| 310 | + | { | |
| 311 | + | key: "impact", | |
| 312 | + | title: "Agent impact", | |
| 313 | + | section: "Insights", | |
| 314 | + | summary: "How much of the work agents do, and how well.", | |
| 315 | + | why: "The share of changes agents author, how often their work lands first time, and where people still step in.", | |
| 316 | + | plans: ["Agents' share of merged changes", "First-time pass rate of checks and reviews", "Where people correct agents most"], | |
| 317 | + | }, | |
| 318 | + | ]; | |
| 319 | + | ||
| 320 | + | export function roadmapItem(key: string): RoadmapItem | undefined { | |
| 321 | + | return ROADMAP.find((item) => item.key === key); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | export function roadmapIn(section: RoadmapItem["section"]): RoadmapItem[] { | |
| 325 | + | return ROADMAP.filter((item) => item.section === section); | |
| 326 | + | } |
| 61 | 61 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 62 | 62 | route("settings/deployments", "routes/repo/settings-deployments.tsx"), | |
| 63 | 63 | route("settings/dependencies", "routes/repo/settings-dependencies.tsx"), | |
| 64 | + | // What the project will have: one page for each Soon in its menu. | |
| 65 | + | route("soon/:feature", "routes/repo/soon.tsx"), | |
| 64 | 66 | ]), | |
| 65 | 67 | // Anything else: a 404 that still knows who is signed in. | |
| 66 | 68 | route("*", "routes/not-found.tsx"), |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | ||
| 14 | 14 | export function meta({ params }: Route.MetaArgs) { | |
| 15 | − | return [{ title: `Actions · ${params.owner}/${params.repo} · g1t` }]; | |
| 15 | + | return [{ title: `Workflows · ${params.owner}/${params.repo} · g1t` }]; | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 264 | 264 | <div> | |
| 265 | 265 | <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 266 | 266 | <PlayCircle size={18} className="text-accent" /> | |
| 267 | − | Actions | |
| 267 | + | Workflows | |
| 268 | 268 | </h2> | |
| 269 | 269 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 270 | − | GitHub Actions workflows in <code className="text-fg">.g1t/workflows</code>, run as GitHub runs them. A run on | |
| 271 | − | a pull request counts as its checks. | |
| 270 | + | Workflows in <code className="text-fg">.g1t/workflows</code>, written as GitHub Actions and run as GitHub runs | |
| 271 | + | them. A run on a pull request counts as its checks. | |
| 272 | 272 | </p> | |
| 273 | 273 | </div> | |
| 274 | 274 | <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg"> | |
| 275 | − | How Actions run on g1t | |
| 275 | + | How workflows run on g1t | |
| 276 | 276 | </a> | |
| 277 | 277 | </header> | |
| 278 | 278 |
| 1 | + | import { ArrowRight, Check, Sparkles } from "lucide-react"; | |
| 2 | + | import { Link, data } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/soon"; | |
| 5 | + | import { ROADMAP, roadmapIn, roadmapItem } from "../../lib/roadmap"; | |
| 6 | + | ||
| 7 | + | export function meta({ params }: Route.MetaArgs) { | |
| 8 | + | const item = roadmapItem(params.feature); | |
| 9 | + | return [{ title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t` }]; | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | export function loader({ params }: Route.LoaderArgs) { | |
| 13 | + | const item = roadmapItem(params.feature); | |
| 14 | + | if (!item) throw data(null, { status: 404 }); | |
| 15 | + | return { item }; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * A page a project will have: what it is for, what it will do, and what | |
| 20 | + | * to use until then. Reached from the project menu's Soon items. | |
| 21 | + | */ | |
| 22 | + | export default function Soon({ loaderData, params }: Route.ComponentProps) { | |
| 23 | + | const { item } = loaderData; | |
| 24 | + | const base = `/${params.owner}/${params.repo}`; | |
| 25 | + | const siblings = roadmapIn(item.section).filter((other) => other.key !== item.key); | |
| 26 | + | return ( | |
| 27 | + | <div className="mx-auto max-w-3xl"> | |
| 28 | + | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 29 | + | <span>{item.section}</span> | |
| 30 | + | <span className="text-line-strong">/</span> | |
| 31 | + | <span className="text-fg">{item.title}</span> | |
| 32 | + | </p> | |
| 33 | + | <div className="mt-4 flex flex-wrap items-center gap-3"> | |
| 34 | + | <h1 className="text-3xl font-semibold tracking-tight">{item.title}</h1> | |
| 35 | + | <span className="inline-flex items-center gap-1.5 rounded-full border border-accent/40 bg-accent/10 px-2.5 py-0.5 text-xs font-medium text-accent"> | |
| 36 | + | <Sparkles size={12} /> | |
| 37 | + | Soon | |
| 38 | + | </span> | |
| 39 | + | </div> | |
| 40 | + | <p className="mt-3 text-lg text-muted">{item.summary}</p> | |
| 41 | + | ||
| 42 | + | <section className="mt-8 rounded-xl border border-line bg-surface p-6"> | |
| 43 | + | <p className="leading-relaxed">{item.why}</p> | |
| 44 | + | <h2 className="mt-6 text-sm font-medium text-muted">What it will do</h2> | |
| 45 | + | <ul className="mt-3 space-y-2.5"> | |
| 46 | + | {item.plans.map((plan) => ( | |
| 47 | + | <li key={plan} className="flex gap-3 text-sm"> | |
| 48 | + | <Check size={16} className="mt-0.5 shrink-0 text-accent" /> | |
| 49 | + | <span>{plan}</span> | |
| 50 | + | </li> | |
| 51 | + | ))} | |
| 52 | + | </ul> | |
| 53 | + | </section> | |
| 54 | + | ||
| 55 | + | {item.today && ( | |
| 56 | + | <Link | |
| 57 | + | to={`${base}/${item.today.path}`} | |
| 58 | + | className="group mt-4 flex items-center justify-between gap-4 rounded-xl border border-line px-5 py-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 59 | + | > | |
| 60 | + | <span> | |
| 61 | + | <span className="block text-xs text-faint">Until then</span> | |
| 62 | + | <span className="mt-0.5 block text-sm font-medium">{item.today.label}</span> | |
| 63 | + | </span> | |
| 64 | + | <ArrowRight size={16} className="text-faint transition-transform group-hover:translate-x-0.5 group-hover:text-fg" /> | |
| 65 | + | </Link> | |
| 66 | + | )} | |
| 67 | + | ||
| 68 | + | {siblings.length > 0 && ( | |
| 69 | + | <section className="mt-10"> | |
| 70 | + | <h2 className="text-sm font-medium text-muted">Also coming to {item.section}</h2> | |
| 71 | + | <ul className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 72 | + | {siblings.map((other) => ( | |
| 73 | + | <li key={other.key}> | |
| 74 | + | <Link | |
| 75 | + | to={`${base}/soon/${other.key}`} | |
| 76 | + | className="block h-full rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 77 | + | > | |
| 78 | + | <span className="text-sm font-medium">{other.title}</span> | |
| 79 | + | <span className="mt-1 block text-sm text-muted">{other.summary}</span> | |
| 80 | + | </Link> | |
| 81 | + | </li> | |
| 82 | + | ))} | |
| 83 | + | </ul> | |
| 84 | + | </section> | |
| 85 | + | )} | |
| 86 | + | ||
| 87 | + | <p className="mt-10 text-xs text-faint"> | |
| 88 | + | {ROADMAP.length} things are on the way to every project. Want this one sooner, or something else?{" "} | |
| 89 | + | <a href="https://g1t.sh/syntaqx/g1t/issues/new" className="text-muted hover:text-fg"> | |
| 90 | + | Tell us | |
| 91 | + | </a> | |
| 92 | + | . | |
| 93 | + | </p> | |
| 94 | + | </div> | |
| 95 | + | ); | |
| 96 | + | } |