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.
| Merge branch 'worktree-agent-a6da31375506fc440' | 1 | import { Check, Copy, Ellipsis, Mail, RotateCw, UserPlus, X } from "lucide-react"; |
| 2 | import { useEffect, useId, useRef, useState } from "react"; | |
| 3 | import { useFetcher } from "react-router"; | |
| 4 | ||
| 5 | import { type Invite, type InvitesOverview, MAX_INVITE_MESSAGE } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 8 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 9 | import { Badge } from "./ui/badge"; | |
| 10 | import { Button } from "./ui/button"; | |
| 11 | import { Card } from "./ui/card"; | |
| 12 | import { CheckboxOption } from "./ui/checkbox"; | |
| 13 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 14 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 15 | import { Field, FieldDescription, FieldLabel } from "./ui/field"; | |
| 16 | import { Hint } from "./ui/hint"; | |
| 17 | import { Input } from "./ui/input"; | |
| 18 | import { Progress } from "./ui/progress"; | |
| 19 | import { SelectField } from "./ui/select"; | |
| 20 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; | |
| 21 | import { Textarea } from "./ui/textarea"; | |
| 22 | import { type BringInto, G1T_INVITES, inviteActions, inviteLink, inviteState, invitationTone, remainingLine } from "../lib/invites"; | |
| 23 | import 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). */ | |
| 26 | function 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 | */ | |
| 42 | function 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. */ | |
| 166 | function 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 | */ | |
| 222 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.