163 lines6,429 bytesCodeBlame

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { Mail } from "lucide-react";
2import { Form, Link } from "react-router";
3
4import type { Invite, InvitesOverview } from "@g1t/contracts";
5
6import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "./ui";
7import { inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites";
8
9const TONE: Record<"pending" | "done" | "dead", string> = {
10 pending: "border-accent/40 text-accent",
11 done: "border-merged/40 text-merged",
12 dead: "border-line text-faint",
13};
14
15function InviteRow({ invite, origin }: { invite: Invite; origin: string }) {
16 const state = inviteState(invite);
17 return (
18 <li className="space-y-2 px-4 py-3">
19 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
20 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
21 <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span>
22 {invite.status === "pending" && (
23 <Form method="post" className="ml-auto">
24 <input type="hidden" name="intent" value="revoke-invite" />
25 <input type="hidden" name="id" value={invite.id} />
26 <Button variant="quiet" type="submit">
27 Revoke
28 </Button>
29 </Form>
30 )}
31 </div>
32 <p className="text-xs text-faint">
33 <span className="font-mono">{invite.hint}…</span> · made <TimeAgo at={invite.createdAt} />
34 {invite.status === "pending" && (
35 <>
36 {" "}· works until {new Date(invite.expiresAt).toISOString().slice(0, 10)}
37 </>
38 )}
39 {invite.chargedTo === "workspace" && invite.workspace == null && " · from a workspace's invites"}
40 </p>
41 {invite.status === "pending" && invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
42 </li>
43 );
44}
45
46/**
47 * Account settings → Invites: what is left, making one (for anyone with the
48 * link, or for one address), copying links, and revoking.
49 */
50export function InvitesSection({
51 overview,
52 created,
53 error,
54 origin = "https://g1t.sh",
55}: {
56 overview: InvitesOverview | null;
57 created?: Invite;
58 error?: string;
59 origin?: string;
60}) {
61 if (!overview) {
62 return (
63 <section id="invites" className="scroll-mt-20">
64 <h2 className="font-medium">Invites</h2>
65 <p className="mt-1 text-sm text-muted">Confirm your email address to invite people to g1t.</p>
66 </section>
67 );
68 }
69 const { allowance, workspaces, invites } = overview;
70 const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0);
71 return (
72 <section id="invites" className="scroll-mt-20">
73 <h2 className="font-medium">Invites</h2>
74 <p className="mt-1 text-sm text-muted">
75 {overview.mode === "invite"
76 ? "g1t is invite-only for now. Each invite lets one person make an account. "
77 : "Anyone can make an account, but an invite still says who sent it. "}
78 A revoked or expired invite that was never used comes back to you.
79 </p>
80 <p className="mt-3 text-sm">
81 <span className="font-medium">{remainingLine(allowance)}</span>
82 {workspaces.map((workspace) => (
83 <span key={workspace.slug} className="text-muted">
84 {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share
85 </span>
86 ))}
87 </p>
88
89 {created?.code && (
90 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
91 <p className="text-sm">
92 {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once
93 {created.email ? ", for that address only" : ""}.
94 </p>
95 <div className="mt-2">
96 <CopyLine text={inviteLink(created.code, origin)} />
97 </div>
98 </div>
99 )}
100
101 {out ? (
102 <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm">
103 <Mail size={16} className="mt-0.5 shrink-0 text-muted" />
104 <p>
105 Need more invites?{" "}
106 <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4">
107 Contact us
108 </a>{" "}
109 and say who you would like to bring, or ask on the{" "}
110 <Link to="/support" className="underline underline-offset-4">
111 support page
112 </Link>
113 .
114 </p>
115 </div>
116 ) : (
117 <Form method="post" className="mt-4 space-y-3">
118 <input type="hidden" name="intent" value="create-invite" />
119 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
120 <div className="grow">
121 <Field label="Email (optional)">
122 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" />
123 </Field>
124 </div>
125 {workspaces.length > 0 && (
126 <label className="block">
127 <span className="mb-1.5 block text-sm font-medium text-muted">Use</span>
128 <select
129 name="charge"
130 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto"
131 >
132 <option value="mine">Your invites</option>
133 {workspaces
134 .filter((workspace) => workspace.allowance.remaining !== 0)
135 .map((workspace) => (
136 <option key={workspace.slug} value={workspace.slug}>
137 {workspace.slug}'s invites
138 </option>
139 ))}
140 </select>
141 </label>
142 )}
143 <Button type="submit">Create invite</Button>
144 </div>
145 <p className="text-xs text-faint">
146 With an email, the invite is sent there and only that address can use it.
147 </p>
148 </Form>
149 )}
150 <div className="mt-2">
151 <ErrorText>{error}</ErrorText>
152 </div>
153
154 {invites.length > 0 && (
155 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
156 {invites.map((invite) => (
157 <InviteRow key={invite.id} invite={invite} origin={origin} />
158 ))}
159 </ul>
160 )}
161 </section>
162 );
163}