| 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 | } |