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