mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Merge branch 'staging' into task/quiz-viral
This commit is contained in:
commit
23db4ed951
23 files changed
+954
-27
No files matched your search
@@ -272,6 +272,9 @@ export const Events = {
|
||||
|
||||
// Invite events
|
||||
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
||||
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||
logEvent("invite_link_opened", { source }),
|
||||
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
||||
|
||||
// Quiz events
|
||||
quizStarted: () => logEvent("quiz_started"),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Deferred hand-off fallback: when a user copies an invite code on another
|
||||
// device (e.g. from the web join page) then opens the app, offer to use the
|
||||
// code found on the clipboard. Never auto-applies the code; the caller must
|
||||
// present a confirmation before using the result. Only checks once per app
|
||||
// session so the user is not repeatedly prompted.
|
||||
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||
|
||||
let hasCheckedThisSession = false;
|
||||
|
||||
/**
|
||||
* Checks the clipboard once per session for a string matching the invite
|
||||
* code shape. Returns the normalized code, or null when there is nothing
|
||||
* usable (or the check already ran this session).
|
||||
*/
|
||||
export async function checkClipboardForInviteCode(): Promise<string | null> {
|
||||
if (hasCheckedThisSession) return null;
|
||||
hasCheckedThisSession = true;
|
||||
|
||||
try {
|
||||
const hasString = await Clipboard.hasStringAsync();
|
||||
if (!hasString) return null;
|
||||
|
||||
const value = await Clipboard.getStringAsync();
|
||||
if (value && isValidInviteCode(value)) {
|
||||
return normalizeInviteCode(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking clipboard for invite code:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Test-only helper to reset the per-session gating between test cases. */
|
||||
export function __resetClipboardInviteOfferForTests(): void {
|
||||
hasCheckedThisSession = false;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Shared invite code shape and deep-link parsing for the invite funnel.
|
||||
//
|
||||
// Two link forms carry an invite code into the app:
|
||||
// - Universal/App Link: https://sauci.app/join/{code}
|
||||
// - Custom scheme link: app.sauci://join?code={code}
|
||||
//
|
||||
// Both forms are handled by the root layout's deep link listener and by the
|
||||
// pairing screen when it receives a `code` route param.
|
||||
|
||||
export const INVITE_CODE_LENGTH = 8;
|
||||
const INVITE_CODE_PATTERN = /^[A-Z0-9]{8}$/;
|
||||
|
||||
export type InviteLinkSource = "universal_link" | "scheme";
|
||||
|
||||
export interface ParsedInviteLink {
|
||||
code: string;
|
||||
source: InviteLinkSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `code` (case-insensitive) matches the 8-character
|
||||
* alphanumeric invite code shape used across the app.
|
||||
*/
|
||||
export function isValidInviteCode(code: string | null | undefined): boolean {
|
||||
if (!code) return false;
|
||||
return INVITE_CODE_PATTERN.test(code.trim().toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw invite code string to the canonical uppercase form used
|
||||
* for display, storage, and API calls.
|
||||
*/
|
||||
export function normalizeInviteCode(code: string): string {
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an incoming URL for an invite code, supporting both the universal
|
||||
* link form (https://sauci.app/join/{code}) and the custom scheme form
|
||||
* (app.sauci://join?code={code}). Returns null when the URL does not carry a
|
||||
* recognizable, validly-shaped invite code.
|
||||
*/
|
||||
export function parseInviteLinkCode(url: string | null | undefined): ParsedInviteLink | null {
|
||||
if (!url) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawCode: string | null = null;
|
||||
let source: InviteLinkSource | null = null;
|
||||
|
||||
if (parsed.protocol === "https:" && parsed.hostname === "sauci.app") {
|
||||
const match = parsed.pathname.match(/^\/join\/([^/]+)\/?$/);
|
||||
if (match) {
|
||||
rawCode = match[1];
|
||||
source = "universal_link";
|
||||
}
|
||||
} else if (parsed.protocol === "app.sauci:") {
|
||||
const host = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
|
||||
if (host === "join") {
|
||||
rawCode = parsed.searchParams.get("code");
|
||||
source = "scheme";
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawCode || !source) return null;
|
||||
|
||||
const code = normalizeInviteCode(rawCode);
|
||||
if (!isValidInviteCode(code)) return null;
|
||||
|
||||
return { code, source };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Deferred hand-off storage for an invite code received before the user is
|
||||
// signed in or has finished onboarding. The code is applied once the user
|
||||
// reaches the pairing screen (see app/(app)/pairing.tsx and app/index.tsx).
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||
|
||||
const STORAGE_KEY = "pending_invite_code";
|
||||
|
||||
export async function stashPendingInviteCode(code: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
||||
} catch (error) {
|
||||
console.error("Error stashing pending invite code:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingInviteCode(): Promise<string | null> {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (value && isValidInviteCode(value)) {
|
||||
return normalizeInviteCode(value);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error reading pending invite code:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPendingInviteCode(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error("Error clearing pending invite code:", error);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user