mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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
@@ -16,6 +16,9 @@
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.sauci.app",
|
||||
"associatedDomains": [
|
||||
"applinks:sauci.app"
|
||||
],
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
@@ -194,6 +197,23 @@
|
||||
"android.permission.USE_BIOMETRIC",
|
||||
"android.permission.USE_FINGERPRINT",
|
||||
"com.android.vending.BILLING"
|
||||
],
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
"autoVerify": true,
|
||||
"data": [
|
||||
{
|
||||
"scheme": "https",
|
||||
"host": "sauci.app",
|
||||
"pathPrefix": "/join"
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
"BROWSABLE",
|
||||
"DEFAULT"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
|
||||
@@ -17,17 +17,24 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { coupleApi } from "../../src/lib/coupleApi";
|
||||
import { coupleApi } from "../../src/lib/coupleApi";
|
||||
import { getPairingError } from "../../src/lib/errors";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { router } from "expo-router";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
||||
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||
|
||||
export default function PairingScreen() {
|
||||
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string }>();
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||
|
||||
// Redirect if already paired
|
||||
useEffect(() => {
|
||||
@@ -36,25 +43,83 @@ export default function PairingScreen() {
|
||||
}
|
||||
}, [couple, partner]);
|
||||
|
||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
||||
// be added later without coupling product data back to Supabase.
|
||||
useEffect(() => {
|
||||
if (!couple || partner) return;
|
||||
// One-time prefill: apply a code carried by a join link (route param), then
|
||||
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||
// match. Never auto-submits; the user still taps "Join Partner".
|
||||
useEffect(() => {
|
||||
if (couple) return; // only relevant to the unpaired join flow
|
||||
if (prefillAttempted) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applyPrefill = async (code: string) => {
|
||||
if (cancelled) return;
|
||||
setInviteCode(code);
|
||||
setWasPrefilled(true);
|
||||
Events.pairingCodePrefilled();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||
if (isValidInviteCode(routeCode)) {
|
||||
await applyPrefill(routeCode);
|
||||
await clearPendingInviteCode();
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stashedCode = await getPendingInviteCode();
|
||||
if (stashedCode) {
|
||||
await applyPrefill(stashedCode);
|
||||
await clearPendingInviteCode();
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardCode = await checkClipboardForInviteCode();
|
||||
if (clipboardCode && !cancelled) {
|
||||
setClipboardOfferCode(clipboardCode);
|
||||
}
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [couple, params.code, prefillAttempted]);
|
||||
|
||||
const acceptClipboardOffer = () => {
|
||||
if (!clipboardOfferCode) return;
|
||||
setInviteCode(clipboardOfferCode);
|
||||
setWasPrefilled(true);
|
||||
Events.inviteLinkOpened("clipboard");
|
||||
Events.pairingCodePrefilled();
|
||||
setClipboardOfferCode(null);
|
||||
};
|
||||
|
||||
const dismissClipboardOffer = () => {
|
||||
setClipboardOfferCode(null);
|
||||
};
|
||||
|
||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
||||
// be added later without coupling product data back to Supabase.
|
||||
useEffect(() => {
|
||||
if (!couple || partner) return;
|
||||
|
||||
// Poll every 5 seconds as fallback
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchCouple();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [couple, partner, fetchCouple]);
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [couple, partner, fetchCouple]);
|
||||
|
||||
const handleCreateCouple = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.create();
|
||||
await coupleApi.create();
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
@@ -76,7 +141,7 @@ export default function PairingScreen() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.join(sanitizedCode);
|
||||
await coupleApi.join(sanitizedCode);
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
@@ -100,8 +165,13 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteCodeChange = (value: string) => {
|
||||
setWasPrefilled(false);
|
||||
setInviteCode(value);
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`
|
||||
? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
|
||||
: "";
|
||||
|
||||
const shareCode = async () => {
|
||||
@@ -144,7 +214,7 @@ export default function PairingScreen() {
|
||||
onPress: async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await coupleApi.cancel();
|
||||
await coupleApi.cancel();
|
||||
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
@@ -338,6 +408,37 @@ export default function PairingScreen() {
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Clipboard invite code offer */}
|
||||
{clipboardOfferCode && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(400)}
|
||||
style={styles.section}
|
||||
>
|
||||
<View style={styles.clipboardOffer} testID="clipboard-invite-offer">
|
||||
<Ionicons name="clipboard-outline" size={18} color={colors.primary} />
|
||||
<Text style={styles.clipboardOfferText}>
|
||||
Use code {clipboardOfferCode} from your clipboard?
|
||||
</Text>
|
||||
<View style={styles.clipboardOfferActions}>
|
||||
<TouchableOpacity
|
||||
onPress={dismissClipboardOffer}
|
||||
style={styles.clipboardOfferButton}
|
||||
testID="clipboard-invite-offer-dismiss"
|
||||
>
|
||||
<Text style={styles.clipboardOfferDismissText}>Not now</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={acceptClipboardOffer}
|
||||
style={[styles.clipboardOfferButton, styles.clipboardOfferButtonPrimary]}
|
||||
testID="clipboard-invite-offer-accept"
|
||||
>
|
||||
<Text style={styles.clipboardOfferAcceptText}>Use code</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Join Card */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
@@ -346,14 +447,20 @@ export default function PairingScreen() {
|
||||
<Text style={styles.sectionTitle}>Have a code?</Text>
|
||||
<GlassCard>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, wasPrefilled && styles.inputPrefilled]}
|
||||
placeholder="Enter invite code"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
value={inviteCode}
|
||||
onChangeText={setInviteCode}
|
||||
onChangeText={handleInviteCodeChange}
|
||||
autoCapitalize="characters"
|
||||
maxLength={8}
|
||||
testID="invite-code-input"
|
||||
/>
|
||||
{wasPrefilled && (
|
||||
<Text style={styles.prefilledCaption}>
|
||||
Code applied from your invite link
|
||||
</Text>
|
||||
)}
|
||||
<GlassButton
|
||||
onPress={handleJoinCouple}
|
||||
disabled={isSubmitting}
|
||||
@@ -436,6 +543,50 @@ const styles = StyleSheet.create({
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputPrefilled: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
prefilledCaption: {
|
||||
...typography.caption1,
|
||||
color: colors.primary,
|
||||
textAlign: "center",
|
||||
marginTop: -spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
clipboardOffer: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
clipboardOfferText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
},
|
||||
clipboardOfferActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
clipboardOfferButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
clipboardOfferButtonPrimary: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
clipboardOfferDismissText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
clipboardOfferAcceptText: {
|
||||
...typography.subhead,
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
|
||||
@@ -43,9 +43,12 @@ const GestureHandlerRootView = Platform.OS === "web"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { authClient } from "../src/lib/authClient";
|
||||
import { authClient } from "../src/lib/authClient";
|
||||
import { colors, spacing, radius, typography } from "../src/theme";
|
||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
||||
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
import { needsOnboarding } from "../src/constants/onboarding";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -117,6 +120,22 @@ export default function RootLayout() {
|
||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
||||
if (!url) return;
|
||||
|
||||
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
|
||||
// are handled separately from the Supabase auth deep link flow below.
|
||||
const inviteLink = parseInviteLinkCode(url);
|
||||
if (inviteLink) {
|
||||
console.log('[DeepLink] Invite link detected:', inviteLink);
|
||||
Events.inviteLinkOpened(inviteLink.source);
|
||||
await stashPendingInviteCode(inviteLink.code);
|
||||
|
||||
const { isAuthenticated, user } = useAuthStore.getState();
|
||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the URL to extract auth parameters
|
||||
const parsedUrl = new URL(url);
|
||||
@@ -148,7 +167,7 @@ export default function RootLayout() {
|
||||
if (accessToken && refreshToken) {
|
||||
// OAuth flow - set session directly
|
||||
console.log('[DeepLink] Setting session with tokens');
|
||||
const { data, error } = await authClient.auth.setSession({
|
||||
const { data, error } = await authClient.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
@@ -160,7 +179,7 @@ export default function RootLayout() {
|
||||
} else if (tokenHash && type) {
|
||||
// Magic link / email verification flow - verify the OTP
|
||||
console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
|
||||
const { data, error } = await authClient.auth.verifyOtp({
|
||||
const { data, error } = await authClient.auth.verifyOtp({
|
||||
token_hash: tokenHash,
|
||||
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
|
||||
});
|
||||
@@ -174,12 +193,12 @@ export default function RootLayout() {
|
||||
} else {
|
||||
// Fallback for web or other scenarios
|
||||
console.log('[DeepLink] No auth params found, falling back to getSession');
|
||||
await authClient.auth.getSession();
|
||||
await authClient.auth.getSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeepLink] Error handling deep link:', error);
|
||||
// Still try to get session as fallback
|
||||
await authClient.auth.getSession();
|
||||
await authClient.auth.getSession();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,7 +225,7 @@ export default function RootLayout() {
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
||||
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { needsOnboarding } from "../src/constants/onboarding";
|
||||
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
||||
|
||||
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||
// applied (e.g. the user tapped a join link before signing in).
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || user?.couple_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
getPendingInviteCode().then((code) => {
|
||||
if (cancelled) return;
|
||||
setHasPendingInvite(!!code);
|
||||
setPendingCodeChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
||||
|
||||
// Show loading state while auth is being determined
|
||||
if (isLoading) {
|
||||
@@ -25,6 +48,26 @@ export default function Index() {
|
||||
return <Redirect href="/(app)/onboarding" />;
|
||||
}
|
||||
|
||||
// A couple already exists for this user; no invite hand-off is relevant.
|
||||
if (user?.couple_id) {
|
||||
return <Redirect href="/(app)" />;
|
||||
}
|
||||
|
||||
// Wait for the one-time pending invite code check before deciding the
|
||||
// route, so a stashed code from a join link is applied without an extra
|
||||
// trip through the home screen.
|
||||
if (!pendingCodeChecked) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPendingInvite) {
|
||||
return <Redirect href="/(app)/pairing" />;
|
||||
}
|
||||
|
||||
// Go to home/dashboard - users can pair from within the app
|
||||
// No forced redirect to pairing for users without a couple
|
||||
return <Redirect href="/(app)" />;
|
||||
|
||||
@@ -3804,8 +3804,8 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
|
||||
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
|
||||
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
|
||||
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
|
||||
EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d
|
||||
@@ -3851,8 +3851,8 @@ SPEC CHECKSUMS:
|
||||
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
|
||||
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
|
||||
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
||||
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
||||
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
checkClipboardForInviteCode,
|
||||
__resetClipboardInviteOfferForTests,
|
||||
} from "../../lib/clipboardInviteOffer";
|
||||
|
||||
jest.mock("expo-clipboard", () => ({
|
||||
hasStringAsync: jest.fn(),
|
||||
getStringAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const hasStringAsync = Clipboard.hasStringAsync as jest.Mock;
|
||||
const getStringAsync = Clipboard.getStringAsync as jest.Mock;
|
||||
|
||||
describe("checkClipboardForInviteCode", () => {
|
||||
beforeEach(() => {
|
||||
__resetClipboardInviteOfferForTests();
|
||||
hasStringAsync.mockReset();
|
||||
getStringAsync.mockReset();
|
||||
});
|
||||
|
||||
it("returns a normalized code when the clipboard holds a validly-shaped code", async () => {
|
||||
hasStringAsync.mockResolvedValue(true);
|
||||
getStringAsync.mockResolvedValue("abcd1234");
|
||||
|
||||
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
|
||||
});
|
||||
|
||||
it("returns null when the clipboard is empty", async () => {
|
||||
hasStringAsync.mockResolvedValue(false);
|
||||
|
||||
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||
expect(getStringAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the clipboard string does not match the invite code shape", async () => {
|
||||
hasStringAsync.mockResolvedValue(true);
|
||||
getStringAsync.mockResolvedValue("hello world");
|
||||
|
||||
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("only checks the clipboard once per session", async () => {
|
||||
hasStringAsync.mockResolvedValue(true);
|
||||
getStringAsync.mockResolvedValue("abcd1234");
|
||||
|
||||
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
|
||||
expect(hasStringAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second call in the same session should not re-check the clipboard.
|
||||
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
|
||||
expect(hasStringAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { isValidInviteCode, normalizeInviteCode, parseInviteLinkCode } from "../../lib/inviteLink";
|
||||
|
||||
describe("isValidInviteCode", () => {
|
||||
it("accepts 8-character alphanumeric codes case-insensitively", () => {
|
||||
expect(isValidInviteCode("ABCD1234")).toBe(true);
|
||||
expect(isValidInviteCode("abcd1234")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects codes with the wrong length or characters", () => {
|
||||
expect(isValidInviteCode("ABC123")).toBe(false); // too short
|
||||
expect(isValidInviteCode("ABCD12345")).toBe(false); // too long
|
||||
expect(isValidInviteCode("ABCD-123")).toBe(false); // invalid character
|
||||
expect(isValidInviteCode("")).toBe(false);
|
||||
expect(isValidInviteCode(null)).toBe(false);
|
||||
expect(isValidInviteCode(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInviteCode", () => {
|
||||
it("trims and uppercases", () => {
|
||||
expect(normalizeInviteCode(" abcd1234 ")).toBe("ABCD1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseInviteLinkCode", () => {
|
||||
it("parses a universal link", () => {
|
||||
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234")).toEqual({
|
||||
code: "ABCD1234",
|
||||
source: "universal_link",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a universal link with a trailing slash", () => {
|
||||
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234/")).toEqual({
|
||||
code: "ABCD1234",
|
||||
source: "universal_link",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a custom scheme link", () => {
|
||||
expect(parseInviteLinkCode("app.sauci://join?code=abcd1234")).toEqual({
|
||||
code: "ABCD1234",
|
||||
source: "scheme",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an unrelated https URL", () => {
|
||||
expect(parseInviteLinkCode("https://sauci.app/redeem")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unrelated host", () => {
|
||||
expect(parseInviteLinkCode("https://example.com/join/abcd1234")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a custom scheme link with a badly-shaped code", () => {
|
||||
expect(parseInviteLinkCode("app.sauci://join?code=123")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a custom scheme link missing the join host", () => {
|
||||
expect(parseInviteLinkCode("app.sauci://login?token=abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for garbage input", () => {
|
||||
expect(parseInviteLinkCode("not a url")).toBeNull();
|
||||
expect(parseInviteLinkCode(null)).toBeNull();
|
||||
expect(parseInviteLinkCode(undefined)).toBeNull();
|
||||
expect(parseInviteLinkCode("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
stashPendingInviteCode,
|
||||
getPendingInviteCode,
|
||||
clearPendingInviteCode,
|
||||
} from "../../lib/pendingInviteCode";
|
||||
|
||||
describe("pendingInviteCode", () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
});
|
||||
|
||||
it("stashes, retrieves, and clears a valid code", async () => {
|
||||
await stashPendingInviteCode("abcd1234");
|
||||
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
||||
|
||||
await clearPendingInviteCode();
|
||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing has been stashed", async () => {
|
||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
||||
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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