Skip to content
265 linesCodeBlameRaw
1import { Mail } from "lucide-react";
2import { useState } from "react";
3import { Form, Link } from "react-router";
4
5import type { Invite, InvitesOverview } from "@g1t/contracts";
6
7import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
8import { Badge } from "./ui/badge";
9import { CheckboxOption } from "./ui/checkbox";
10import { Hint } from "./ui/hint";
11import { SelectField } from "./ui/select";
12import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites";
13import type { BringIntoChoices } from "../lib/invites.server";
14
15const SELECT = "h-auto w-full py-2 sm:w-auto sm:min-w-40";
16
17const TONE: Record<"pending" | "done" | "dead", string> = {
18 pending: "border-accent/40 text-accent",
19 done: "border-merged/40 text-merged",
20 dead: "border-line text-faint",
21};
22
23function InviteRow({ invite, origin }: { invite: Invite; origin: string }) {
24 const state = inviteState(invite);
25 const kind = inviteKind(invite);
26 return (
27 <li className="space-y-2 px-4 py-3">
28 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
29 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
30 <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span>
31 <Hint
32 label={
33 kind.kind === "g1t"
34 ? "Lets them make an account. It adds them to no workspace."
35 : "An invitation to join this workspace, which they accept or decline. Without an account, it lets them make one first."
36 }
37 >
38 <Badge size="md">{kind.label}</Badge>
39 </Hint>
40 {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && (
41 <Form method="post" className="ml-auto">
42 <input type="hidden" name="intent" value="revoke-invite" />
43 <input type="hidden" name="id" value={invite.id} />
44 <SubmitButton variant="outline" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…">
45 Revoke
46 </SubmitButton>
47 </Form>
48 )}
49 </div>
50 <p className="text-xs text-faint">
51 <span className="font-mono">{invite.hint}…</span> · made <TimeAgo at={invite.createdAt} />
52 {invite.status === "pending" && (
53 <>
54 {" "}· works until {new Date(invite.expiresAt).toISOString().slice(0, 10)}
55 </>
56 )}
57 {invite.chargedTo === "workspace" && invite.workspace == null && " · from a workspace's invites"}
58 </p>
59 {invite.status === "pending" && invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
60 </li>
61 );
62}
63
64/** "To bring someone into a workspace, invite them from that workspace's People page", with those pages. */
65function Elsewhere({ people }: { people: BringIntoChoices["people"] }) {
66 return (
67 <p className="mt-4 text-sm text-muted">
68 {G1T_INVITES.elsewhere}
69 {people.length > 0 ? ": " : "."}
70 {people.map((workspace, index) => (
71 <span key={workspace.slug}>
72 {index > 0 && ", "}
73 <Link to={workspace.to} className="text-fg underline underline-offset-4 hover:text-accent">
74 {workspace.name}
75 </Link>
76 </span>
77 ))}
78 {people.length > 0 && "."}
79 </p>
80 );
81}
82
83/**
84 * Making an invite to g1t: an address (or anyone with the link), whose
85 * invites it uses, and, only when its box is ticked, a workspace to invite
86 * them to as well. Unticked, nothing about a workspace is sent.
87 */
88function InviteForm({ overview, bringInto }: { overview: InvitesOverview; bringInto: BringIntoChoices }) {
89 const [alsoJoin, setAlsoJoin] = useState(false);
90 const { workspaces } = overview;
91 return (
92 <Form method="post" className="mt-4 space-y-4">
93 <input type="hidden" name="intent" value="create-invite" />
94 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
95 <div className="grow">
96 <Field label="Email (optional)">
97 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" />
98 </Field>
99 </div>
100 {workspaces.length > 0 && (
101 <label className="block">
102 <span className="mb-1.5 block text-sm font-medium text-muted">Use</span>
103 <SelectField
104 name="charge"
105 defaultValue="mine"
106 className={SELECT}
107 options={[
108 { value: "mine", label: "Your invites" },
109 ...workspaces
110 .filter((workspace) => workspace.allowance.remaining !== 0)
111 .map((workspace) => ({ value: workspace.slug, label: `${workspace.slug}'s invites` })),
112 ]}
113 />
114 </label>
115 )}
116 <SubmitButton match={{ intent: "create-invite" }} pending="Creating…">
117 Create invite
118 </SubmitButton>
119 </div>
120 <p className="text-xs text-faint">With an email, the invite is sent there and only that address can use it.</p>
121
122 {bringInto.options.length > 0 && (
123 <div className="space-y-3 rounded-md border border-line p-3">
124 <CheckboxOption
125 name="also_join"
126 checked={alsoJoin}
127 onCheckedChange={(checked) => setAlsoJoin(checked === true)}
128 label={G1T_INVITES.alsoJoin}
129 description={G1T_INVITES.alsoJoinHint}
130 />
131 {alsoJoin && (
132 <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end">
133 <label className="block">
134 <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span>
135 <SelectField
136 name="join"
137 required
138 placeholder="Choose a workspace"
139 className={SELECT}
140 options={bringInto.options.map((workspace) => ({
141 value: workspace.slug,
142 label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`,
143 }))}
144 />
145 </label>
146 <label className="block">
147 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
148 <SelectField
149 name="join_role"
150 defaultValue="member"
151 className={SELECT}
152 options={[
153 { value: "member", label: "Member" },
154 { value: "owner", label: "Owner" },
155 ]}
156 />
157 </label>
158 </div>
159 )}
160 {alsoJoin && bringInto.note && <p className="pl-6 text-xs text-faint">{bringInto.note}</p>}
161 </div>
162 )}
163 </Form>
164 );
165}
166
167/**
168 * Settings → Invites (`/settings/invites`): invites to g1t. Each lets one
169 * person make an account and adds them to no workspace, unless "Also
170 * invite them to a workspace" is ticked. They exist only while sign-up
171 * takes an invite; once anyone can sign up, the page keeps only the list
172 * of invites already made. An invitation to a workspace is made on its
173 * People page, which this page points to.
174 */
175export function InvitesSection({
176 overview,
177 created,
178 error,
179 origin = "https://g1t.sh",
180 bringInto = { options: [], note: null, people: [] },
181}: {
182 overview: InvitesOverview | null;
183 /** The workspaces an invite can also invite its person to, and the People pages (lib/invites.server.ts). */
184 bringInto?: BringIntoChoices;
185 created?: Invite;
186 error?: string;
187 origin?: string;
188}) {
189 if (!overview) {
190 return (
191 <section id="invites" className="scroll-mt-20">
192 <p className="text-sm text-muted">Confirm your email address to invite people to g1t.</p>
193 </section>
194 );
195 }
196 const { allowance, workspaces, invites } = overview;
197 const shows = invitesPage(overview.mode, invites.length);
198 const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0);
199 return (
200 <section id="invites" className="scroll-mt-20">
201 {shows.form ? (
202 <>
203 <p className="text-sm text-muted">A revoked or expired invite that was never used comes back to you.</p>
204 <p className="mt-3 text-sm">
205 <span className="font-medium">{remainingLine(allowance)}</span>
206 {workspaces.map((workspace) => (
207 <span key={workspace.slug} className="text-muted">
208 {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share
209 </span>
210 ))}
211 </p>
212
213 {created?.code && (
214 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
215 <p className="text-sm">
216 {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once
217 {created.email ? ", for that address only" : ""}.
218 </p>
219 <div className="mt-2">
220 <CopyLine text={inviteLink(created.code, origin)} />
221 </div>
222 </div>
223 )}
224
225 {out ? (
226 <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm">
227 <Mail size={16} className="mt-0.5 shrink-0 text-muted" />
228 <p>
229 Need more invites?{" "}
230 <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4">
231 Contact us
232 </a>{" "}
233 and say who you would like to bring, or ask on the{" "}
234 <Link to="/support" className="underline underline-offset-4">
235 support page
236 </Link>
237 .
238 </p>
239 </div>
240 ) : (
241 // Empty again once the invite is made; kept as filled in when it failed.
242 <InviteForm key={created?.id ?? ""} overview={overview} bringInto={bringInto} />
243 )}
244 <div className="mt-2">
245 <ErrorText>{error}</ErrorText>
246 </div>
247 <Elsewhere people={bringInto.people} />
248 </>
249 ) : (
250 <p className="text-sm text-muted">{G1T_INVITES.open}</p>
251 )}
252
253 {invites.length > 0 && (
254 <>
255 {!shows.form && <h2 className="mt-6 text-sm font-medium">Invites you made</h2>}
256 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
257 {invites.map((invite) => (
258 <InviteRow key={invite.id} invite={invite} origin={origin} />
259 ))}
260 </ul>
261 </>
262 )}
263 </section>
264 );
265}