Initial commit

This commit is contained in:
jjbrunton committed 2025-12-27 19:10:11 +00:00
commit 4d53d83769
73 files changed
+37728

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+64
View File
@@ -0,0 +1,64 @@
{
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "sauci",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#1a1a2e"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"infoPlist": {
"UIBackgroundModes": [
"remote-notification"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"VIBRATE"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#e94560"
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Tabs, Redirect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet } from "react-native";
import { useAuthStore, useMatchStore } from "../../src/store";
export default function AppLayout() {
const { isAuthenticated, isLoading } = useAuthStore();
const { newMatchesCount } = useMatchStore();
// Show loading state while checking authentication
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: "#16213e",
borderTopColor: "#0f3460",
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: 8,
height: 60,
},
tabBarActiveTintColor: "#e94560",
tabBarInactiveTintColor: "#666",
tabBarLabelStyle: {
fontSize: 12,
fontWeight: "500",
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="packs"
options={{
title: "Packs",
tabBarIcon: ({ color, size }) => (
<Ionicons name="layers" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
title: "Play",
tabBarIcon: ({ color, size }) => (
<Ionicons name="flame" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="matches"
options={{
title: "Matches",
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
tabBarBadgeStyle: {
backgroundColor: "#e94560",
fontSize: 10,
},
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="pairing"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="chat/[id]"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
},
});
+571
View File
@@ -0,0 +1,571 @@
import { useState, useEffect, useRef } from "react";
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, AppState } from "react-native";
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../../src/lib/supabase";
import { useAuthStore } from "../../../src/store";
import * as ImagePicker from "expo-image-picker";
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { Database } from "../../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
export default function ChatScreen() {
const { id } = useLocalSearchParams();
const matchId = id as string;
const router = useRouter();
const { user } = useAuthStore();
const [messages, setMessages] = useState<Message[]>([]);
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
const [uploading, setUploading] = useState(false);
const [partnerTyping, setPartnerTyping] = useState(false);
const flatListRef = useRef<FlatList>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!matchId) return;
// Fetch match context (question and responses)
const fetchMatch = async () => {
const { data } = await supabase
.from("matches")
.select("*, question:questions(*)")
.eq("id", matchId)
.single();
if (data) {
// Fetch responses for both users in this couple for this question
const { data: responses } = await supabase
.from("responses")
.select("*, profiles(name)")
.eq("question_id", data.question_id)
.eq("couple_id", data.couple_id);
setMatch({ ...data, responses: responses || [] });
}
};
fetchMatch();
// Fetch initial messages and mark as read
const fetchMessagesAndMarkRead = async () => {
const { data, error } = await supabase
.from("messages")
.select("*")
.eq("match_id", matchId)
.order("created_at", { ascending: false });
if (data) {
setMessages(data);
// Identify unread messages from partner
const unreadIds = data
.filter(m => m.user_id !== user?.id && !m.read_at)
.map(m => m.id);
if (unreadIds.length > 0) {
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
.in("id", unreadIds);
// Optimistically update local state
setMessages(prev => prev.map(m =>
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
));
}
}
};
fetchMessagesAndMarkRead();
// Subscribe to new messages, updates (read receipts), and typing
const channel = supabase
.channel(`chat:${matchId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `match_id=eq.${matchId}`,
},
async (payload) => {
const newMessage = payload.new as Message;
// If receiving a message from partner, mark as read immediately since we are in the chat
if (newMessage.user_id !== user?.id) {
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
.eq("id", newMessage.id);
newMessage.read_at = new Date().toISOString();
}
setMessages((prev) => [newMessage, ...prev]);
setPartnerTyping(false); // Stop typing indicator if they sent a message
}
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "messages",
filter: `match_id=eq.${matchId}`,
},
(payload) => {
const updatedMessage = payload.new as Message;
setMessages((prev) =>
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
);
}
)
.on("broadcast", { event: "typing" }, (payload) => {
if (payload.payload.userId !== user?.id) {
setPartnerTyping(true);
// Auto-hide typing indicator after 3s
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
setPartnerTyping(false);
}, 3000);
}
})
.subscribe();
return () => {
supabase.removeChannel(channel);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
};
}, [matchId]);
const handleTyping = (text: string) => {
setInputText(text);
if (text.length > 0) {
supabase.channel(`chat:${matchId}`).send({
type: "broadcast",
event: "typing",
payload: { userId: user?.id },
});
}
};
const handleSend = async () => {
if (!inputText.trim()) return;
if (!user) return;
const content = inputText.trim();
setInputText("");
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
}
};
const handlePickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.7,
});
if (!result.canceled) {
uploadImage(result.assets[0].uri);
}
};
const uploadImage = async (uri: string) => {
if (!user) return;
setUploading(true);
try {
let fileBody;
let ext = "jpg";
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
const uriExt = uri.split('.').pop();
if (uriExt && uriExt !== uri) {
ext = uriExt.toLowerCase();
}
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = `image/${ext === "jpg" ? "jpeg" : ext}`;
const { error: uploadError } = await supabase.storage
.from("chat-media")
.upload(fileName, fileBody, {
contentType,
upsert: false
});
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from("chat-media")
.getPublicUrl(fileName);
await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
media_path: publicUrl,
});
} catch (error) {
Alert.alert("Error", "Failed to upload image");
console.error(error);
} finally {
setUploading(false);
}
};
const revealMessage = async (messageId: string) => {
// Optimistic update
setMessages(prev => prev.map(m =>
m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m
));
const { error } = await supabase
.from("messages")
.update({ media_viewed_at: new Date().toISOString() })
.eq("id", messageId);
if (error) {
console.error("Failed to mark as viewed", error);
}
};
const renderMessage = ({ item }: { item: Message }) => {
const isMe = item.user_id === user?.id;
return (
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
<View style={[styles.bubble, isMe ? styles.myBubble : styles.theirBubble]}>
{item.media_path ? (
<View>
<Image
source={{ uri: item.media_path }}
style={styles.messageImage}
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
/>
{(!isMe && !item.media_viewed_at) && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<Ionicons name="eye-off-outline" size={32} color="#fff" />
<Text style={styles.revealText}>Tap to reveal</Text>
</View>
</TouchableOpacity>
)}
</View>
) : (
<Text style={styles.messageText}>{item.content}</Text>
)}
<View style={styles.metaContainer}>
<Text style={[styles.timestamp, isMe ? styles.myTimestamp : styles.theirTimestamp]}>
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
{isMe && (
<Ionicons
name={item.read_at ? "checkmark-done-outline" : "checkmark-outline"}
size={14}
color={item.read_at ? "#4cd137" : "rgba(255,255,255,0.7)"}
style={styles.readIcon}
/>
)}
</View>
</View>
</View>
);
};
return (
<View style={styles.container}>
<Stack.Screen options={{
title: "Chat",
headerTitle: match?.question?.text ? () => (
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={2}>
{match.question.text}
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
</Text>
{match.responses && match.responses.length > 0 && (
<View style={styles.answersContainer}>
{match.responses.map((response) => (
<Text key={response.user_id} style={styles.answerText}>
{response.profiles?.name || 'You'}: {response.answer}
</Text>
))}
</View>
)}
</View>
) : "Chat",
headerStyle: { backgroundColor: "#16213e" },
headerTintColor: "#fff",
headerBackTitleVisible: false,
}} />
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
{/* Sticky top bar with question and answers */}
{match?.question && (
<View style={styles.stickyTopBar}>
<Text style={styles.stickyQuestionText}>
{match.question.text}
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
</Text>
{match.responses && match.responses.length > 0 && (
<View style={styles.stickyAnswersContainer}>
{match.responses.map((response) => (
<Text key={response.user_id} style={styles.stickyAnswerText}>
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
</Text>
))}
</View>
)}
</View>
)}
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
partnerTyping ? (
<View style={styles.typingContainer}>
<Text style={styles.typingText}>Partner is typing...</Text>
</View>
) : null
}
/>
<View style={styles.inputContainer}>
<TouchableOpacity onPress={handlePickImage} disabled={uploading} style={styles.attachButton}>
{uploading ? (
<ActivityIndicator color="#e94560" size="small" />
) : (
<Ionicons name="image-outline" size={24} color="#e94560" />
)}
</TouchableOpacity>
<TextInput
style={styles.input}
value={inputText}
onChangeText={handleTyping}
placeholder="Type a message..."
placeholderTextColor="#666"
multiline
/>
<TouchableOpacity onPress={handleSend} disabled={!inputText.trim()} style={styles.sendButton}>
<Ionicons
name="send"
size={24}
color={inputText.trim() ? "#e94560" : "#666"}
/>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
keyboardAvoiding: {
flex: 1,
},
listContent: {
padding: 16,
},
messageRow: {
marginBottom: 16,
flexDirection: "row",
},
myMessageRow: {
justifyContent: "flex-end",
},
theirMessageRow: {
justifyContent: "flex-start",
},
bubble: {
maxWidth: "75%",
padding: 12,
borderRadius: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
myBubble: {
backgroundColor: "#e94560",
borderBottomRightRadius: 4,
},
theirBubble: {
backgroundColor: "#16213e",
borderWidth: 1,
borderColor: "#0f3460",
borderBottomLeftRadius: 4,
},
messageText: {
color: "#fff",
fontSize: 16,
lineHeight: 22,
},
messageImage: {
width: 200,
height: 200,
borderRadius: 12,
backgroundColor: "rgba(0,0,0,0.2)",
},
metaContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
marginTop: 4,
},
timestamp: {
fontSize: 10,
},
myTimestamp: {
color: "rgba(255, 255, 255, 0.7)",
},
theirTimestamp: {
color: "rgba(255, 255, 255, 0.4)",
},
readIcon: {
marginLeft: 4,
},
inputContainer: {
flexDirection: "row",
alignItems: "center",
padding: 12,
paddingBottom: Platform.OS === "ios" ? 34 : 12,
backgroundColor: "#16213e",
borderTopWidth: 1,
borderTopColor: "#0f3460",
},
attachButton: {
padding: 10,
},
input: {
flex: 1,
backgroundColor: "#0f3460",
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
color: "#fff",
maxHeight: 120,
marginHorizontal: 8,
fontSize: 16,
},
sendButton: {
padding: 10,
},
headerTitleContainer: {
alignItems: "center",
maxWidth: 200,
},
headerTitle: {
color: "#fff",
fontWeight: "bold",
fontSize: 16,
textAlign: "center",
},
answersContainer: {
marginTop: 4,
alignItems: "center",
},
answerText: {
color: "rgba(255,255,255,0.8)",
fontSize: 12,
marginBottom: 2,
},
typingContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
typingText: {
color: "#666",
fontStyle: "italic",
fontSize: 12,
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.3)",
borderRadius: 12,
},
revealContent: {
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.6)",
padding: 12,
borderRadius: 12,
},
revealText: {
color: "#fff",
fontSize: 12,
fontWeight: "bold",
marginTop: 4,
},
stickyTopBar: {
backgroundColor: "#16213e",
borderBottomWidth: 1,
borderBottomColor: "#0f3460",
padding: 16,
paddingTop: 12,
paddingBottom: 12,
},
stickyQuestionText: {
color: "#fff",
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
marginBottom: 8,
},
stickyAnswersContainer: {
alignItems: "center",
},
stickyAnswerText: {
color: "rgba(255,255,255,0.8)",
fontSize: 14,
marginBottom: 4,
}
});
+206
View File
@@ -0,0 +1,206 @@
import { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native";
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
export default function HomeScreen() {
const { user, partner } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
const { packs, fetchPacks } = usePacksStore();
useEffect(() => {
fetchMatches();
fetchPacks();
}, []);
const recentMatches = matches.slice(0, 3);
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.greeting}>
Hey, {user?.name || "there"} 👋
</Text>
{partner && (
<Text style={styles.partnerText}>
Paired with {partner.name || partner.email || 'your partner'}
</Text>
)}
</View>
{/* Quick Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Ionicons name="heart" size={24} color="#e94560" />
<Text style={styles.statNumber}>{matches.length}</Text>
<Text style={styles.statLabel}>Matches</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="layers" size={24} color="#e94560" />
<Text style={styles.statNumber}>{packs.length}</Text>
<Text style={styles.statLabel}>Packs</Text>
</View>
<View style={styles.statCard}>
<Ionicons name="sparkles" size={24} color="#e94560" />
<Text style={styles.statNumber}>{newMatchesCount}</Text>
<Text style={styles.statLabel}>New</Text>
</View>
</View>
{/* Start Playing CTA */}
<TouchableOpacity
style={styles.ctaCard}
onPress={() => router.push("/(app)/swipe")}
>
<View style={styles.ctaContent}>
<Text style={styles.ctaTitle}>Ready to explore?</Text>
<Text style={styles.ctaSubtitle}>
Swipe through questions and discover what you both enjoy
</Text>
</View>
<Ionicons name="arrow-forward" size={24} color="#fff" />
</TouchableOpacity>
{/* Recent Matches */}
{recentMatches.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Recent Matches</Text>
<TouchableOpacity onPress={() => router.push("/(app)/matches")}>
<Text style={styles.seeAll}>See all</Text>
</TouchableOpacity>
</View>
{recentMatches.map((match) => (
<View key={match.id} style={styles.matchItem}>
<View style={styles.matchIcon}>
<Ionicons name="heart" size={20} color="#e94560" />
</View>
<Text style={styles.matchText} numberOfLines={1}>
{(match as any).question?.text || "A new match!"}
</Text>
{match.is_new && <View style={styles.newBadge} />}
</View>
))}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
},
greeting: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
partnerText: {
fontSize: 14,
color: "#888",
marginTop: 4,
},
statsContainer: {
flexDirection: "row",
paddingHorizontal: 24,
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
alignItems: "center",
borderWidth: 1,
borderColor: "#0f3460",
},
statNumber: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginTop: 8,
},
statLabel: {
fontSize: 12,
color: "#888",
marginTop: 4,
},
ctaCard: {
margin: 24,
backgroundColor: "#e94560",
borderRadius: 16,
padding: 20,
flexDirection: "row",
alignItems: "center",
},
ctaContent: {
flex: 1,
},
ctaTitle: {
fontSize: 18,
fontWeight: "bold",
color: "#fff",
},
ctaSubtitle: {
fontSize: 14,
color: "rgba(255,255,255,0.8)",
marginTop: 4,
},
section: {
paddingHorizontal: 24,
marginBottom: 24,
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
},
seeAll: {
color: "#e94560",
fontSize: 14,
},
matchItem: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
marginBottom: 8,
borderWidth: 1,
borderColor: "#0f3460",
},
matchIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(233, 69, 96, 0.2)",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
matchText: {
flex: 1,
color: "#fff",
fontSize: 14,
},
newBadge: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#e94560",
},
});
+192
View File
@@ -0,0 +1,192 @@
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from "react-native";
import { useMatchStore, useAuthStore } from "../../src/store";
import { useEffect } from "react";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
const { user } = useAuthStore();
useEffect(() => {
fetchMatches();
}, []);
// Mark all matches as seen when matches are loaded
useEffect(() => {
if (matches.length > 0) {
markAllAsSeen();
}
}, [matches.length]);
const router = useRouter();
const renderItem = ({ item }: { item: any }) => {
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
let userText = item.question.text;
let partnerText = item.question.partner_text;
if (item.question.partner_text && userResponse && partnerResponse) {
const userTime = new Date(userResponse.created_at).getTime();
const partnerTime = new Date(partnerResponse.created_at).getTime();
// If user answered after partner, they saw the partner_text
if (userTime > partnerTime) {
userText = item.question.partner_text;
partnerText = item.question.text;
}
}
return (
<TouchableOpacity
style={styles.matchCard}
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.7}
>
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={24} color="#e94560" />
</View>
<View style={styles.content}>
<Text style={styles.questionText}>{userText}</Text>
{item.question.partner_text && (
<Text style={styles.partnerText}>Partner: {partnerText}</Text>
)}
<View style={styles.tagContainer}>
<View style={styles.tag}>
<Text style={styles.tagText}>
{item.match_type === "yes_yes" ? "Review: YES + YES" : "Review: YES + MAYBE"}
</Text>
</View>
<Text style={styles.date}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
if (!user) {
return (
<View style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator color="#e94560" />
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Your Matches</Text>
</View>
<FlatList
data={matches}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={false} onRefresh={fetchMatches} tintColor="#e94560" />
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No matches yet. Keep swiping!</Text>
</View>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
list: {
padding: 24,
},
matchCard: {
flexDirection: "row",
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
justifyContent: "center",
alignItems: "center",
marginRight: 16,
},
content: {
flex: 1,
},
questionText: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 8,
lineHeight: 22,
},
tagContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
tag: {
backgroundColor: "rgba(233, 69, 96, 0.2)",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
tagText: {
color: "#e94560",
fontSize: 12,
fontWeight: "bold",
},
date: {
color: "#666",
fontSize: 12,
},
emptyContainer: {
padding: 40,
alignItems: "center",
},
emptyText: {
color: "#666",
fontSize: 16,
},
partnerText: {
fontSize: 14,
color: "#aaa",
fontStyle: "italic",
marginBottom: 8,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
+143
View File
@@ -0,0 +1,143 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Switch } from "react-native";
import { usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect } from "react";
export default function PacksScreen() {
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
useEffect(() => {
fetchPacks();
}, []);
const renderItem = ({ item }: { item: any }) => (
<View style={styles.card}>
<TouchableOpacity
style={styles.cardContent}
activeOpacity={0.7}
onPress={() => router.push(`/pack/${item.id}`)}
>
<View style={styles.iconContainer}>
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
</View>
<View style={styles.content}>
<View style={styles.headerRow}>
<Text style={styles.name}>{item.name}</Text>
{item.is_premium && (
<View style={styles.badge}>
<Text style={styles.badgeText}>PRO</Text>
</View>
)}
</View>
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
</View>
</TouchableOpacity>
<Switch
value={enabledPackIds.includes(item.id)}
onValueChange={() => togglePack(item.id)}
trackColor={{ false: "#767577", true: "#e94560" }}
thumbColor={"#fff"}
/>
</View>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Question Packs</Text>
</View>
<FlatList
data={packs}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshing={isLoading}
onRefresh={fetchPacks}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
list: {
padding: 24,
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
},
cardContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "rgba(255, 255, 255, 0.05)",
justifyContent: "center",
alignItems: "center",
marginRight: 16,
},
emoji: {
fontSize: 24,
},
content: {
flex: 1,
marginRight: 16,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
},
name: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
marginRight: 8,
},
badge: {
backgroundColor: "#e94560",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "bold",
},
description: {
fontSize: 14,
color: "#888",
lineHeight: 20,
},
});
+337
View File
@@ -0,0 +1,337 @@
import { useState, useEffect } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
Share,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { useAuthStore } from "../../src/store";
import { supabase } from "../../src/lib/supabase";
import { router } from "expo-router";
export default function PairingScreen() {
const { user, 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]);
const handleCreateCouple = async () => {
setIsSubmitting(true);
try {
const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "POST",
body: {},
});
if (error) throw error;
if (data.error) throw new Error(data.error);
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
} catch (error: any) {
Alert.alert("Error", error.message);
} finally {
setIsSubmitting(false);
}
};
const handleJoinCouple = async () => {
if (inviteCode.length < 8) {
Alert.alert("Error", "Please enter a valid invite code");
return;
}
setIsSubmitting(true);
try {
// Refresh session to ensure we have a valid token
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session) {
throw new Error("Please log in again");
}
const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "POST",
body: { invite_code: inviteCode },
});
if (error) throw error;
if (data.error) throw new Error(data.error);
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
Alert.alert("Success", "You are now paired! 💕", [
{ text: "Let's Go", onPress: () => router.replace("/(app)/") }
]);
} catch (error: any) {
Alert.alert("Error", error.message);
} finally {
setIsSubmitting(false);
}
};
const copyToClipboard = async () => {
if (couple?.invite_code) {
await Clipboard.setStringAsync(couple.invite_code);
Alert.alert("Copied", "Invite code copied to clipboard");
}
};
const shareCode = async () => {
if (couple?.invite_code) {
try {
await Share.share({
message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`,
});
} catch (error) {
console.error(error);
}
}
};
if (isAuthLoading) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
// If user has a couple but no partner, they are waiting
if (couple && !partner) {
return (
<View style={styles.container}>
<View style={styles.content}>
<View style={styles.iconContainer}>
<Ionicons name="heart" size={48} color="#e94560" />
</View>
<Text style={styles.title}>Partner Code</Text>
<Text style={styles.subtitle}>
Share this code with your partner to link your accounts
</Text>
<TouchableOpacity style={styles.codeContainer} onPress={copyToClipboard}>
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
<Ionicons name="copy-outline" size={24} color="#666" />
</TouchableOpacity>
<TouchableOpacity style={styles.shareButton} onPress={shareCode}>
<Ionicons name="share-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Share Code</Text>
</TouchableOpacity>
<Text style={styles.waitingText}>
Waiting for your partner to join...
</Text>
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
</View>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={styles.content}>
<Text style={styles.emoji}>🔗</Text>
<Text style={styles.title}>Pair Up</Text>
<Text style={styles.subtitle}>
Link with your partner to start matching
</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Have a code?</Text>
<TextInput
style={styles.input}
placeholder="Enter invite code"
placeholderTextColor="#666"
value={inviteCode}
onChangeText={setInviteCode}
autoCapitalize="characters"
maxLength={8}
/>
<TouchableOpacity
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleJoinCouple}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? "Joining..." : "Join Partner"}
</Text>
</TouchableOpacity>
</View>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleCreateCouple}
disabled={isSubmitting}
>
<Text style={styles.secondaryButtonText}>
{isSubmitting ? "Creating..." : "Create New Code"}
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
centerContent: {
justifyContent: "center",
alignItems: "center",
},
content: {
flex: 1,
padding: 24,
justifyContent: "center",
alignItems: "center",
},
emoji: {
fontSize: 64,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
marginBottom: 48,
textAlign: "center",
},
card: {
width: "100%",
backgroundColor: "#16213e",
borderRadius: 16,
padding: 24,
borderWidth: 1,
borderColor: "#0f3460",
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 16,
fontSize: 18,
color: "#fff",
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
textAlign: "center",
letterSpacing: 2,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
button: {
backgroundColor: "#e94560",
borderRadius: 12,
padding: 16,
alignItems: "center",
},
buttonDisabled: {
opacity: 0.7,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: 32,
width: "100%",
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#333",
},
dividerText: {
color: "#666",
paddingHorizontal: 16,
},
secondaryButton: {
padding: 16,
},
secondaryButtonText: {
color: "#e94560",
fontSize: 16,
fontWeight: "600",
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(233, 69, 96, 0.1)",
justifyContent: "center",
alignItems: "center",
marginBottom: 24,
},
codeContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#16213e",
padding: 20,
borderRadius: 16,
marginTop: 32,
marginBottom: 24,
borderWidth: 1,
borderColor: "#e94560",
borderStyle: "dashed",
},
code: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
letterSpacing: 4,
marginRight: 16,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
shareButton: {
flexDirection: "row",
backgroundColor: "#e94560",
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
alignItems: "center",
},
waitingText: {
marginTop: 48,
color: "#666",
fontSize: 14,
},
});
+168
View File
@@ -0,0 +1,168 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, Image } from "react-native";
import { useAuthStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../src/lib/supabase";
export default function ProfileScreen() {
const { user, partner, signOut, fetchCouple } = useAuthStore();
const handleUnpair = async () => {
Alert.alert(
"Unpair Partner",
"Are you sure you want to unpair? This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Unpair",
style: "destructive",
onPress: async () => {
try {
await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
await fetchCouple(); // Refresh state
} catch (error) {
Alert.alert("Error", "Failed to unpair");
}
},
},
]
);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Profile</Text>
</View>
<View style={styles.content}>
<View style={styles.profileCard}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
<Text style={styles.name}>{user?.name || "User"}</Text>
<Text style={styles.email}>{user?.email}</Text>
</View>
<Text style={styles.sectionTitle}>Relationship</Text>
<View style={styles.section}>
{partner ? (
<View style={styles.row}>
<View style={styles.partnerInfo}>
<Ionicons name="heart" size={24} color="#e94560" />
<Text style={styles.rowText}>Paired with {partner.name || partner.email || 'your partner'}</Text>
</View>
<TouchableOpacity onPress={handleUnpair}>
<Ionicons name="trash-outline" size={20} color="#666" />
</TouchableOpacity>
</View>
) : (
<View style={styles.row}>
<Text style={styles.rowText}>Not paired yet</Text>
</View>
)}
</View>
<Text style={styles.sectionTitle}>Settings</Text>
<View style={styles.section}>
<TouchableOpacity style={styles.row} onPress={signOut}>
<Text style={[styles.rowText, { color: "#ff4444" }]}>Sign Out</Text>
<Ionicons name="log-out-outline" size={20} color="#ff4444" />
</TouchableOpacity>
</View>
<Text style={styles.version}>Sauci v1.0.0</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
content: {
padding: 24,
},
profileCard: {
alignItems: "center",
marginBottom: 40,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "#e94560",
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
avatarText: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
},
name: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginBottom: 4,
},
email: {
fontSize: 14,
color: "#666",
},
sectionTitle: {
fontSize: 14,
fontWeight: "600",
color: "#666",
marginBottom: 8,
marginLeft: 4,
textTransform: "uppercase",
},
section: {
backgroundColor: "#16213e",
borderRadius: 12,
marginBottom: 24,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#1a1a2e",
},
partnerInfo: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
rowText: {
fontSize: 16,
color: "#fff",
},
version: {
textAlign: "center",
color: "#666",
fontSize: 12,
marginTop: 24,
},
});
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../src/lib/supabase";
import SwipeCard from "../../src/components/SwipeCard";
export default function SwipeScreen() {
const { packId } = useLocalSearchParams();
const [questions, setQuestions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
fetchQuestions();
}, [packId]);
const fetchQuestions = async () => {
try {
const { data, error } = await supabase.rpc("get_recommended_questions", {
target_pack_id: packId || null
});
if (error) {
console.error("Error fetching recommended questions:", error);
throw error;
}
// Smart Shuffle
// Priority:
// 1. Completion of Two-Part Match (Partner answered, is_two_part = true) -> +1.5
// 2. Partner Answered (Any) -> +0.7
// 3. New Two-Part (Get someone to start) -> +0.4
const sorted = (data || []).sort((a: any, b: any) => {
let scoreA = Math.random();
let scoreB = Math.random();
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
else if (a.partner_answered) scoreA += 0.7;
else if (a.is_two_part) scoreA += 0.4;
if (b.partner_answered && b.is_two_part) scoreB += 1.5;
else if (b.partner_answered) scoreB += 0.7;
else if (b.is_two_part) scoreB += 0.4;
return scoreB - scoreA;
});
setQuestions(sorted);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
const handleSwipe = async (direction: "left" | "right" | "up") => {
const question = questions[currentIndex];
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
// Optimistic update
setCurrentIndex(prev => prev + 1);
try {
// DEBUG: Check if we have a valid session
const { data: { session } } = await supabase.auth.getSession();
console.log("DEBUG - Session exists:", !!session);
console.log("DEBUG - Access token exists:", !!session?.access_token);
console.log("DEBUG - Token preview:", session?.access_token?.substring(0, 50) + "...");
const result = await supabase.functions.invoke("submit-response", {
body: {
question_id: question.id,
answer,
},
});
console.log("DEBUG - Function result:", result);
} catch (error) {
console.error("Failed to submit response", error);
}
};
if (isLoading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
if (currentIndex >= questions.length) {
return (
<View style={styles.container}>
<Ionicons name="checkmark-circle-outline" size={80} color="#e94560" />
<Text style={styles.emptyTitle}>All caught up!</Text>
<Text style={styles.emptySubtitle}>
Check back later for more questions or try a different pack.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.progress}>
Question {currentIndex + 1} of {questions.length}
</Text>
</View>
<View style={styles.cardContainer}>
{/* Render next card in background for smoothness */}
{questions[currentIndex + 1] && (
<View style={[styles.backgroundCard]} />
)}
{/* Active card */}
<SwipeCard
key={questions[currentIndex].id}
question={questions[currentIndex]}
onSwipe={handleSwipe}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
justifyContent: "center",
alignItems: "center",
},
header: {
position: "absolute",
top: 60,
zIndex: 1,
},
progress: {
color: "#666",
fontSize: 16,
fontWeight: "600",
},
cardContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
marginTop: 40,
},
backgroundCard: {
position: "absolute",
width: "100%", // Match SwipeCard width logic
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
transform: [{ scale: 0.95 }, { translateY: 10 }],
opacity: 0.5,
},
emptyTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginTop: 24,
marginBottom: 8,
},
emptySubtitle: {
fontSize: 16,
color: "#888",
textAlign: "center",
paddingHorizontal: 40,
},
});
+13
View File
@@ -0,0 +1,13 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "#1a1a2e" },
animation: "fade",
}}
/>
);
}
+420
View File
@@ -0,0 +1,420 @@
import { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from "react-native";
import { Redirect } from "expo-router";
import * as Linking from "expo-linking";
import { supabase } from "../../src/lib/supabase";
import { useAuthStore } from "../../src/store";
type AuthMode = "magic-link" | "password";
export default function LoginScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
const [isSignUp, setIsSignUp] = useState(false);
const { isAuthenticated } = useAuthStore();
// Use declarative Redirect instead of imperative router.replace()
// This ensures navigation only happens after the Root Layout is mounted
if (isAuthenticated) {
return <Redirect href="/" />;
}
const handleMagicLink = async () => {
if (!email.trim()) {
Alert.alert("Error", "Please enter your email");
return;
}
setIsLoading(true);
const { error } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
},
});
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
} else {
setIsMagicLinkSent(true);
}
};
const handlePasswordAuth = async () => {
if (!email.trim()) {
Alert.alert("Error", "Please enter your email");
return;
}
if (!password) {
Alert.alert("Error", "Please enter your password");
return;
}
if (isSignUp && password.length < 6) {
Alert.alert("Error", "Password must be at least 6 characters");
return;
}
setIsLoading(true);
if (isSignUp) {
const { error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
} else {
Alert.alert(
"Check your email",
"We sent you a confirmation link to verify your email address.",
[{ text: "OK" }]
);
}
} else {
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
}
}
};
const handleGoogleSignIn = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: Linking.createURL("/(auth)/login"),
},
});
if (error) {
Alert.alert("Error", error.message);
}
};
const handleAppleSignIn = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: "apple",
options: {
redirectTo: Linking.createURL("/(auth)/login"),
},
});
if (error) {
Alert.alert("Error", error.message);
}
};
if (isMagicLinkSent) {
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.emoji}>✉️</Text>
<Text style={styles.title}>Check your email</Text>
<Text style={styles.subtitle}>
We sent a magic link to {email}
</Text>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => setIsMagicLinkSent(false)}
>
<Text style={styles.secondaryButtonText}>Try a different email</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<Text style={styles.emoji}>💕</Text>
<Text style={styles.title}>Sauci</Text>
<Text style={styles.subtitle}>
Explore intimacy together
</Text>
{/* Auth Mode Toggle */}
<View style={styles.modeToggleContainer}>
<TouchableOpacity
style={[
styles.modeToggle,
authMode === "magic-link" && styles.modeToggleActive,
]}
onPress={() => setAuthMode("magic-link")}
>
<Text
style={[
styles.modeToggleText,
authMode === "magic-link" && styles.modeToggleTextActive,
]}
>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.modeToggle,
authMode === "password" && styles.modeToggleActive,
]}
onPress={() => setAuthMode("password")}
>
<Text
style={[
styles.modeToggleText,
authMode === "password" && styles.modeToggleTextActive,
]}
>
Password
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Enter your email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
{authMode === "password" && (
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
/>
)}
{authMode === "magic-link" ? (
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleMagicLink}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? "Sending..." : "Continue with Magic Link"}
</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handlePasswordAuth}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading
? isSignUp
? "Creating Account..."
: "Signing In..."
: isSignUp
? "Create Account"
: "Sign In"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.toggleSignUp}
onPress={() => setIsSignUp(!isSignUp)}
>
<Text style={styles.toggleSignUpText}>
{isSignUp
? "Already have an account? Sign In"
: "Don't have an account? Sign Up"}
</Text>
</TouchableOpacity>
</>
)}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.socialButton}
onPress={handleGoogleSignIn}
>
<Text style={styles.socialButtonText}>Continue with Google</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.socialButton}
onPress={handleAppleSignIn}
>
<Text style={styles.socialButtonText}>Continue with Apple</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
content: {
flex: 1,
paddingHorizontal: 24,
paddingTop: 80,
alignItems: "center",
},
emoji: {
fontSize: 64,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
marginBottom: 48,
textAlign: "center",
},
form: {
width: "100%",
},
input: {
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
},
button: {
backgroundColor: "#e94560",
borderRadius: 12,
padding: 16,
alignItems: "center",
},
buttonDisabled: {
opacity: 0.7,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: 24,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#333",
},
dividerText: {
color: "#666",
paddingHorizontal: 16,
},
socialButton: {
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginBottom: 12,
borderWidth: 1,
borderColor: "#0f3460",
},
socialButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "500",
},
secondaryButton: {
marginTop: 24,
},
secondaryButtonText: {
color: "#e94560",
fontSize: 16,
},
scrollContent: {
flexGrow: 1,
},
modeToggleContainer: {
flexDirection: "row",
backgroundColor: "#16213e",
borderRadius: 12,
padding: 4,
marginBottom: 24,
width: "100%",
},
modeToggle: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: "center",
},
modeToggleActive: {
backgroundColor: "#e94560",
},
modeToggleText: {
color: "#666",
fontSize: 14,
fontWeight: "500",
},
modeToggleTextActive: {
color: "#fff",
},
toggleSignUp: {
marginTop: 16,
alignItems: "center",
},
toggleSignUpText: {
color: "#e94560",
fontSize: 14,
},
});
+90
View File
@@ -0,0 +1,90 @@
// Web polyfills must be imported FIRST before any other React Native imports
import "../src/polyfills/web";
import React, { useEffect } from "react";
import { Platform, View } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering
if (Platform.OS === "web" && typeof window === "undefined") {
React.useLayoutEffect = useEffect;
// Also silence the warning in case some libraries ignore the monkey-patch
const originalError = console.error;
console.error = (...args) => {
if (typeof args[0] === "string" && args[0].includes("useLayoutEffect does nothing on the server")) {
return;
}
originalError(...args);
};
}
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
// GestureHandlerRootView is only needed on native platforms
// On web, we use a simple View to avoid findNodeHandle errors
const GestureHandlerRootView = Platform.OS === "web"
? View
: require("react-native-gesture-handler").GestureHandlerRootView;
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase";
const queryClient = new QueryClient();
export default function RootLayout() {
const { fetchUser, setUser } = useAuthStore();
useEffect(() => {
// Fetch user on mount
fetchUser();
// Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => {
if (url) {
// Supabase needs to handle the URL to extract session
// getSession() will automatically check window.location on web if detectSessionInUrl is true
// For native, we rely on onAuthStateChange or manual processing if needed
await supabase.auth.getSession();
}
};
// Get initial URL
Linking.getInitialURL().then(handleDeepLink);
// Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => {
handleDeepLink(event.url);
});
// Listen for auth changes
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
fetchUser();
} else if (event === "SIGNED_OUT") {
setUser(null);
}
}
);
return () => {
linkingSubscription.remove();
authSubscription.unsubscribe();
};
}, []);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "#1a1a2e" },
}}
/>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native";
export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore();
// Show loading state
if (isLoading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
// Redirect based on auth state
if (!isAuthenticated) {
return <Redirect href="/(auth)/login" />;
}
// If user has no couple, go to pairing
if (!user?.couple_id) {
return <Redirect href="/(app)/pairing" />;
}
// Otherwise go to home
return <Redirect href="/(app)/" />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
},
});
+191
View File
@@ -0,0 +1,191 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { supabase } from "../../src/lib/supabase";
import { usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { Question } from "../../src/types";
export default function PackDetailsScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const { packs, fetchPacks } = usePacksStore();
const [questions, setQuestions] = useState<Question[]>([]);
const [isLoading, setIsLoading] = useState(true);
const pack = packs.find((p) => p.id === id);
useEffect(() => {
if (packs.length === 0) {
fetchPacks();
}
fetchPackDetails();
}, [id]);
const fetchPackDetails = async () => {
try {
const { data, error } = await supabase
.from("questions")
.select("*")
.eq("pack_id", id)
.order("created_at");
if (error) throw error;
setQuestions(data || []);
} catch (error) {
console.error("Error fetching questions:", error);
} finally {
setIsLoading(false);
}
};
const renderItem = ({ item }: { item: Question }) => (
<View style={styles.card}>
<Text style={styles.questionText}>{item.text}</Text>
{item.partner_text && (
<Text style={styles.partnerText}>Partner role: {item.partner_text}</Text>
)}
<View style={styles.intensityContainer}>
{[...Array(5)].map((_, index) => (
<Ionicons
key={index}
name="flame"
size={12}
color={index < item.intensity ? "#e94560" : "#333"}
style={{ marginRight: 2 }}
/>
))}
</View>
</View>
);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.title}>{pack?.name || "Pack Details"}</Text>
<View style={{ width: 24 }} />
</View>
{pack && (
<View style={styles.packInfo}>
<View style={styles.iconContainer}>
<Text style={styles.emoji}>{pack.icon || "📦"}</Text>
</View>
<Text style={styles.description}>{pack.description}</Text>
</View>
)}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Questions ({questions.length})</Text>
</View>
<FlatList
data={questions}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
backButton: {
padding: 4,
},
title: {
fontSize: 20,
fontWeight: "bold",
color: "#fff",
},
packInfo: {
padding: 24,
alignItems: "center",
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "rgba(255, 255, 255, 0.05)",
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
emoji: {
fontSize: 32,
},
description: {
fontSize: 16,
color: "#ccc",
textAlign: "center",
lineHeight: 24,
},
sectionHeader: {
padding: 24,
paddingBottom: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: "bold",
color: "#e94560",
},
list: {
padding: 24,
},
card: {
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: "#0f3460",
},
questionText: {
fontSize: 16,
color: "#fff",
marginBottom: 8,
lineHeight: 22,
},
intensityContainer: {
flexDirection: "row",
},
partnerText: {
fontSize: 14,
color: "#aaa",
fontStyle: "italic",
marginBottom: 8,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+28
View File
@@ -0,0 +1,28 @@
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
// Find the project and workspace directories
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
// 1. Watch only relevant folders to improve performance on Windows
// watching the entire workspaceRoot is too slow
config.watchFolders = [
path.resolve(workspaceRoot, 'node_modules'),
path.resolve(workspaceRoot, 'packages/shared')
];
// 2. Let Metro know where to resolve packages and in which order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
// 3. Force Metro to resolve (sub)dependencies from the `node_modules`
// of the workspace root, unless they are installed in the project root.
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
+12353
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@sauci/mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0",
"base64-arraybuffer": "^1.0.2",
"expo": "~52.0.0",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.0",
"expo-file-system": "~18.0.12",
"expo-image-picker": "~16.0.6",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.5",
"react-native-gesture-handler": "~2.20.0",
"react-native-reanimated": "~3.16.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-web": "^0.21.2",
"zustand": "^4.4.7"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.3.12",
"typescript": "^5.3.3"
}
}
+312
View File
@@ -0,0 +1,312 @@
import { View, Text, StyleSheet, Image, Dimensions, TouchableOpacity } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
interpolate,
Extrapolate,
withTiming,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null };
onSwipe: (direction: "left" | "right" | "up") => void;
}
export default function SwipeCard({ question, onSwipe }: Props) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const context = useSharedValue({ x: 0, y: 0 });
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.05);
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
[-15, 15],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1);
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// Horizontal swipe (YES/NO)
const direction = translateX.value > 0 ? "right" : "left";
translateX.value = withSpring(direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
// Vertical swipe up (MAYBE)
translateY.value = withSpring(-SCREEN_WIDTH * 1.5);
runOnJS(onSwipe)("up");
} else {
// Reset
translateX.value = withSpring(0);
translateY.value = withSpring(0);
rotation.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
}));
const overlayStyle = (direction: "left" | "right" | "up", color: string) =>
useAnimatedStyle(() => {
let opacity = 0;
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
}
return {
opacity,
backgroundColor: color,
};
});
return (
<View style={styles.cardWrapper}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, animatedStyle]}>
{/* Overlays */}
<Animated.View style={[styles.overlay, overlayStyle("right", "rgba(76, 175, 80, 0.4)")]}>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left", "rgba(244, 67, 54, 0.4)")]}>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up", "rgba(255, 152, 0, 0.4)")]}>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
</View>
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
</View>
</Animated.View>
</GestureDetector>
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.actionButton, styles.noButton]}
onPress={() => onSwipe("left")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.maybeButton]}
onPress={() => onSwipe("up")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.yesButton]}
onPress={() => onSwipe("right")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
width: SCREEN_WIDTH - 48,
height: 500,
},
card: {
width: "100%",
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
overflow: "hidden",
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
lineHeight: 28,
},
footer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
overlayText: {
fontSize: 48,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerContainer: {
width: "100%",
alignItems: "center",
},
divider: {
height: 1,
width: "80%",
backgroundColor: "rgba(255,255,255,0.1)",
marginVertical: 24,
position: "relative",
justifyContent: "center",
alignItems: "center",
},
dividerText: {
color: "#666",
backgroundColor: "#16213e",
paddingHorizontal: 10,
fontSize: 12,
fontWeight: "bold",
position: "absolute",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
zIndex: 21,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
overflow: "hidden",
zIndex: 22,
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
});
+17
View File
@@ -0,0 +1,17 @@
/**
* SwipeCard component with platform-specific implementations.
*
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
*
* - On web: Uses React Native's built-in PanResponder which is compatible with
* react-native-web (SwipeCard.web.tsx)
*
* Metro bundler automatically picks the right file based on the platform:
* - .native.tsx for iOS/Android
* - .web.tsx for web
*
* This file is a fallback that re-exports the web version for any other platform
* that might not have a specific implementation.
*/
export { default } from "./SwipeCard.web";
@@ -0,0 +1,379 @@
import { useState, useRef } from "react";
import { View, Text, StyleSheet, Dimensions, Animated, PanResponder, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null };
onSwipe: (direction: "left" | "right" | "up") => void;
}
/**
* Web-compatible SwipeCard using React Native's built-in PanResponder
* instead of react-native-gesture-handler which doesn't work on web.
*/
export default function SwipeCard({ question, onSwipe }: Props) {
const [dismissed, setDismissed] = useState(false);
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const rotation = translateX.interpolate({
inputRange: [-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
outputRange: ["-15deg", "15deg"],
extrapolate: "clamp",
});
const rightOverlayOpacity = translateX.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const leftOverlayOpacity = translateX.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const upOverlayOpacity = translateY.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area (bottom 100px)
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y;
},
onMoveShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
},
onPanResponderGrant: () => {
Animated.timing(scale, {
toValue: 1.05,
duration: 100,
useNativeDriver: true,
}).start();
},
onPanResponderMove: (_, gestureState) => {
translateX.setValue(gestureState.dx);
translateY.setValue(gestureState.dy);
},
onPanResponderRelease: (_, gestureState) => {
Animated.timing(scale, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start();
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
// Horizontal swipe
const direction = gestureState.dx > 0 ? "right" : "left";
const targetX = direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5;
Animated.spring(translateX, {
toValue: targetX,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe(direction);
});
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
// Vertical swipe up
Animated.spring(translateY, {
toValue: -SCREEN_WIDTH * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("up");
});
} else {
// Reset position
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
}),
]).start();
}
},
})
).current;
if (dismissed) {
return null;
}
return (
<Animated.View
style={[
styles.card,
{
transform: [
{ translateX },
{ translateY },
{ rotate: rotation },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Overlays */}
<Animated.View
style={[
styles.overlay,
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
]}
>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
]}
>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
]}
>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
</View>
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
</View>
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.actionButton,
styles.noButton,
hoveredButton === "no" && styles.buttonHovered,
]}
onPress={() => onSwipe("left")}
onMouseEnter={() => setHoveredButton("no")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.maybeButton,
hoveredButton === "maybe" && styles.buttonHovered,
]}
onPress={() => onSwipe("up")}
onMouseEnter={() => setHoveredButton("maybe")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.yesButton,
hoveredButton === "yes" && styles.buttonHovered,
]}
onPress={() => onSwipe("right")}
onMouseEnter={() => setHoveredButton("yes")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
position: "absolute",
width: SCREEN_WIDTH - 48,
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
overflow: "hidden",
cursor: "grab",
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
lineHeight: 28,
},
footer: {
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
overlayText: {
fontSize: 48,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerContainer: {
width: "100%",
alignItems: "center",
},
divider: {
height: 1,
width: "80%",
backgroundColor: "rgba(255,255,255,0.1)",
marginVertical: 24,
position: "relative",
justifyContent: "center",
alignItems: "center",
},
dividerText: {
color: "#666",
backgroundColor: "#16213e",
paddingHorizontal: 10,
fontSize: 12,
fontWeight: "bold",
position: "absolute",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
overflow: "hidden",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
buttonHovered: {
transform: [{ scale: 1.1 }],
shadowOpacity: 0.6,
shadowRadius: 12,
},
});
+62
View File
@@ -0,0 +1,62 @@
import "react-native-url-polyfill/auto";
import { createClient } from "@supabase/supabase-js";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
// In-memory storage fallback for SSR/Node.js environments
const memoryStorage: Record<string, string> = {};
// SecureStore adapter for Supabase auth
const ExpoSecureStoreAdapter = {
getItem: (key: string) => {
if (Platform.OS === "web") {
// Check if we're in a browser environment with localStorage
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key);
}
// Fallback for SSR/Node.js
return memoryStorage[key] ?? null;
}
return SecureStore.getItemAsync(key);
},
setItem: (key: string, value: string) => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, value);
return;
}
// Fallback for SSR/Node.js
memoryStorage[key] = value;
return;
}
return SecureStore.setItemAsync(key, value);
},
removeItem: (key: string) => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.removeItem(key);
return;
}
// Fallback for SSR/Node.js
delete memoryStorage[key];
return;
}
return SecureStore.deleteItemAsync(key);
},
};
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
// DEBUG: Log which key is being used
console.log("DEBUG - Anon key preview:", supabaseAnonKey?.substring(0, 30) + "...");
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: ExpoSecureStoreAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === "web",
},
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Web platform polyfills
* This file must be imported BEFORE any other React Native imports
* to ensure the polyfills are applied before libraries like react-native-gesture-handler load.
*
* IMPORTANT: Do NOT use ES6 imports from react-native in this file!
* Using imports would cause react-native to be loaded before the polyfill runs.
*/
// We need to detect web platform without importing react-native
const isWeb = typeof document !== "undefined";
if (isWeb) {
// Polyfill findNodeHandle for web platform
// react-native-gesture-handler uses this internally but react-native-web throws an error
// We patch it to return null instead, which is safer than throwing
try {
// Get react-native module and override findNodeHandle
const RN = require("react-native");
// Create a no-op function that returns null
const webFindNodeHandle = () => null;
// Override the module's export
Object.defineProperty(RN, "findNodeHandle", {
value: webFindNodeHandle,
writable: true,
configurable: true,
});
} catch (e) {
console.warn("Failed to polyfill findNodeHandle:", e);
}
}
+245
View File
@@ -0,0 +1,245 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Profile, Couple, Match, QuestionPack } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
fetchUser: async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", session.user.id)
.maybeSingle();
set({
user: profile,
isAuthenticated: true,
});
// If user has a couple, fetch couple data
if (profile?.couple_id) {
await get().fetchCouple();
}
set({ isLoading: false });
} catch (error) {
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) return;
const { data: couple } = await supabase
.from("couples")
.select("*")
.eq("id", user.couple_id)
.maybeSingle();
// Fetch partner
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
set({ couple, partner });
},
signOut: async () => {
await supabase.auth.signOut();
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false
});
},
setUser: (user) => set({ user }),
}));
// Match store for managing matches
interface MatchState {
matches: Match[];
newMatchesCount: number;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
fetchMatches: async () => {
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.order("created_at", { ascending: false });
if (!matches) return;
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const { data: responses } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
const data = matches.map(match => ({
...match,
responses: responses?.filter(r => r.question_id === match.question_id) || []
}));
const newCount = data.filter((m) => m.is_new).length;
set({ matches: data, newMatchesCount: newCount });
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
);
const newCount = matches.filter((m) => m.is_new).length;
set({ matches, newMatchesCount: newCount });
},
markAllAsSeen: async () => {
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
);
set({ matches, newMatchesCount: 0 });
},
addMatch: (match) => {
set((state) => ({
matches: [match, ...state.matches],
newMatchesCount: state.newMatchesCount + 1,
}));
},
}));
// Packs store
interface PacksState {
packs: QuestionPack[];
enabledPackIds: string[];
isLoading: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
togglePack: (packId: string) => Promise<void>;
}
export const usePacksStore = create<PacksState>((set, get) => ({
packs: [],
enabledPackIds: [],
isLoading: false,
fetchPacks: async () => {
set({ isLoading: true });
const { data: packs } = await supabase
.from("question_packs")
.select("*")
.eq("is_public", true)
.order("sort_order");
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
set({ packs: packs ?? [], isLoading: false });
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ enabledPackIds: [] });
return;
}
const { data: couplePacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", coupleId)
.eq("enabled", true);
const ids = couplePacks?.map(cp => cp.pack_id) || [];
set({ enabledPackIds: ids });
},
togglePack: async (packId: string) => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
// Optimistic update
const newIds = newValue
? [...get().enabledPackIds, packId]
: get().enabledPackIds.filter(id => id !== packId);
set({ enabledPackIds: newIds });
const { error } = await supabase
.from("couple_packs")
.upsert({
couple_id: coupleId,
pack_id: packId,
enabled: newValue
});
if (error) {
console.error("Error toggling pack:", error);
// Revert by refetching
get().fetchEnabledPacks();
}
}
}));
+80
View File
@@ -0,0 +1,80 @@
// User profile (extends Supabase auth.users)
export interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
created_at: string;
updated_at: string;
}
// Couple pairing
export interface Couple {
id: string;
invite_code: string;
created_at: string;
}
// Question pack
export interface QuestionPack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
sort_order: number;
created_at: string;
}
// Individual question
export interface Question {
id: string;
pack_id: string;
text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5;
created_at: string;
}
// User's answer type
export type AnswerType = 'yes' | 'no' | 'maybe';
// Match type when both partners agree
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
// User response to a question
export interface Response {
id: string;
user_id: string;
question_id: string;
couple_id: string;
answer: AnswerType;
created_at: string;
}
// Match between partners
export interface Match {
id: string;
couple_id: string;
question_id: string;
match_type: MatchType;
is_new: boolean;
created_at: string;
}
// Extended match with question details
export interface MatchWithQuestion extends Match {
question: Question;
}
// Pack with progress info
export interface PackProgress {
pack: QuestionPack;
total_questions: number;
answered_questions: number;
matches_count: number;
}
+445
View File
@@ -0,0 +1,445 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.1"
}
public: {
Tables: {
couple_packs: {
Row: {
couple_id: string
created_at: string | null
enabled: boolean | null
pack_id: string
}
Insert: {
couple_id: string
created_at?: string | null
enabled?: boolean | null
pack_id: string
}
Update: {
couple_id?: string
created_at?: string | null
enabled?: boolean | null
pack_id?: string
}
Relationships: [
{
foreignKeyName: "couple_packs_couple_id_fkey"
columns: ["couple_id"]
isOneToOne: false
referencedRelation: "couples"
referencedColumns: ["id"]
},
{
foreignKeyName: "couple_packs_pack_id_fkey"
columns: ["pack_id"]
isOneToOne: false
referencedRelation: "question_packs"
referencedColumns: ["id"]
},
]
}
couples: {
Row: {
created_at: string | null
id: string
invite_code: string | null
}
Insert: {
created_at?: string | null
id?: string
invite_code?: string | null
}
Update: {
created_at?: string | null
id?: string
invite_code?: string | null
}
Relationships: []
}
matches: {
Row: {
couple_id: string
created_at: string | null
id: string
is_new: boolean | null
match_type: Database["public"]["Enums"]["match_type"]
question_id: string
}
Insert: {
couple_id: string
created_at?: string | null
id?: string
is_new?: boolean | null
match_type: Database["public"]["Enums"]["match_type"]
question_id: string
}
Update: {
couple_id?: string
created_at?: string | null
id?: string
is_new?: boolean | null
match_type?: Database["public"]["Enums"]["match_type"]
question_id?: string
}
Relationships: [
{
foreignKeyName: "matches_couple_id_fkey"
columns: ["couple_id"]
isOneToOne: false
referencedRelation: "couples"
referencedColumns: ["id"]
},
{
foreignKeyName: "matches_question_id_fkey"
columns: ["question_id"]
isOneToOne: false
referencedRelation: "questions"
referencedColumns: ["id"]
},
]
}
messages: {
Row: {
content: string | null
created_at: string | null
id: string
match_id: string
media_path: string | null
media_viewed_at: string | null
read_at: string | null
user_id: string
}
Insert: {
content?: string | null
created_at?: string | null
id?: string
match_id: string
media_path?: string | null
media_viewed_at?: string | null
read_at?: string | null
user_id: string
}
Update: {
content?: string | null
created_at?: string | null
id?: string
match_id?: string
media_path?: string | null
media_viewed_at?: string | null
read_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "messages_match_id_fkey"
columns: ["match_id"]
isOneToOne: false
referencedRelation: "matches"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
avatar_url: string | null
couple_id: string | null
created_at: string | null
email: string | null
id: string
is_premium: boolean | null
name: string | null
push_token: string | null
updated_at: string | null
}
Insert: {
avatar_url?: string | null
couple_id?: string | null
created_at?: string | null
email?: string | null
id: string
is_premium?: boolean | null
name?: string | null
push_token?: string | null
updated_at?: string | null
}
Update: {
avatar_url?: string | null
couple_id?: string | null
created_at?: string | null
email?: string | null
id?: string
is_premium?: boolean | null
name?: string | null
push_token?: string | null
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "profiles_couple_id_fkey"
columns: ["couple_id"]
isOneToOne: false
referencedRelation: "couples"
referencedColumns: ["id"]
},
]
}
question_packs: {
Row: {
created_at: string | null
description: string | null
display_order: number
id: string
image_url: string | null
is_explicit: boolean | null
is_pro: boolean | null
is_public: boolean | null
title: string
}
Insert: {
created_at?: string | null
description?: string | null
display_order?: number
id?: string
image_url?: string | null
is_explicit?: boolean | null
is_pro?: boolean | null
is_public?: boolean | null
title: string
}
Update: {
created_at?: string | null
description?: string | null
display_order?: number
id?: string
image_url?: string | null
is_explicit?: boolean | null
is_pro?: boolean | null
is_public?: boolean | null
title?: string
}
Relationships: []
}
questions: {
Row: {
created_at: string | null
id: string
intensity: number | null
pack_id: string
text: string
partner_text: string | null
}
Insert: {
created_at?: string | null
id?: string
intensity?: number | null
pack_id: string
text: string
partner_text?: string | null
}
Update: {
created_at?: string | null
id?: string
intensity?: number | null
pack_id?: string
text?: string
partner_text?: string | null
}
Relationships: [
{
foreignKeyName: "questions_pack_id_fkey"
columns: ["pack_id"]
isOneToOne: false
referencedRelation: "question_packs"
referencedColumns: ["id"]
},
]
}
responses: {
Row: {
answer: Database["public"]["Enums"]["answer_type"]
couple_id: string
created_at: string | null
id: string
question_id: string
user_id: string
}
Insert: {
answer: Database["public"]["Enums"]["answer_type"]
couple_id: string
created_at?: string | null
id?: string
question_id: string
user_id: string
}
Update: {
answer?: Database["public"]["Enums"]["answer_type"]
couple_id?: string
created_at?: string | null
id?: string
question_id?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "responses_couple_id_fkey"
columns: ["couple_id"]
isOneToOne: false
referencedRelation: "couples"
referencedColumns: ["id"]
},
{
foreignKeyName: "responses_question_id_fkey"
columns: ["question_id"]
isOneToOne: false
referencedRelation: "questions"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
get_recommended_questions: {
Args: {
target_pack_id?: string
}
Returns: {
id: string
text: string
partner_text: string | null
is_two_part: boolean
pack_id: string
intensity: number
partner_answered: boolean
}[]
}
}
Enums: {
answer_type: "yes" | "no" | "maybe"
match_type: "yes_yes" | "yes_maybe" | "maybe_maybe"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {
answer_type: ["yes", "no", "maybe"],
match_type: ["yes_yes", "yes_maybe", "maybe_maybe"],
},
},
} as const
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020"
],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"noEmit": true,
"jsx": "react-native",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"node_modules"
],
"extends": "expo/tsconfig.base"
}