pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/lib/invites.ts
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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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`). */ | |
| 8 | export const INVITES_CONTACT = "hey@flagon.io"; | |
| 9 | ||
| 10 | /** The subject that sorts a request for invites, as the support page lists them. */ | |
| 11 | export const INVITES_SUBJECT = "[g1t Invites] "; | |
| 12 | ||
| 13 | /** A mail link asking for more invites, for a person or a workspace. */ | |
| 14 | export 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. */ | |
| 20 | export 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. */ | |
| 25 | export const HAVE_AN_INVITE = "/register#invite"; | |
| 26 | ||
| 27 | /** The address an invite link has. */ | |
| 28 | export 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 | */ | |
| 37 | export 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 | ||
| 45 | type 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. */ | |
| 53 | export 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. */ | |
| 67 | export 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. */ | |
| 73 | export 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 | */ | |
| 85 | export 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 | } |