90 lines3,781 bytesCodeBlame
1/**
2 * Invites, as the site shows them: what sign-up says while g1t is
3 * invite-only, links to an invite, and how each invite reads in a list.
4 * No Workers or React imports, so it can be tested under Node.
5 */
6
7/** Where people ask for more invites: support's mailbox (`CONTACT.support`). */
8export const INVITES_CONTACT = "hey@flagon.io";
9
10/** The subject that sorts a request for invites, as the support page lists them. */
11export const INVITES_SUBJECT = "[g1t Invites] ";
12
13/** A mail link asking for more invites, for a person or a workspace. */
14export function moreInvitesMailto(about?: string): string {
15 const subject = `${INVITES_SUBJECT}${about ? `More invites for ${about}` : "More invites"}`;
16 return `mailto:${INVITES_CONTACT}?subject=${encodeURIComponent(subject)}`;
17}
18
19/** What the sign-up buttons say. While invite-only, nobody can just sign up. */
20export function signUpCopy(inviteOnly: boolean): { primary: string; secondary: string | null } {
21 return inviteOnly ? { primary: "Request access", secondary: "Have an invite?" } : { primary: "Sign up", secondary: null };
22}
23
24/** The /register address that opens on the invite-code field. */
25export const HAVE_AN_INVITE = "/register#invite";
26
27/** The address an invite link has. */
28export function inviteLink(code: string, origin = "https://g1t.sh"): string {
29 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
30}
31
32/**
33 * An invite code from however someone pasted it: the code, a whole
34 * invite link, or a /register?invite= address. Identity reads it again;
35 * this only tidies what is put back into a form.
36 */
37export function cleanCode(raw: string | null | undefined): string {
38 let text = (raw ?? "").trim();
39 const param = /[?&]invite=([^&#\s]+)/i.exec(text);
40 if (param) text = decodeURIComponent(param[1]!);
41 else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? "";
42 return text.replace(/\s+/g, "").slice(0, 80);
43}
44
45type Listed = {
46 status: "pending" | "redeemed" | "expired" | "revoked";
47 redeemedBy: string | null;
48 email: string | null;
49 workspace: string | null;
50};
51
52/** How an invite's state reads in a list. */
53export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } {
54 switch (invite.status) {
55 case "pending":
56 return { label: "Pending", tone: "pending" };
57 case "redeemed":
58 return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" };
59 case "expired":
60 return { label: "Expired", tone: "dead" };
61 case "revoked":
62 return { label: "Revoked", tone: "dead" };
63 }
64}
65
66/** Who an invite is for, in a list. */
67export function inviteFor(invite: Listed): string {
68 const who = invite.email ?? "Anyone with the link";
69 return invite.workspace ? `${who} · joins ${invite.workspace}` : who;
70}
71
72/** How many invites are left, in words. */
73export function remainingLine(allowance: { limit: number | null; used: number; remaining: number | null }): string {
74 if (allowance.limit == null) return "No limit on your invites";
75 const left = allowance.remaining ?? 0;
76 if (left === 0) return `You have used all ${allowance.limit} of your invites`;
77 return `${left} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`;
78}
79
80/**
81 * Whether a sign-up or access form was filled in by a bot: the hidden
82 * `website` field people never see, or a form sent back faster than a
83 * person types.
84 */
85export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean {
86 const trap = form.get("website");
87 if (typeof trap === "string" && trap.trim() !== "") return true;
88 const started = Number(form.get("started"));
89 return Number.isFinite(started) && started > 0 && now - started < 1500;
90}