Merge branch 'staging' into task/quiz-viral

This commit is contained in:
jjbrunton committed 2026-08-30 18:10:24 +01:00
commit 23db4ed951
23 files changed
+954 -27

No files matched your search

+168 -17
View File
@@ -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,
+25 -6
View File
@@ -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") {
+43
View File
@@ -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)" />;