Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

376 linesCodeBlameRaw
1import { Check, Copy, Ellipsis, Mail, RotateCw, UserPlus, X } from "lucide-react";
2import { useEffect, useId, useRef, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { type Invite, type InvitesOverview, MAX_INVITE_MESSAGE } from "@g1t/contracts";
6
7import { CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
8import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
9import { Badge } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Card } from "./ui/card";
12import { CheckboxOption } from "./ui/checkbox";
13import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
14import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
15import { Field, FieldDescription, FieldLabel } from "./ui/field";
16import { Hint } from "./ui/hint";
17import { Input } from "./ui/input";
18import { Progress } from "./ui/progress";
19import { SelectField } from "./ui/select";
20import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
21import { Textarea } from "./ui/textarea";
22import { type BringInto, G1T_INVITES, inviteActions, inviteLink, inviteState, invitationTone, remainingLine } from "../lib/invites";
23import type { InviteActionData } from "../lib/invites.server";
24
25/** After an invite is made or revoked, the account menu's count is asked for again (routes/settings-menu-json.ts). */
26function useRefreshMenuCount(answer: InviteActionData | undefined) {
27 const menu = useFetcher({ key: "account-menu" });
28 const seen = useRef<InviteActionData | undefined>(undefined);
29 useEffect(() => {
30 if (!answer || seen.current === answer || !("done" in answer)) return;
31 seen.current = answer;
32 if (answer.done !== "resent") menu.load("/settings/menu.json");
33 // eslint-disable-next-line react-hooks/exhaustive-deps
34 }, [answer]);
35}
36
37/**
38 * Inviting a friend, in a dialog: their address, a note for the email, and,
39 * only when its box is ticked, a paid workspace the person owns to invite
40 * them to as well. On success the dialog closes and the page shows the link.
41 */
42function InviteFriendDialog({
43 bringInto,
44 disabled,
45 defaultOpen,
46 onCreated,
47}: {
48 bringInto: BringInto[];
49 disabled: string | null;
50 defaultOpen: boolean;
51 onCreated: (invite: Invite) => void;
52}) {
53 const [open, setOpen] = useState(defaultOpen && !disabled);
54 const [alsoJoin, setAlsoJoin] = useState(false);
55 const fetcher = useFetcher<InviteActionData>();
56 const answer = fetcher.state === "idle" ? fetcher.data : undefined;
57 useRefreshMenuCount(answer);
58 const told = useRef<InviteActionData | undefined>(undefined);
59 useEffect(() => {
60 if (!answer || told.current === answer) return;
61 told.current = answer;
62 if ("done" in answer && answer.done === "created") {
63 setOpen(false);
64 setAlsoJoin(false);
65 onCreated(answer.invite);
66 }
67 }, [answer, onCreated]);
68 const error = answer && "error" in answer ? answer.error : null;
69 const id = useId();
70 const trigger = (
71 <Button type="button" disabled={disabled != null}>
72 <UserPlus size={15} />
73 {G1T_INVITES.action}
74 </Button>
75 );
76 return (
77 <Dialog open={open} onOpenChange={setOpen}>
78 {disabled ? (
79 <Hint label={disabled} disabled>
80 {trigger}
81 </Hint>
82 ) : (
83 <DialogTrigger asChild>{trigger}</DialogTrigger>
84 )}
85 <DialogContent>
86 {open && (
87 <fetcher.Form method="post" className="grid gap-5">
88 <input type="hidden" name="intent" value="create-invite" />
89 <DialogHeader>
90 <DialogTitle>{G1T_INVITES.action}</DialogTitle>
91 <DialogDescription>{G1T_INVITES.dialog}</DialogDescription>
92 </DialogHeader>
93 <Field>
94 <FieldLabel htmlFor={`${id}-email`}>Their email address</FieldLabel>
95 <Input id={`${id}-email`} name="email" type="email" required maxLength={254} autoComplete="off" placeholder="friend@example.com" autoFocus />
96 <FieldDescription>Only this address can use the invite.</FieldDescription>
97 </Field>
98 <Field>
99 <FieldLabel htmlFor={`${id}-message`}>
100 Message <span className="font-normal text-faint">(optional)</span>
101 </FieldLabel>
102 <Textarea
103 id={`${id}-message`}
104 name="message"
105 maxLength={MAX_INVITE_MESSAGE}
106 rows={3}
107 placeholder="A line for the email: what you'd like to build together."
108 />
109 <FieldDescription>Quoted in the email, under your name.</FieldDescription>
110 </Field>
111 {bringInto.length > 0 && (
112 <div className="grid gap-3 rounded-lg border border-line p-3">
113 <CheckboxOption
114 name="also_join"
115 checked={alsoJoin}
116 onCheckedChange={(checked) => setAlsoJoin(checked === true)}
117 label={G1T_INVITES.alsoJoin}
118 description={G1T_INVITES.alsoJoinHint}
119 />
120 {alsoJoin && (
121 <div className="grid gap-3 pl-6 sm:grid-cols-2">
122 <Field>
123 <FieldLabel htmlFor={`${id}-join`}>Workspace</FieldLabel>
124 <SelectField
125 id={`${id}-join`}
126 name="join"
127 required
128 defaultValue={bringInto.length === 1 ? bringInto[0]!.slug : undefined}
129 placeholder="Choose a workspace"
130 options={bringInto.map((workspace) => ({
131 value: workspace.slug,
132 label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`,
133 }))}
134 />
135 </Field>
136 <Field>
137 <FieldLabel htmlFor={`${id}-role`}>Role</FieldLabel>
138 <SelectField
139 id={`${id}-role`}
140 name="join_role"
141 defaultValue="member"
142 options={[
143 { value: "member", label: "Member" },
144 { value: "owner", label: "Owner" },
145 ]}
146 />
147 </Field>
148 </div>
149 )}
150 </div>
151 )}
152 {error && <ErrorText>{error}</ErrorText>}
153 <DialogFooter>
154 <DialogClose asChild>
155 <Button type="button" variant="outline">
156 Cancel
157 </Button>
158 </DialogClose>
159 <SubmitButton fetcher={fetcher} match={{ intent: "create-invite" }} pending="Sending…">
160 <Mail size={15} />
161 Send invite
162 </SubmitButton>
163 </DialogFooter>
164 </fetcher.Form>
165 )}
166 </DialogContent>
167 </Dialog>
168 );
169}
170
171/** The ⋯ menu on one invite: copy its link, send it again, revoke it. */
172function InviteMenu({ invite, origin, onDone }: { invite: Invite; origin: string; onDone: (answer: InviteActionData) => void }) {
173 const fetcher = useFetcher<InviteActionData>();
174 const [copied, setCopied] = useState(false);
175 const can = inviteActions(invite);
176 const link = invite.code ? inviteLink(invite.code, origin) : null;
177 const answer = fetcher.state === "idle" ? fetcher.data : undefined;
178 useRefreshMenuCount(answer);
179 const told = useRef<InviteActionData | undefined>(undefined);
180 useEffect(() => {
181 if (!answer || told.current === answer) return;
182 told.current = answer;
183 onDone(answer);
184 }, [answer, onDone]);
185 if (!can.revoke && !can.copy) return null;
186 return (
187 <DropdownMenu>
188 <DropdownMenuTrigger asChild>
189 <Button
190 type="button"
191 aria-label={`Manage the invite for ${invite.email ?? invite.hint}`}
192 variant="ghost"
193 size="inline"
194 className="p-1.5 text-faint max-sm:p-2.5"
195 disabled={fetcher.state !== "idle"}
196 >
197 <Ellipsis size={16} />
198 </Button>
199 </DropdownMenuTrigger>
200 <DropdownMenuContent align="end" className="w-52">
201 <DropdownMenuItem
202 disabled={!can.copy || !link}
203 onSelect={(event) => {
204 event.preventDefault();
205 if (!link) return;
206 void navigator.clipboard?.writeText(link).then(() => {
207 setCopied(true);
208 setTimeout(() => setCopied(false), 1500);
209 });
210 }}
211 >
212 {copied ? <Check /> : <Copy />}
213 {copied ? "Copied" : "Copy link"}
214 </DropdownMenuItem>
215 <DropdownMenuItem disabled={!can.resend} onSelect={() => fetcher.submit({ intent: "resend-invite", id: invite.id }, { method: "post" })}>
216 <RotateCw />
217 Send again
218 </DropdownMenuItem>
219 <DropdownMenuSeparator />
220 <DropdownMenuItem
221 disabled={!can.revoke}
222 className="text-danger"
223 onSelect={() => fetcher.submit({ intent: "revoke-invite", id: invite.id }, { method: "post" })}
224 >
225 <X />
226 Revoke
227 </DropdownMenuItem>
228 </DropdownMenuContent>
229 </DropdownMenu>
230 );
231}
232
233/**
234 * Settings → Invites to g1t (`/settings/invites`): Invite a friend. How many
235 * invites are left, the button that opens the dialog, and every invite sent,
236 * with who joined. Anyone can sign up without one; nothing here says
237 * otherwise.
238 */
239export function InvitesSection({
240 overview,
241 bringInto,
242 origin = "https://g1t.sh",
243 openOnLoad = false,
244}: {
245 /** Null until the person's own address is confirmed, or when identity did not answer. */
246 overview: InvitesOverview | null;
247 /** Paid workspaces the person owns, to also invite a friend to (lib/invites.server.ts). */
248 bringInto: BringInto[];
249 origin?: string;
250 /** Opened from the account menu's Invite a friend: the dialog starts open. */
251 openOnLoad?: boolean;
252}) {
253 const [created, setCreated] = useState<Invite | null>(null);
254 const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
255 if (!overview) {
256 return (
257 <Alert tone="info">
258 <AlertTitle>Confirm your email address first</AlertTitle>
259 <AlertDescription>Once your address is confirmed you can invite friends to g1t from here.</AlertDescription>
260 </Alert>
261 );
262 }
263 const { allowance, invites } = overview;
264 const joined = invites.filter((invite) => invite.status === "redeemed" || invite.status === "awaiting_answer").length;
265 const out = allowance.remaining === 0;
266 const done = (answer: InviteActionData) => {
267 if (!answer) return;
268 const who = "id" in answer ? invites.find((invite) => invite.id === answer.id)?.email : null;
269 if ("error" in answer) setNotice({ tone: "error", text: answer.error });
270 else if (answer.done === "revoked") setNotice({ tone: "ok", text: `Revoked the invite for ${who ?? "them"}. It is yours to use again.` });
271 else if (answer.done === "resent") setNotice({ tone: "ok", text: `Sent the invite to ${who ?? "them"} again.` });
272 };
273 return (
274 <section id="invites" className="grid gap-6">
275 <Card className="flex flex-col gap-4 rounded-lg p-4 sm:flex-row sm:items-center">
276 <div className="min-w-0 grow">
277 <p className="text-sm font-medium tabular-nums">{remainingLine(allowance)}</p>
278 {allowance.limit > 0 && (
279 <Progress
280 value={(allowance.used / allowance.limit) * 100}
281 aria-label={`${allowance.used} of ${allowance.limit} invites used`}
282 className="mt-2 max-w-sm"
283 />
284 )}
285 <p className="mt-2 text-xs text-faint">
286 {out ? G1T_INVITES.outNote : G1T_INVITES.comesBack}
287 {joined > 0 && ` ${joined} ${joined === 1 ? "friend has" : "friends have"} joined.`}
288 </p>
289 </div>
290 <InviteFriendDialog
291 bringInto={bringInto}
292 disabled={out ? G1T_INVITES.out : null}
293 defaultOpen={openOnLoad}
294 onCreated={(invite) => {
295 setNotice(null);
296 setCreated(invite);
297 }}
298 />
299 </Card>
300
301 {created && (
302 <Alert tone="success" role="status">
303 <AlertTitle>Invite sent to {created.email}</AlertTitle>
304 <AlertDescription className="mt-2 grid gap-2">
305 <span className="text-muted">They can also use this link. It works once, for that address only.</span>
306 {created.code && <CopyLine text={inviteLink(created.code, origin)} breakAtSlashes />}
307 </AlertDescription>
308 </Alert>
309 )}
310
311 {invites.length === 0 ? (
312 <EmptyState title="No invites yet">Invite a friend and they show here, with when they join.</EmptyState>
313 ) : (
314 <Card tone="plain" className="overflow-hidden">
315 <Table>
316 <TableHeader>
317 <TableRow>
318 <TableHead>Friend</TableHead>
319 <TableHead className="max-sm:hidden">Sent</TableHead>
320 <TableHead className="max-sm:hidden">Status</TableHead>
321 <TableHead className="w-10">
322 <span className="sr-only">Actions</span>
323 </TableHead>
324 </TableRow>
325 </TableHeader>
326 <TableBody>
327 {invites.map((invite) => {
328 const state = inviteState(invite);
329 return (
330 <TableRow key={invite.id}>
331 <TableCell className="max-w-64 align-middle text-sm">
332 <span className="inline-flex min-w-0 items-center gap-1.5">
333 <Mail size={13} aria-hidden="true" className="shrink-0 text-faint" />
334 <span className="truncate">{invite.email ?? "Anyone with the link"}</span>
335 </span>
336 {invite.workspace && <span className="block text-xs text-faint">Also invited to {invite.workspace}</span>}
337 {/* On a phone, the status and when it was sent fold under the address. */}
338 <span className="mt-1.5 flex flex-wrap items-center gap-2 sm:hidden">
339 <Badge tone={invitationTone(invite.status)} size="md" className="max-w-full truncate">
340 {state.label}
341 </Badge>
342 <span className="text-xs text-faint">
343 <TimeAgo at={invite.createdAt} />
344 </span>
345 </span>
346 </TableCell>
347 <TableCell className="align-middle text-sm whitespace-nowrap text-muted max-sm:hidden">
348 <TimeAgo at={invite.createdAt} />
349 </TableCell>
350 <TableCell className="align-middle max-sm:hidden">
351 <Badge tone={invitationTone(invite.status)} size="md" className="max-w-56 truncate">
352 {state.label}
353 </Badge>
354 {invite.status === "pending" && <span className="ml-2 text-xs whitespace-nowrap text-faint">until {invite.expiresAt.slice(0, 10)}</span>}
355 </TableCell>
356 <TableCell className="align-middle text-right">
357 <InviteMenu invite={invite} origin={origin} onDone={done} />
358 </TableCell>
359 </TableRow>
360 );
361 })}
362 </TableBody>
363 </Table>
364 </Card>
365 )}
366 {notice &&
367 (notice.tone === "ok" ? (
368 <p className="-mt-3 text-sm text-muted" role="status">
369 {notice.text}
370 </p>
371 ) : (
372 <ErrorText>{notice.text}</ErrorText>
373 ))}
374 </section>
375 );
376}