Files
Sauci/apps/mobile/app/(app)/pairing.tsx
T
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00

590 lines
21 KiB
TypeScript
Executable File

import { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
StyleSheet,
Alert,
ActivityIndicator,
Share,
Linking,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
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 { getPairingError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics";
import { router } from "expo-router";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Redirect if already paired
useEffect(() => {
if (couple && partner) {
router.replace("/(app)");
}
}, [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;
// Poll every 5 seconds as fallback
const pollInterval = setInterval(() => {
fetchCouple();
}, 5000);
return () => {
clearInterval(pollInterval);
};
}, [couple, partner, fetchCouple]);
const handleCreateCouple = async () => {
setIsSubmitting(true);
try {
await coupleApi.create();
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
Events.coupleCreated();
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
} finally {
setIsSubmitting(false);
}
};
const handleJoinCouple = async () => {
// Validate invite code format (8 alphanumeric characters)
const sanitizedCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
return;
}
setIsSubmitting(true);
try {
await coupleApi.join(sanitizedCode);
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
]);
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
} finally {
setIsSubmitting(false);
}
};
const copyToClipboard = async () => {
if (couple?.invite_code) {
await Clipboard.setStringAsync(couple.invite_code);
Events.inviteCodeCopied();
Alert.alert("Copied", "Invite code copied to clipboard");
}
};
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}`
: "";
const shareCode = async () => {
if (couple?.invite_code) {
try {
await Share.share({ message: shareMessage });
Events.codeShared();
} catch (error) {
console.error(error);
}
}
};
const shareViaWhatsApp = async () => {
const url = `whatsapp://send?text=${encodeURIComponent(shareMessage)}`;
const canOpen = await Linking.canOpenURL(url);
if (canOpen) {
await Linking.openURL(url);
Events.codeShared();
} else {
shareCode();
}
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const handleCancelPairing = async () => {
Alert.alert(
"Cancel Pairing",
"Are you sure you want to cancel? Your invite code will be deleted.",
[
{ text: "Keep Waiting", style: "cancel" },
{
text: "Cancel Pairing",
style: "destructive",
onPress: async () => {
setIsSubmitting(true);
try {
await coupleApi.cancel();
await fetchUser();
await fetchCouple();
Events.pairingCancelled();
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
} finally {
setIsSubmitting(false);
}
},
},
]
);
};
if (isAuthLoading) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</GradientBackground>
);
}
// If user has a couple but no partner, they are waiting
if (couple && !partner) {
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.7}
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Partner Code</Text>
</Animated.View>
<View style={styles.content}>
{/* Icon */}
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.iconSection}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Share this code with your partner to link your accounts
</Text>
</Animated.View>
{/* Code Card */}
<Animated.View
entering={FadeInDown.delay(300).duration(500)}
style={styles.section}
>
<GlassCard variant="elevated">
<TouchableOpacity
style={styles.codeContainer}
onPress={copyToClipboard}
activeOpacity={0.7}
>
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
<View style={styles.copyIcon}>
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<Text style={styles.tapToCopy}>Tap to copy</Text>
</GlassCard>
</Animated.View>
{/* CTA Text */}
<Animated.View
entering={FadeInDown.delay(350).duration(500)}
style={styles.ctaSection}
>
<Text style={styles.ctaText}>Send this to your partner to get started</Text>
</Animated.View>
{/* Primary Share Button */}
<Animated.View
entering={FadeInDown.delay(400).duration(500)}
style={styles.section}
>
<GlassButton
onPress={shareCode}
fullWidth
size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
>
Share Invite Code
</GlassButton>
</Animated.View>
{/* Quick share buttons */}
<Animated.View
entering={FadeInDown.delay(450).duration(500)}
style={styles.quickShareRow}
>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaSMS} activeOpacity={0.7}>
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Messages</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>WhatsApp</Text>
</TouchableOpacity>
</Animated.View>
{/* Waiting indicator */}
<Animated.View
entering={FadeInDown.delay(500).duration(500)}
style={styles.waitingSection}
>
<View style={styles.waitingBadge}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
</View>
</Animated.View>
{/* Cancel Button */}
<Animated.View
entering={FadeInDown.delay(600).duration(500)}
style={styles.cancelSection}
>
<TouchableOpacity
onPress={handleCancelPairing}
disabled={isSubmitting}
style={styles.cancelButton}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
</TouchableOpacity>
</Animated.View>
</View>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{/* Header */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.7}
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Pair Up</Text>
</Animated.View>
<View style={styles.content}>
{/* Icon */}
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.iconSection}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="link" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Link with your partner to start matching
</Text>
</Animated.View>
{/* Join Card */}
<Animated.View
entering={FadeInDown.delay(300).duration(500)}
style={styles.section}
>
<Text style={styles.sectionTitle}>Have a code?</Text>
<GlassCard>
<TextInput
style={styles.input}
placeholder="Enter invite code"
placeholderTextColor={colors.textTertiary}
value={inviteCode}
onChangeText={setInviteCode}
autoCapitalize="characters"
maxLength={8}
/>
<GlassButton
onPress={handleJoinCouple}
disabled={isSubmitting}
loading={isSubmitting}
fullWidth
>
Join Partner
</GlassButton>
</GlassCard>
</Animated.View>
{/* Divider */}
<Animated.View
entering={FadeInDown.delay(400).duration(500)}
style={styles.divider}
>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<View style={styles.dividerLine} />
</Animated.View>
{/* Create Code Button */}
<Animated.View
entering={FadeInDown.delay(500).duration(500)}
style={styles.section}
>
<GlassButton
variant="secondary"
onPress={handleCreateCouple}
disabled={isSubmitting}
fullWidth
>
Create New Code
</GlassButton>
</Animated.View>
</View>
</KeyboardAvoidingView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
flexDirection: "row",
alignItems: "center",
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
input: {
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
// ...
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
title: {
...typography.title1,
color: colors.text,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
justifyContent: "center",
},
iconSection: {
alignItems: "center",
marginBottom: spacing.xl,
},
iconGradient: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
...shadows.lg,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
maxWidth: 280,
},
section: {
marginBottom: spacing.md,
},
sectionTitle: {
...typography.caption1,
color: colors.textTertiary,
textTransform: "uppercase",
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: spacing.lg,
},
dividerText: {
...typography.subhead,
color: colors.textTertiary,
paddingHorizontal: spacing.md,
},
codeContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
code: {
...typography.largeTitle,
color: colors.text,
letterSpacing: 6,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
tapToCopy: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
marginTop: spacing.sm,
},
waitingSection: {
alignItems: "center",
marginTop: spacing.xl,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
},
cancelSection: {
alignItems: "center",
marginTop: spacing.xl,
},
cancelButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
cancelButtonText: {
...typography.subhead,
color: colors.error,
},
ctaSection: {
alignItems: "center",
marginBottom: spacing.md,
},
ctaText: {
...typography.callout,
color: colors.textSecondary,
textAlign: "center",
},
quickShareRow: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.md,
marginBottom: spacing.md,
},
quickShareButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
},
quickShareLabel: {
...typography.subhead,
color: colors.text,
},
});