g1t/apps/sudo/app/routes/new-enterprise.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Billing accounts, terms and enterprises; g1t is no longer free | 1 | import { ArrowLeft, Building2 } from "lucide-react"; |
| 2 | import { data, Link, redirect } from "react-router"; | |
| 3 | ||
| 4 | import type { Route } from "./+types/new-enterprise"; | |
| 5 | import { Avatar, Button, Field, Input, Notice, Section, Textarea } from "~/components/ui"; | |
| 6 | import { fields, parseSlugList, text } from "~/lib/forms"; | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 7 | import { admin, identity } from "~/lib/services.server"; |
| Billing accounts, terms and enterprises; g1t is no longer free | 8 | import { requireStaff } from "~/lib/staff"; |
| 9 | ||
| 10 | export const meta: Route.MetaFunction = () => [{ title: "New enterprise · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 11 | ||
| 12 | export async function loader({ context }: Route.LoaderArgs) { | |
| 13 | requireStaff(context); | |
| 14 | return null; | |
| 15 | } | |
| 16 | ||
| 17 | type ActionData = | |
| 18 | | { error: string; values: Record<string, string> } | |
| 19 | | { review: { name: string; workspaces: string[]; values: Record<string, string> } }; | |
| 20 | ||
| 21 | export async function action({ request, context }: Route.ActionArgs) { | |
| 22 | const staff = requireStaff(context); | |
| 23 | const form = await request.formData(); | |
| 24 | const values = fields(form, "name", "workspaces"); | |
| 25 | const fail = (error: string) => data<ActionData>({ error, values }, { status: 422 }); | |
| 26 | ||
| 27 | const name = values.name.replace(/\s+/g, " "); | |
| 28 | if (!name) return fail("Give the enterprise a name."); | |
| 29 | if (name.length > 100) return fail("Keep the name under 100 characters."); | |
| 30 | const workspaces = parseSlugList(values.workspaces); | |
| 31 | if (!workspaces.ok) return fail(workspaces.error); | |
| 32 | if (workspaces.value.length === 0) return fail("Name at least one workspace for it to pay for."); | |
| 33 | if (workspaces.value.length > 100) return fail("At most 100 workspaces at once."); | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 34 | const found = await Promise.all(workspaces.value.map((slug) => identity.workspace(slug))); |
| 35 | const unknown = workspaces.value.filter((_, index) => !found[index]); | |
| 36 | if (unknown.length > 0) return fail(`No workspace called ${unknown.join(", ")}.`); | |
| Billing accounts, terms and enterprises; g1t is no longer free | 37 | |
| 38 | // Moving workspaces onto it changes who pays for them: confirm first. | |
| 39 | if (text(form, "confirm") !== "yes") { | |
| 40 | return { review: { name, workspaces: workspaces.value, values } } satisfies ActionData; | |
| 41 | } | |
| 42 | const result = await admin.createEnterprise(name, workspaces.value, staff.email); | |
| 43 | if (!result.ok) return fail(result.error.message); | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 44 | return redirect(`/enterprises/${encodeURIComponent(result.value.id)}?done=created#top`); |
| Billing accounts, terms and enterprises; g1t is no longer free | 45 | } |
| 46 | ||
| 47 | export default function NewEnterprise({ actionData }: Route.ComponentProps) { | |
| 48 | const result = actionData as ActionData | undefined; | |
| 49 | const review = result && "review" in result ? result.review : null; | |
| 50 | const error = result && "error" in result ? result : null; | |
| 51 | const values = review?.values ?? error?.values; | |
| 52 | ||
| 53 | return ( | |
| 54 | <main className="mx-auto max-w-2xl px-4 py-8 sm:py-10"> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 55 | <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| Billing accounts, terms and enterprises; g1t is no longer free | 56 | <ArrowLeft size={14} /> |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 57 | Enterprises |
| Billing accounts, terms and enterprises; g1t is no longer free | 58 | </Link> |
| 59 | <h1 className="mt-4 text-2xl font-semibold tracking-tight">New enterprise</h1> | |
| 60 | <p className="mt-1 text-sm text-muted"> | |
| Say what g1t does, not whom it is like | 61 | One customer that pays for several workspaces: one bill, one limit, one set of terms. Set its terms |
| Billing accounts, terms and enterprises; g1t is no longer free | 62 | once it exists. |
| 63 | </p> | |
| 64 | ||
| 65 | {review && ( | |
| 66 | <section id="review" className="mt-6 scroll-mt-20 rounded-lg border border-merged/40 bg-merged/5 p-4 sm:p-5"> | |
| 67 | <h2 className="font-semibold tracking-tight">Create {review.name}?</h2> | |
| 68 | <p className="mt-1 text-sm text-muted"> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 69 | These workspaces will be billed to it from now on, under its limit and terms instead of their own: |
| Billing accounts, terms and enterprises; g1t is no longer free | 70 | </p> |
| 71 | <ul className="mt-3 flex flex-wrap gap-2"> | |
| 72 | {review.workspaces.map((slug) => ( | |
| 73 | <li key={slug} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 font-mono text-xs"> | |
| 74 | <Avatar name={slug} size={14} /> | |
| 75 | {slug} | |
| 76 | </li> | |
| 77 | ))} | |
| 78 | </ul> | |
| 79 | <form method="post" action="/enterprises/new" className="mt-4 flex flex-wrap items-center gap-2"> | |
| 80 | <input type="hidden" name="name" value={review.values.name} /> | |
| 81 | <input type="hidden" name="workspaces" value={review.values.workspaces} /> | |
| 82 | <input type="hidden" name="confirm" value="yes" /> | |
| 83 | <Button type="submit" variant="lavender"> | |
| 84 | <Building2 size={14} /> | |
| 85 | Create enterprise | |
| 86 | </Button> | |
| 87 | <Link to="/enterprises/new" className="px-2 text-sm text-muted hover:text-fg"> | |
| 88 | Cancel | |
| 89 | </Link> | |
| 90 | </form> | |
| 91 | </section> | |
| 92 | )} | |
| 93 | ||
| 94 | <Section title="Enterprise" className="mt-6"> | |
| 95 | <form method="post" action="/enterprises/new#review" className="space-y-4"> | |
| 96 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 97 | <Field label="Name" hint="As it should appear on the bill."> | |
| 98 | <Input name="name" required maxLength={100} placeholder="Acme Corporation" defaultValue={values?.name ?? ""} /> | |
| 99 | </Field> | |
| 100 | <Field label="Workspaces" hint="Slugs, separated by commas or one per line."> | |
| 101 | <Textarea name="workspaces" required rows={4} placeholder={"acme\nacme-labs"} defaultValue={values?.workspaces ?? ""} className="font-mono" /> | |
| 102 | </Field> | |
| 103 | <div className="flex justify-end"> | |
| 104 | <Button type="submit">Review</Button> | |
| 105 | </div> | |
| 106 | </form> | |
| 107 | </Section> | |
| 108 | </main> | |
| 109 | ); | |
| 110 | } |