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.

g1t.sh
348 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 id={`${id}-message`} name="message" maxLength={MAX_INVITE_MESSAGE} rows={3} placeholder="A line for the email: what you'd like to build together." />
103 <FieldDescription>Quoted in the email, under your name.</FieldDescription>
104 </Field>
105 {bringInto.length > 0 && (
106 <div className="grid gap-3 rounded-lg border border-line p-3">
107 <CheckboxOption
108 name="also_join"
109 checked={alsoJoin}
110 onCheckedChange={(checked) => setAlsoJoin(checked === true)}
111 label={G1T_INVITES.alsoJoin}
112 description={G1T_INVITES.alsoJoinHint}
113 />
114 {alsoJoin && (
115 <div className="grid gap-3 pl-6 sm:grid-cols-2">
116 <Field>
117 <FieldLabel htmlFor={`${id}-join`}>Workspace</FieldLabel>
118 <SelectField
119 id={`${id}-join`}
120 name="join"
121 required
122 defaultValue={bringInto.length === 1 ? bringInto[0]!.slug : undefined}
123 placeholder="Choose a workspace"
124 options={bringInto.map((workspace) => ({
125 value: workspace.slug,
126 label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`,
127 }))}
128 />
129 </Field>
130 <Field>
131 <FieldLabel htmlFor={`${id}-role`}>Role</FieldLabel>
132 <SelectField
133 id={`${id}-role`}
134 name="join_role"
135 defaultValue="member"
136 options={[
137 { value: "member", label: "Member" },
138 { value: "owner", label: "Owner" },
139 ]}
140 />
141 </Field>
142 </div>
143 )}
144 </div>
145 )}
146 {error && <ErrorText>{error}</ErrorText>}
147 <DialogFooter>
148 <DialogClose asChild>
149 <Button type="button" variant="outline">
150 Cancel
151 </Button>
152 </DialogClose>
153 <SubmitButton fetcher={fetcher} match={{ intent: "create-invite" }} pending="Sending…">
154 <Mail size={15} />
155 Send invite
156 </SubmitButton>
157 </DialogFooter>
158 </fetcher.Form>
159 )}
160 </DialogContent>
161 </Dialog>
162 );
163}
164
165/** The ⋯ menu on one invite: copy its link, send it again, revoke it. */
166function InviteMenu({ invite, origin, onDone }: { invite: Invite; origin: string; onDone: (answer: InviteActionData) => void }) {
167 const fetcher = useFetcher<InviteActionData>();
168 const [copied, setCopied] = useState(false);
169 const can = inviteActions(invite);
170 const link = invite.code ? inviteLink(invite.code, origin) : null;
171 const answer = fetcher.state === "idle" ? fetcher.data : undefined;
172 useRefreshMenuCount(answer);
173 const told = useRef<InviteActionData | undefined>(undefined);
174 useEffect(() => {
175 if (!answer || told.current === answer) return;
176 told.current = answer;
177 onDone(answer);
178 }, [answer, onDone]);
179 if (!can.revoke && !can.copy) return null;
180 return (
181 <DropdownMenu>
182 <DropdownMenuTrigger asChild>
183 <Button type="button" aria-label={`Manage the invite for ${invite.email ?? invite.hint}`} variant="ghost" size="inline" className="p-1.5 text-faint max-sm:p-2.5" disabled={fetcher.state !== "idle"}>
184 <Ellipsis size={16} />
185 </Button>
186 </DropdownMenuTrigger>
187 <DropdownMenuContent align="end" className="w-52">
188 <DropdownMenuItem
189 disabled={!can.copy || !link}
190 onSelect={(event) => {
191 event.preventDefault();
192 if (!link) return;
193 void navigator.clipboard?.writeText(link).then(() => {
194 setCopied(true);
195 setTimeout(() => setCopied(false), 1500);
196 });
197 }}
198 >
199 {copied ? <Check /> : <Copy />}
200 {copied ? "Copied" : "Copy link"}
201 </DropdownMenuItem>
202 <DropdownMenuItem disabled={!can.resend} onSelect={() => fetcher.submit({ intent: "resend-invite", id: invite.id }, { method: "post" })}>
203 <RotateCw />
204 Send again
205 </DropdownMenuItem>
206 <DropdownMenuSeparator />
207 <DropdownMenuItem disabled={!can.revoke} className="text-danger" onSelect={() => fetcher.submit({ intent: "revoke-invite", id: invite.id }, { method: "post" })}>
208 <X />
209 Revoke
210 </DropdownMenuItem>
211 </DropdownMenuContent>
212 </DropdownMenu>
213 );
214}
215
216/**
217 * Settings → Invites to g1t (`/settings/invites`): Invite a friend. How many
218 * invites are left, the button that opens the dialog, and every invite sent,
219 * with who joined. Anyone can sign up without one; nothing here says
220 * otherwise.
221 */
222export function InvitesSection({
223 overview,
224 bringInto,
225 origin = "https://g1t.sh",
226 openOnLoad = false,
227}: {
228 /** Null until the person's own address is confirmed, or when identity did not answer. */
229 overview: InvitesOverview | null;
230 /** Paid workspaces the person owns, to also invite a friend to (lib/invites.server.ts). */
231 bringInto: BringInto[];
232 origin?: string;
233 /** Opened from the account menu's Invite a friend: the dialog starts open. */
234 openOnLoad?: boolean;
235}) {
236 const [created, setCreated] = useState<Invite | null>(null);
237 const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
238 if (!overview) {
239 return (
240 <Alert tone="info">
241 <AlertTitle>Confirm your email address first</AlertTitle>
242 <AlertDescription>Once your address is confirmed you can invite friends to g1t from here.</AlertDescription>
243 </Alert>
244 );
245 }
246 const { allowance, invites } = overview;
247 const joined = invites.filter((invite) => invite.status === "redeemed" || invite.status === "awaiting_answer").length;
248 const out = allowance.remaining === 0;
249 const done = (answer: InviteActionData) => {
250 if (!answer) return;
251 const who = "id" in answer ? invites.find((invite) => invite.id === answer.id)?.email : null;
252 if ("error" in answer) setNotice({ tone: "error", text: answer.error });
253 else if (answer.done === "revoked") setNotice({ tone: "ok", text: `Revoked the invite for ${who ?? "them"}. It is yours to use again.` });
254 else if (answer.done === "resent") setNotice({ tone: "ok", text: `Sent the invite to ${who ?? "them"} again.` });
255 };
256 return (
257 <section id="invites" className="grid gap-6">
258 <Card className="flex flex-col gap-4 rounded-lg p-4 sm:flex-row sm:items-center">
259 <div className="min-w-0 grow">
260 <p className="text-sm font-medium tabular-nums">{remainingLine(allowance)}</p>
261 {allowance.limit > 0 && (
262 <Progress value={(allowance.used / allowance.limit) * 100} aria-label={`${allowance.used} of ${allowance.limit} invites used`} className="mt-2 max-w-sm" />
263 )}
264 <p className="mt-2 text-xs text-faint">
265 {out ? G1T_INVITES.outNote : G1T_INVITES.comesBack}
266 {joined > 0 && ` ${joined} ${joined === 1 ? "friend has" : "friends have"} joined.`}
267 </p>
268 </div>
269 <InviteFriendDialog
270 bringInto={bringInto}
271 disabled={out ? G1T_INVITES.out : null}
272 defaultOpen={openOnLoad}
273 onCreated={(invite) => {
274 setNotice(null);
275 setCreated(invite);
276 }}
277 />
278 </Card>
279
280 {created && (
281 <Alert tone="success" role="status">
282 <AlertTitle>Invite sent to {created.email}</AlertTitle>
283 <AlertDescription className="mt-2 grid gap-2">
284 <span className="text-muted">They can also use this link. It works once, for that address only.</span>
285 {created.code && <CopyLine text={inviteLink(created.code, origin)} breakAtSlashes />}
286 </AlertDescription>
287 </Alert>
288 )}
289
290 {invites.length === 0 ? (
291 <EmptyState title="No invites yet">Invite a friend and they show here, with when they join.</EmptyState>
292 ) : (
293 <Card tone="plain" className="overflow-hidden">
294 <Table>
295 <TableHeader>
296 <TableRow>
297 <TableHead>Friend</TableHead>
298 <TableHead className="max-sm:hidden">Sent</TableHead>
299 <TableHead className="max-sm:hidden">Status</TableHead>
300 <TableHead className="w-10">
301 <span className="sr-only">Actions</span>
302 </TableHead>
303 </TableRow>
304 </TableHeader>
305 <TableBody>
306 {invites.map((invite) => {
307 const state = inviteState(invite);
308 return (
309 <TableRow key={invite.id}>
310 <TableCell className="max-w-64 align-middle text-sm">
311 <span className="inline-flex min-w-0 items-center gap-1.5">
312 <Mail size={13} aria-hidden="true" className="shrink-0 text-faint" />
313 <span className="truncate">{invite.email ?? "Anyone with the link"}</span>
314 </span>
315 {invite.workspace && <span className="block text-xs text-faint">Also invited to {invite.workspace}</span>}
316 {/* On a phone, the status and when it was sent fold under the address. */}
317 <span className="mt-1.5 flex flex-wrap items-center gap-2 sm:hidden">
318 <Badge tone={invitationTone(invite.status)} size="md" className="max-w-full truncate">
319 {state.label}
320 </Badge>
321 <span className="text-xs text-faint">
322 <TimeAgo at={invite.createdAt} />
323 </span>
324 </span>
325 </TableCell>
326 <TableCell className="align-middle text-sm whitespace-nowrap text-muted max-sm:hidden">
327 <TimeAgo at={invite.createdAt} />
328 </TableCell>
329 <TableCell className="align-middle max-sm:hidden">
330 <Badge tone={invitationTone(invite.status)} size="md" className="max-w-56 truncate">
331 {state.label}
332 </Badge>
333 {invite.status === "pending" && <span className="ml-2 text-xs whitespace-nowrap text-faint">until {invite.expiresAt.slice(0, 10)}</span>}
334 </TableCell>
335 <TableCell className="align-middle text-right">
336 <InviteMenu invite={invite} origin={origin} onDone={done} />
337 </TableCell>
338 </TableRow>
339 );
340 })}
341 </TableBody>
342 </Table>
343 </Card>
344 )}
345 {notice && (notice.tone === "ok" ? <p className="-mt-3 text-sm text-muted" role="status">{notice.text}</p> : <ErrorText>{notice.text}</ErrorText>)}
346 </section>
347 );
348}