Skip to content

g1t/apps/web/app/routes/workspace/new.tsx

137 lines5,642 bytesCodeBlame
1import { Form, Link, redirect } from "react-router";
2
3import type { Route } from "./+types/new";
4import { page } from "../../lib/meta";
5import { planHref } from "../../components/start-plan";
6import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
7import { billing, identity } from "../../lib/services.server";
8import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
9
10export function meta(args: Route.MetaArgs) {
11 return page(args, { title: "New workspace · g1t" });
12}
13
14export async function loader({ request, context }: Route.LoaderArgs) {
15 const user = requireUser(context, request);
16 const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug);
17 // A person owns at most one free workspace; identity refuses a second
18 // either way, so a failure here only leaves the form up.
19 const free = owned.length > 0 ? await billing.freeWorkspaces(owned).catch(() => [] as string[]) : [];
20 return { user, first: (user.workspaces ?? []).length === 0, free };
21}
22
23export async function action({ request, context }: Route.ActionArgs) {
24 assertSameOrigin(request);
25 const user = requireUser(context, request);
26 const form = await request.formData();
27 const result = await identity.createWorkspace(
28 user,
29 String(form.get("slug") ?? ""),
30 String(form.get("displayName") ?? ""),
31 );
32 if (!result.ok) return { error: result.error.message };
33 // Someone sent here on their way elsewhere carries on to it.
34 const next = nextPath(request);
35 throw redirect(next === "/" ? `/${result.value.slug}` : next);
36}
37
38export default function NewWorkspace({
39 loaderData,
40 actionData,
41}: Route.ComponentProps) {
42 const { user, first, free } = loaderData;
43 return (
44 <main className="mx-auto max-w-lg px-4 py-12">
45 <h1 className="text-xl font-semibold">
46 {first ? "Create your workspace" : "New workspace"}
47 </h1>
48 <p className="mt-2 text-sm text-muted">
49 {first && "Everything on g1t lives in a workspace, so this comes first. "}
50 A workspace holds repositories, the people who work on them and the
51 access tokens that automate them, and is the first part of every
52 address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>.
53 Use one for yourself, and one for each team or company you work with.
54 </p>
55 {free.length > 0 ? (
56 <OneFreeWorkspace free={free} />
57 ) : (
58 <>
59 {!user.verified && (
60 <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm">
61 Confirm your email address first. We sent you a link.
62 </p>
63 )}
64 <Form method="post" className="mt-8 space-y-4">
65 <Field
66 label="Name in URLs"
67 hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days."
68 >
69 <div className="flex items-center gap-2 font-mono text-sm">
70 <span className="text-muted">g1t.sh/</span>
71 <Input
72 name="slug"
73 required
74 autoFocus
75 maxLength={39}
76 defaultValue={first ? user.username : ""}
77 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
78 />
79 </div>
80 </Field>
81 <Field label="Display name (optional)">
82 <Input name="displayName" maxLength={80} />
83 </Field>
84 <p className="text-xs text-faint">
85 A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have.
86 </p>
87 <ErrorText>{actionData?.error}</ErrorText>
88 <SubmitButton pending="Creating…">Create workspace</SubmitButton>
89 </Form>
90 </>
91 )}
92 </main>
93 );
94}
95
96/** Why there is no form: the person owns a free workspace already. */
97function OneFreeWorkspace({ free }: { free: string[] }) {
98 const [first] = free;
99 return (
100 <section className="mt-8 rounded-xl border border-line bg-surface p-5">
101 <h2 className="font-medium">You already own a free workspace</h2>
102 <p className="mt-2 text-sm text-muted">
103 Each person can own one workspace that is not on the g1t plan.{" "}
104 {free.length === 1 ? (
105 <>
106 Yours is <span className="font-mono text-fg">{first}</span>.
107 </>
108 ) : (
109 <>
110 You own {free.length} from before (
111 {free.map((slug, i) => (
112 <span key={slug}>
113 {i > 0 && ", "}
114 <span className="font-mono text-fg">{slug}</span>
115 </span>
116 ))}
117 ), and keep them all.
118 </>
119 )}{" "}
120 A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "}
121 {free.length === 1 ? "it" : "the ones"} you no longer use.
122 </p>
123 <div className="mt-4 flex flex-wrap items-center gap-3">
124 <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink>
125 <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg">
126 Workspace settings
127 </Link>
128 </div>
129 <p className="mt-4 text-xs text-faint">
130 The plan is one price for everyone in the workspace, never per person.{" "}
131 <Link to="/pricing" className="underline underline-offset-2 hover:text-fg">
132 Pricing
133 </Link>
134 </p>
135 </section>
136 );
137}