mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge invite funnel join links into staging
This commit is contained in:
commit
ccd3f03437
22 files changed
+950
-23
No files matched your search
@@ -16,6 +16,9 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
|
"associatedDomains": [
|
||||||
|
"applinks:sauci.app"
|
||||||
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
@@ -194,6 +197,23 @@
|
|||||||
"android.permission.USE_BIOMETRIC",
|
"android.permission.USE_BIOMETRIC",
|
||||||
"android.permission.USE_FINGERPRINT",
|
"android.permission.USE_FINGERPRINT",
|
||||||
"com.android.vending.BILLING"
|
"com.android.vending.BILLING"
|
||||||
|
],
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"autoVerify": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"scheme": "https",
|
||||||
|
"host": "sauci.app",
|
||||||
|
"pathPrefix": "/join"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": [
|
||||||
|
"BROWSABLE",
|
||||||
|
"DEFAULT"
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ import { LinearGradient } from "expo-linear-gradient";
|
|||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useAuthStore } from "../../src/store";
|
||||||
import { coupleApi } from "../../src/lib/coupleApi";
|
import { coupleApi } from "../../src/lib/coupleApi";
|
||||||
import { getPairingError } from "../../src/lib/errors";
|
import { getPairingError } from "../../src/lib/errors";
|
||||||
import { Events } from "../../src/lib/analytics";
|
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 { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
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() {
|
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 [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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
|
// Redirect if already paired
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,25 +43,83 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
|
|
||||||
// Poll the standalone API while waiting for a partner. Realtime delivery can
|
// One-time prefill: apply a code carried by a join link (route param), then
|
||||||
// be added later without coupling product data back to Supabase.
|
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||||
useEffect(() => {
|
// match. Never auto-submits; the user still taps "Join Partner".
|
||||||
if (!couple || partner) return;
|
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
|
// Poll every 5 seconds as fallback
|
||||||
const pollInterval = setInterval(() => {
|
const pollInterval = setInterval(() => {
|
||||||
fetchCouple();
|
fetchCouple();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
};
|
};
|
||||||
}, [couple, partner, fetchCouple]);
|
}, [couple, partner, fetchCouple]);
|
||||||
|
|
||||||
const handleCreateCouple = async () => {
|
const handleCreateCouple = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await coupleApi.create();
|
await coupleApi.create();
|
||||||
|
|
||||||
await fetchUser(); // Refresh user to get couple_id
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
await fetchCouple(); // Fetch couple data
|
await fetchCouple(); // Fetch couple data
|
||||||
@@ -76,7 +141,7 @@ export default function PairingScreen() {
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await coupleApi.join(sanitizedCode);
|
await coupleApi.join(sanitizedCode);
|
||||||
|
|
||||||
await fetchUser(); // Refresh user to get couple_id
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
await fetchCouple(); // Fetch couple data
|
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
|
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 () => {
|
const shareCode = async () => {
|
||||||
@@ -144,7 +214,7 @@ export default function PairingScreen() {
|
|||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await coupleApi.cancel();
|
await coupleApi.cancel();
|
||||||
|
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
await fetchCouple();
|
await fetchCouple();
|
||||||
@@ -338,6 +408,37 @@ export default function PairingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</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 */}
|
{/* Join Card */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
@@ -346,14 +447,20 @@ export default function PairingScreen() {
|
|||||||
<Text style={styles.sectionTitle}>Have a code?</Text>
|
<Text style={styles.sectionTitle}>Have a code?</Text>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[styles.input, wasPrefilled && styles.inputPrefilled]}
|
||||||
placeholder="Enter invite code"
|
placeholder="Enter invite code"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChangeText={setInviteCode}
|
onChangeText={handleInviteCodeChange}
|
||||||
autoCapitalize="characters"
|
autoCapitalize="characters"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
|
testID="invite-code-input"
|
||||||
/>
|
/>
|
||||||
|
{wasPrefilled && (
|
||||||
|
<Text style={styles.prefilledCaption}>
|
||||||
|
Code applied from your invite link
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleJoinCouple}
|
onPress={handleJoinCouple}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -436,6 +543,50 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||||
marginBottom: spacing.md,
|
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: {
|
dividerLine: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ const GestureHandlerRootView = Platform.OS === "web"
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAuthStore } from "../src/store";
|
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 { colors, spacing, radius, typography } from "../src/theme";
|
||||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
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();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -117,6 +120,22 @@ export default function RootLayout() {
|
|||||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
||||||
if (!url) return;
|
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 {
|
try {
|
||||||
// Parse the URL to extract auth parameters
|
// Parse the URL to extract auth parameters
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
@@ -148,7 +167,7 @@ export default function RootLayout() {
|
|||||||
if (accessToken && refreshToken) {
|
if (accessToken && refreshToken) {
|
||||||
// OAuth flow - set session directly
|
// OAuth flow - set session directly
|
||||||
console.log('[DeepLink] Setting session with tokens');
|
console.log('[DeepLink] Setting session with tokens');
|
||||||
const { data, error } = await authClient.auth.setSession({
|
const { data, error } = await authClient.auth.setSession({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
@@ -160,7 +179,7 @@ export default function RootLayout() {
|
|||||||
} else if (tokenHash && type) {
|
} else if (tokenHash && type) {
|
||||||
// Magic link / email verification flow - verify the OTP
|
// Magic link / email verification flow - verify the OTP
|
||||||
console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
|
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,
|
token_hash: tokenHash,
|
||||||
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
|
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
|
||||||
});
|
});
|
||||||
@@ -174,12 +193,12 @@ export default function RootLayout() {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback for web or other scenarios
|
// Fallback for web or other scenarios
|
||||||
console.log('[DeepLink] No auth params found, falling back to getSession');
|
console.log('[DeepLink] No auth params found, falling back to getSession');
|
||||||
await authClient.auth.getSession();
|
await authClient.auth.getSession();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DeepLink] Error handling deep link:', error);
|
console.error('[DeepLink] Error handling deep link:', error);
|
||||||
// Still try to get session as fallback
|
// 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
|
// Listen for auth changes
|
||||||
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
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 { Redirect } from "expo-router";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||||
import { needsOnboarding } from "../src/constants/onboarding";
|
import { needsOnboarding } from "../src/constants/onboarding";
|
||||||
|
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
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
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -25,6 +48,26 @@ export default function Index() {
|
|||||||
return <Redirect href="/(app)/onboarding" />;
|
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
|
// Go to home/dashboard - users can pair from within the app
|
||||||
// No forced redirect to pairing for users without a couple
|
// No forced redirect to pairing for users without a couple
|
||||||
return <Redirect href="/(app)" />;
|
return <Redirect href="/(app)" />;
|
||||||
|
|||||||
@@ -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
|
// Invite events
|
||||||
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
||||||
|
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
||||||
|
logEvent("invite_link_opened", { source }),
|
||||||
|
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
||||||
|
|
||||||
// Tutorial events
|
// Tutorial events
|
||||||
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const componentPath = new URL('./JoinClient.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('/join/[code] JoinClient', () => {
|
||||||
|
it('validates the code shape client-side without calling a private API', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("isValidInviteCode(code)")
|
||||||
|
expect(source).not.toMatch(/\/public\/v1\//)
|
||||||
|
expect(source).not.toMatch(/fetch\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attempts the app scheme link and shows the raw code fallback', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('buildInviteSchemeUrl')
|
||||||
|
expect(source).toContain('invite-code-display')
|
||||||
|
expect(source).toContain('Copy code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies only the raw code, not a share message', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('navigator.clipboard.writeText(normalizedCode)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to both app stores', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('APP_STORE_URL')
|
||||||
|
expect(source).toContain('PLAY_STORE_URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('instruments the funnel with PostHog page view, copy, and store-click events', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_viewed'")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_code_copied')")
|
||||||
|
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a clear fallback message for a malformed invite code', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('Invite link not recognized')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Check, Copy, Heart } from 'lucide-react'
|
||||||
|
import { usePostHog } from 'posthog-js/react'
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
||||||
|
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
|
||||||
|
|
||||||
|
export function JoinClient({ code }: { code: string }) {
|
||||||
|
const posthog = usePostHog()
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const hasAttemptedOpen = useRef(false)
|
||||||
|
|
||||||
|
const isValid = isValidInviteCode(code)
|
||||||
|
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code])
|
||||||
|
|
||||||
|
// Track the page view once. Fires regardless of code validity so we can
|
||||||
|
// see broken/expired links in the funnel too.
|
||||||
|
useEffect(() => {
|
||||||
|
posthog?.capture('join_page_viewed', { code_valid: isValid })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Attempt to open the app via the custom scheme once. If the app is
|
||||||
|
// installed and registered for the scheme, this hands off to it; if not,
|
||||||
|
// nothing visible happens and the fallback UI below remains in view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValid || hasAttemptedOpen.current) return
|
||||||
|
hasAttemptedOpen.current = true
|
||||||
|
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
||||||
|
}, [isValid, normalizedCode])
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(normalizedCode)
|
||||||
|
setCopied(true)
|
||||||
|
posthog?.capture('join_page_code_copied')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy invite code:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStoreClick = (store: 'app_store' | 'play_store') => {
|
||||||
|
posthog?.capture('join_page_store_button_clicked', { store })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
||||||
|
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
|
||||||
|
<p className="text-white/60 mb-8">
|
||||||
|
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn-primary inline-block">
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Back to Home</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
|
||||||
|
<Heart className="w-10 h-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to Sauci</h1>
|
||||||
|
<p className="text-white/60 mb-10">
|
||||||
|
Open the app to pair up instantly. If it doesn't open automatically, copy your code below and
|
||||||
|
enter it after you install Sauci.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="glass-light p-6 sm:p-8 mb-8">
|
||||||
|
<p className="text-white/60 text-sm mb-3">Your invite code</p>
|
||||||
|
<p
|
||||||
|
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||||
|
data-testid="invite-code-display"
|
||||||
|
>
|
||||||
|
{normalizedCode}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
Copy code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/40 text-sm mb-6">Don't have Sauci yet? Get the app first:</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href={APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => handleStoreClick('app_store')}
|
||||||
|
className="btn-primary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Download on the App Store
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={PLAY_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => handleStoreClick('play_store')}
|
||||||
|
className="btn-secondary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Get it on Google Play
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
||||||
|
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
|
<div className="flex gap-6 text-sm text-white/40">
|
||||||
|
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
|
||||||
|
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
|
||||||
|
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { JoinClient } from './JoinClient'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Join your partner on Sauci',
|
||||||
|
description: 'Open Sauci and pair up instantly with your invite code.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JoinPage({ params }: { params: { code: string } }) {
|
||||||
|
return <JoinClient code={params.code} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// App store and deep-link destinations used by the invite join page
|
||||||
|
// (apps/web/app/join/[code]/page.tsx). These are the real, live listing URLs
|
||||||
|
// already used by components/Hero.tsx, components/CTA.tsx,
|
||||||
|
// components/Navbar.tsx, and components/Pricing.tsx.
|
||||||
|
|
||||||
|
export const APP_STORE_URL = 'https://apps.apple.com/gb/app/sauci-couple-games-romance/id6757159885'
|
||||||
|
export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
|
||||||
|
|
||||||
|
export function buildInviteSchemeUrl(code: string): string {
|
||||||
|
return `app.sauci://join?code=${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUniversalUrl(code: string): string {
|
||||||
|
return `https://sauci.app/join/${encodeURIComponent(code)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isValidInviteCode, normalizeInviteCode } from './inviteCode'
|
||||||
|
|
||||||
|
describe('isValidInviteCode', () => {
|
||||||
|
it('accepts 8-character alphanumeric codes', () => {
|
||||||
|
expect(isValidInviteCode('ABCD1234')).toBe(true)
|
||||||
|
expect(isValidInviteCode('abcd1234')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects codes with the wrong length or characters', () => {
|
||||||
|
expect(isValidInviteCode('ABC123')).toBe(false)
|
||||||
|
expect(isValidInviteCode('ABCD12345')).toBe(false)
|
||||||
|
expect(isValidInviteCode('ABCD-123')).toBe(false)
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Shared invite code shape validation for the web join page. Mirrors the
|
||||||
|
// mobile app's invite code shape (apps/mobile/src/lib/inviteLink.ts) and the
|
||||||
|
// standalone API's invite_code column. This module only validates the shape
|
||||||
|
// client-side; it never calls a private API.
|
||||||
|
|
||||||
|
const INVITE_CODE_PATTERN = /^[A-Za-z0-9]{8}$/
|
||||||
|
|
||||||
|
export function isValidInviteCode(code: string | null | undefined): boolean {
|
||||||
|
if (!code) return false
|
||||||
|
return INVITE_CODE_PATTERN.test(code.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInviteCode(code: string): string {
|
||||||
|
return code.trim().toUpperCase()
|
||||||
|
}
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
// No file extension by design; iOS requires this exact path and
|
||||||
|
// rejects a response that isn't served as application/json.
|
||||||
|
source: '/.well-known/apple-app-site-association',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Type', value: 'application/json' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/assetlinks.json',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Content-Type', value: 'application/json' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"applinks": {
|
||||||
|
"apps": [],
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"appID": "BXVPBATDZY.com.sauci.app",
|
||||||
|
"paths": ["/join/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"65:2F:E7:60:3F:2E:D2:54:8B:4A:EF:0E:F1:AC:35:3E:DD:13:13:EF:35:E7:90:A7:01:1F:89:0F:4F:71:E2:21"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const aasaPath = new URL('./apple-app-site-association', import.meta.url)
|
||||||
|
const assetlinksPath = new URL('./assetlinks.json', import.meta.url)
|
||||||
|
|
||||||
|
describe('apple-app-site-association', () => {
|
||||||
|
it('is valid JSON that covers /join/*', async () => {
|
||||||
|
const raw = await readFile(aasaPath, 'utf8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
|
||||||
|
expect(data.applinks.details).toHaveLength(1)
|
||||||
|
expect(data.applinks.details[0].paths).toContain('/join/*')
|
||||||
|
// com.sauci.app matches apps/mobile/app.json ios.bundleIdentifier.
|
||||||
|
expect(data.applinks.details[0].appID).toMatch(/\.com\.sauci\.app$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('assetlinks.json', () => {
|
||||||
|
it('is valid JSON that targets the Android package', async () => {
|
||||||
|
const raw = await readFile(assetlinksPath, 'utf8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
|
||||||
|
expect(data[0].target.package_name).toBe('com.sauci.app')
|
||||||
|
expect(data[0].target.namespace).toBe('android_app')
|
||||||
|
// Must be the Play App Signing key certificate fingerprint (Play Console >
|
||||||
|
// Test and release > Setup > App signing), colon-separated uppercase hex.
|
||||||
|
expect(data[0].target.sha256_cert_fingerprints[0]).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -263,6 +263,55 @@ DELETE delete-relationship
|
|||||||
Both users: Unpaired
|
Both users: Unpaired
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Invite Funnel (Join Links)
|
||||||
|
|
||||||
|
The invite code can also be delivered as a tappable link instead of requiring
|
||||||
|
the recipient to type it in manually.
|
||||||
|
|
||||||
|
**Link forms:**
|
||||||
|
- Universal/App Link: `https://sauci.app/join/{code}` (served by `apps/web`,
|
||||||
|
opens the app directly on a device that has it installed and verified).
|
||||||
|
- Custom scheme: `app.sauci://join?code={code}` (used by the web join page's
|
||||||
|
automatic hand-off attempt, and remains supported for any existing client
|
||||||
|
behavior).
|
||||||
|
|
||||||
|
**Web fallback (`apps/web/app/join/[code]/page.tsx`):** validates the code
|
||||||
|
shape client-side only (8-character alphanumeric; never calls a private API),
|
||||||
|
attempts the custom scheme link, and otherwise shows the code with a copy
|
||||||
|
button plus App Store / Play Store links. Copying only copies the raw code.
|
||||||
|
|
||||||
|
**Association files:** `apps/web/public/.well-known/apple-app-site-association`
|
||||||
|
and `apps/web/public/.well-known/assetlinks.json` authorize `sauci.app` to open
|
||||||
|
the app for `/join/*`. The Apple Team ID (`BXVPBATDZY`, from
|
||||||
|
`apps/mobile/app.json` and `apps/mobile/eas.json`) and bundle/package id
|
||||||
|
(`com.sauci.app`) are real values already used elsewhere in the repo. The
|
||||||
|
SHA-256 fingerprint in `assetlinks.json` is the Play App Signing key
|
||||||
|
certificate fingerprint from the Play Console (Test and release > Setup >
|
||||||
|
App signing); if the app signing key ever rotates, this file must be updated
|
||||||
|
or Android App Links will stop verifying.
|
||||||
|
|
||||||
|
**Mobile link handling:** `apps/mobile/app.json` declares
|
||||||
|
`associatedDomains: ["applinks:sauci.app"]` (iOS) and an intent filter for
|
||||||
|
`https://sauci.app/join/*` (Android), in addition to the existing `app.sauci`
|
||||||
|
scheme. `apps/mobile/app/_layout.tsx` parses incoming URLs
|
||||||
|
(`src/lib/inviteLink.ts`) and either routes a signed-in, onboarded user
|
||||||
|
straight to the pairing screen with the code pre-filled, or stashes the code
|
||||||
|
(`src/lib/pendingInviteCode.ts`) for signed-out/mid-onboarding users. The
|
||||||
|
stashed code is applied once the user reaches the pairing screen or the app's
|
||||||
|
initial route (`apps/mobile/app/index.tsx`) after sign-in/onboarding
|
||||||
|
completes.
|
||||||
|
|
||||||
|
**Deferred clipboard hand-off:** if an unpaired user opens the pairing screen
|
||||||
|
with no code from a link or stash, the app checks the clipboard once per
|
||||||
|
session (`src/lib/clipboardInviteOffer.ts`) for a string matching the invite
|
||||||
|
code shape and offers to use it ("Use code XXXXXXXX from your clipboard?").
|
||||||
|
The code is never applied automatically; the user must accept the offer.
|
||||||
|
|
||||||
|
**Analytics:** `invite_link_opened` (`source`: `universal_link` | `scheme` |
|
||||||
|
`clipboard`), `pairing_code_prefilled` (mobile, Firebase Analytics), and
|
||||||
|
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
||||||
|
(web, PostHog).
|
||||||
|
|
||||||
## Cascade Behavior
|
## Cascade Behavior
|
||||||
|
|
||||||
When a couple is deleted, foreign key cascades clean up:
|
When a couple is deleted, foreign key cascades clean up:
|
||||||
|
|||||||
Reference in new issue
Block a user