| 1 | /** |
| 2 | * The Invites page: every invite, to find and revoke one, with invites to |
| 3 | * g1t from Invite a friend on their own tab, and more of those for a |
| 4 | * person. Anyone may register, so there is no waitlist and no shared link |
| 5 | * to manage; an invite is never needed to sign up. No Workers imports, so |
| 6 | * it can be tested under Node. Identity checks everything again. |
| 7 | */ |
| 8 | |
| 9 | /** The page's tabs: every invite, or only invites to g1t (Invite a friend). */ |
| 10 | export type InvitesTab = "all" | "g1t"; |
| 11 | |
| 12 | export function invitesTab(raw: string | null | undefined): InvitesTab { |
| 13 | return raw === "g1t" ? "g1t" : "all"; |
| 14 | } |
| 15 | |
| 16 | /** The page's address, with its search. */ |
| 17 | export function invitesHref(params: Record<string, string | null | undefined> = {}): string { |
| 18 | const search = new URLSearchParams(); |
| 19 | for (const [key, value] of Object.entries(params)) if (value) search.set(key, value); |
| 20 | const query = search.toString(); |
| 21 | return query ? `/invites?${query}` : "/invites"; |
| 22 | } |
| 23 | |
| 24 | /** The flash messages the page redirects back with (`?done=`). */ |
| 25 | export const INVITES_DONE: Record<string, string> = { |
| 26 | revoked: "Invite revoked. It stops working at once.", |
| 27 | granted: "Granted. It applies at once.", |
| 28 | }; |
| 29 | |
| 30 | /** The flash for `?done=`. */ |
| 31 | export function doneMessage(done: string | null): string | null { |
| 32 | return done ? (INVITES_DONE[done] ?? null) : null; |
| 33 | } |
| 34 | |
| 35 | /** What an account made with a shared link, before registration opened, says about where it came from. */ |
| 36 | export function joinedThrough(label: string): string { |
| 37 | return `Joined through ${label}`; |
| 38 | } |
| 39 | |
| 40 | /** The most invites one grant gives or takes back (`MAX_INVITE_GRANT` in the contracts). */ |
| 41 | export const MAX_GRANT = 1000; |
| 42 | |
| 43 | const USERNAME = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; |
| 44 | |
| 45 | export type Grant = { username: string; amount: number; note: string }; |
| 46 | |
| 47 | /** A grant of invites to g1t, as typed: who, how many (negative takes back), and why. */ |
| 48 | export function parseGrant(form: { get(name: string): unknown }): { ok: true; value: Grant } | { ok: false; error: string } { |
| 49 | const read = (name: string) => { |
| 50 | const value = form.get(name); |
| 51 | return typeof value === "string" ? value.trim() : ""; |
| 52 | }; |
| 53 | const username = read("username").replace(/^@/, "").toLowerCase(); |
| 54 | if (!USERNAME.test(username)) return { ok: false, error: `“${read("username")}” is not a username.` }; |
| 55 | const raw = read("amount"); |
| 56 | if (!/^-?\d+$/.test(raw)) return { ok: false, error: "Give a whole number of invites, such as 10, or -5 to take some back." }; |
| 57 | const amount = Number(raw); |
| 58 | if (amount === 0 || Math.abs(amount) > MAX_GRANT) return { ok: false, error: `Grant between 1 and ${MAX_GRANT} invites.` }; |
| 59 | const note = read("note"); |
| 60 | if (note.length < 3) return { ok: false, error: "Say why, for the record." }; |
| 61 | return { ok: true, value: { username, amount, note: note.slice(0, 500) } }; |
| 62 | } |