feat(invite-funnel): add web join page and mobile deep-link hand-off

Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 17:58:43 +01:00
1 parent 5564f9bbab
commit d48b44baa8
22 files changed
+950 -23

No files matched your search

+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") {