refactor(swipe): split swipe screen modules

Move swipe screen logic into hooks/services and extract
state-specific UI components to keep files small while
preserving existing behavior.

Reference: US-002
This commit is contained in:
jjbrunton committed 2026-01-25 21:05:54 +00:00
1 parent 209dc652d2
commit 8d4d9d8750
55 files changed
+11127 -1681

No files matched your search

+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 34
versionCode 35
versionName "1.0.2"
}
signingConfigs {
+1 -1
View File
@@ -177,7 +177,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 34,
"versionCode": 35,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
File diff suppressed because it is too large. Load diff
@@ -432,11 +432,11 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = Sauci;
PRODUCT_NAME = "Sauci";
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1";
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
@@ -463,10 +463,10 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = Sauci;
PRODUCT_NAME = "Sauci";
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1";
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
@@ -0,0 +1,168 @@
import { Platform, StyleSheet, View } from "react-native";
import { useRouter } from "expo-router";
import { GradientBackground } from "../../components/ui";
import { QuestionFeedbackModal } from "../../components/feedback";
import { MatchConfetti } from "../../components/MatchConfetti";
import { Paywall } from "../../components/paywall";
import { useSwipeScreen } from "./hooks/useSwipeScreen";
import { SwipeCardStack } from "./components/SwipeCardStack";
import { SwipeHeader } from "./components/SwipeHeader";
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
import { SwipeLoadingState } from "./components/SwipeLoadingState";
import { SwipeNoPartnerState } from "./components/SwipeNoPartnerState";
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
import { SwipeBlockedState } from "./components/SwipeBlockedState";
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
import { SwipeCaughtUpState } from "./components/SwipeCaughtUpState";
const SwipeScreen = () => {
const router = useRouter();
const {
packId,
mode,
filteredQuestions,
currentIndex,
feedbackQuestion,
isLoading,
isUploading,
isBlocked,
gapInfo,
dailyLimitInfo,
showPaywall,
packContext,
showConfetti,
countdown,
user,
partner,
couple,
enabledPackIds,
effectiveTotal,
fetchQuestions,
handleAnswer,
handleConfettiComplete,
getQuestionPackInfo,
checkAnswerGap,
setFeedbackQuestion,
setShowPaywall,
} = useSwipeScreen();
if (isLoading) {
return <SwipeLoadingState />;
}
if (!partner) {
return (
<SwipeNoPartnerState
hasCouple={!!couple}
onPairPress={() => router.push("/pairing")}
/>
);
}
if (dailyLimitInfo?.is_blocked) {
return (
<SwipeDailyLimitState
dailyLimitInfo={dailyLimitInfo}
countdown={countdown}
showPaywall={showPaywall}
onShowPaywall={() => setShowPaywall(true)}
onClosePaywall={() => setShowPaywall(false)}
onPaywallSuccess={() => {
setShowPaywall(false);
fetchQuestions();
}}
/>
);
}
if (isBlocked && gapInfo) {
return (
<SwipeBlockedState
unansweredCount={gapInfo.unanswered}
onCheckAgain={checkAnswerGap}
/>
);
}
if (!packId && enabledPackIds.length === 0) {
return (
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
);
}
if (currentIndex >= filteredQuestions.length) {
return (
<SwipeCaughtUpState
isPendingMode={mode === 'pending'}
isPackMode={!!packId}
packName={packContext?.name}
onViewMatches={() => router.push("/(app)/matches")}
onBackToHome={() => router.push("/")}
onRefresh={fetchQuestions}
/>
);
}
return (
<GradientBackground>
<View style={styles.container}>
<SwipeHeader
currentIndex={currentIndex}
effectiveTotal={effectiveTotal}
totalQuestions={filteredQuestions.length}
mode={mode}
packContext={packContext}
showBackButton={!!packId || mode === 'pending'}
onBack={() => router.back()}
/>
<SwipeCardStack
questions={filteredQuestions}
currentIndex={currentIndex}
getPackInfo={getQuestionPackInfo}
user={user}
partner={partner}
onAnswer={handleAnswer}
onReport={(questionId, questionText) => setFeedbackQuestion({ id: questionId, text: questionText })}
/>
<View style={styles.bottomSpacer} />
</View>
<QuestionFeedbackModal
visible={!!feedbackQuestion}
onClose={() => setFeedbackQuestion(null)}
questionId={feedbackQuestion?.id || ''}
questionText={feedbackQuestion?.text || ''}
/>
<MatchConfetti
visible={showConfetti}
onAnimationComplete={handleConfettiComplete}
/>
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
fetchQuestions();
}}
/>
{isUploading && <SwipeUploadOverlay />}
</GradientBackground>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},
});
export default SwipeScreen;
@@ -0,0 +1,33 @@
import { colors } from "../../../theme";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeBlockedStateProps {
unansweredCount: number;
onCheckAgain: () => void;
}
export const SwipeBlockedState = ({ unansweredCount, onCheckAgain }: SwipeBlockedStateProps) => {
const accent = colors.premium.rose;
return (
<SwipeInfoStateLayout
accentColor={accent}
icon="hourglass-outline"
label="PATIENCE"
title="Waiting"
badgeText={`${unansweredCount} UNANSWERED`}
description={`You're ahead by ${unansweredCount} questions. Give your partner some time to catch up so you can discover matches together.`}
features={[
{ icon: "heart-outline", text: "Matches happen together", color: accent },
{ icon: "notifications-outline", text: "We'll notify your partner", color: accent },
{ icon: "refresh-outline", text: "Check back anytime", color: accent },
]}
teaser="Good things come to those who wait"
action={{
label: "Check Again",
onPress: onCheckAgain,
variant: "secondary",
}}
/>
);
};
@@ -0,0 +1,79 @@
import { StyleSheet, View } from "react-native";
import { radius, shadows } from "../../../theme";
import type { PackInfo } from "../types";
import { SwipeQuestionCard } from "./SwipeQuestionCard";
import type { AnswerType } from "../../../types";
import type { ResponseData } from "../types";
interface SwipeCardStackProps {
questions: any[];
currentIndex: number;
getPackInfo: (question: any) => PackInfo | null;
user: { id: string; name?: string | null; avatar_url?: string | null } | null;
partner: { id: string; name?: string | null; avatar_url?: string | null } | null;
onAnswer: (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => void;
onReport: (questionId: string, questionText: string) => void;
}
export const SwipeCardStack = ({
questions,
currentIndex,
getPackInfo,
user,
partner,
onAnswer,
onReport,
}: SwipeCardStackProps) => {
const activeQuestion = questions[currentIndex];
return (
<View style={styles.cardContainer}>
{questions[currentIndex + 2] && (
<View style={[styles.stackCard, styles.stackCardThird]} />
)}
{questions[currentIndex + 1] && (
<View style={[styles.stackCard, styles.stackCardSecond]} />
)}
{activeQuestion && (
<SwipeQuestionCard
question={activeQuestion}
packInfo={getPackInfo(activeQuestion)}
user={user}
partner={partner}
onAnswer={onAnswer}
onReport={onReport}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
cardContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
stackCard: {
position: "absolute",
width: "85%",
height: "85%",
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
stackCardSecond: {
transform: [{ scale: 0.95 }, { translateY: 12 }],
opacity: 0.4,
...shadows.md,
},
stackCardThird: {
transform: [{ scale: 0.90 }, { translateY: 24 }],
opacity: 0.2,
...shadows.sm,
},
});
@@ -0,0 +1,68 @@
import { colors } from "../../../theme";
import { SwipeInfoStateLayout, type FeatureItem } from "./SwipeInfoStateLayout";
interface SwipeCaughtUpStateProps {
isPendingMode: boolean;
isPackMode: boolean;
packName?: string | null;
onViewMatches: () => void;
onBackToHome: () => void;
onRefresh: () => void;
}
export const SwipeCaughtUpState = ({
isPendingMode,
isPackMode,
packName,
onViewMatches,
onBackToHome,
onRefresh,
}: SwipeCaughtUpStateProps) => {
const accent = colors.premium.rose;
const label = isPendingMode ? 'QUEUE COMPLETE' : isPackMode ? 'PACK COMPLETE' : 'COMPLETE';
const title = isPendingMode ? 'All Caught Up!' : isPackMode ? packName || 'Pack Complete' : 'All Caught Up';
const badgeText = isPendingMode ? 'YOUR TURN COMPLETE' : isPackMode ? 'ALL QUESTIONS ANSWERED' : "YOU'RE AHEAD";
const description = isPendingMode
? "You've answered all the questions your partner swiped on. Nice work! Check back later for more."
: isPackMode
? "You've answered all questions in this pack. Head home to discover more!"
: "You've answered all available questions. New questions are added regularly, or explore different packs from home.";
const teaser = isPendingMode
? 'Your partner will love that you caught up'
: isPackMode
? 'Discover more ways to connect'
: 'More ways to connect are on the way';
const features: FeatureItem[] = isPendingMode
? [
{ icon: 'heart', text: 'Check your matches', color: accent },
{ icon: 'chatbubbles-outline', text: 'Chat about discoveries', color: accent },
{ icon: 'sparkles', text: 'Keep exploring together', color: accent },
]
: [
{ icon: 'sparkles', text: 'New questions weekly', color: accent },
{ icon: 'home-outline', text: 'Explore from home', color: accent },
{ icon: 'chatbubbles-outline', text: 'Chat about your matches', color: accent },
];
const action = isPendingMode
? { label: 'View Matches', onPress: onViewMatches }
: isPackMode
? { label: 'Back to Home', onPress: onBackToHome }
: { label: 'Refresh Questions', onPress: onRefresh, variant: 'secondary' as const };
return (
<SwipeInfoStateLayout
accentColor={accent}
icon="checkmark"
label={label}
title={title}
badgeText={badgeText}
description={description}
features={features}
teaser={teaser}
action={action}
/>
);
};
@@ -0,0 +1,229 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { DecorativeSeparator, GradientBackground } from "../../../components/ui";
import { Paywall } from "../../../components/paywall";
import { colors, radius, spacing, typography } from "../../../theme";
import type { DailyLimitInfo } from "../types";
interface SwipeDailyLimitStateProps {
dailyLimitInfo: DailyLimitInfo;
countdown: string;
showPaywall: boolean;
onShowPaywall: () => void;
onClosePaywall: () => void;
onPaywallSuccess: () => void;
}
export const SwipeDailyLimitState = ({
dailyLimitInfo,
countdown,
showPaywall,
onShowPaywall,
onClosePaywall,
onPaywallSuccess,
}: SwipeDailyLimitStateProps) => {
const accent = colors.premium.gold;
return (
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.waitingContent}
>
<View style={styles.dailyLimitIconWrapper}>
<View style={styles.dailyLimitIconGlow} />
<View style={styles.dailyLimitIconContainer}>
<Ionicons name="hourglass-outline" size={36} color={accent} />
</View>
</View>
<Text style={styles.dailyLimitLabel}>DAILY LIMIT REACHED</Text>
<Text style={styles.dailyLimitTitle}>Take a Breather</Text>
<DecorativeSeparator variant="gold" />
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.countdownContainer}
>
<Text style={styles.countdownLabel}>REFRESHES IN</Text>
<View style={styles.countdownBadge}
>
<Ionicons name="timer-outline" size={20} color={accent} />
<Text style={styles.countdownText}>{countdown}</Text>
</View>
</Animated.View>
<Text style={styles.dailyLimitDescription}>
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
Let the anticipation build while you wait.
</Text>
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="sparkles" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Fresh questions tomorrow</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="infinite-outline" size={16} color={colors.premium.gold} />
<Text style={styles.waitingFeatureText}>Go unlimited with Premium</Text>
</View>
</View>
<Text style={styles.premiumUpsellText}>Want to keep exploring?</Text>
<TouchableOpacity
onPress={onShowPaywall}
style={styles.premiumButton}
activeOpacity={0.85}
>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.premiumButtonGradient}
/>
<View style={styles.premiumButtonContent}>
<Ionicons name="infinite" size={18} color="#000" />
<Text style={styles.premiumButtonText}>Unlock Unlimited</Text>
</View>
</TouchableOpacity>
</Animated.View>
</View>
<Paywall
visible={showPaywall}
onClose={onClosePaywall}
onSuccess={onPaywallSuccess}
/>
</GradientBackground>
);
};
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
waitingContent: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
waitingFeatures: {
marginBottom: spacing.xl,
},
waitingFeatureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
waitingFeatureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
dailyLimitIconWrapper: {
position: 'relative',
marginBottom: spacing.xl,
},
dailyLimitIconGlow: {
position: 'absolute',
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(212, 175, 55, 0.15)',
top: -24,
left: -24,
},
dailyLimitIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1.5,
borderColor: 'rgba(212, 175, 55, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
dailyLimitLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.xs,
},
dailyLimitTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
countdownContainer: {
alignItems: 'center',
marginBottom: spacing.xl,
},
countdownLabel: {
...typography.caption2,
letterSpacing: 1.5,
color: colors.textTertiary,
marginBottom: spacing.sm,
},
countdownBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
countdownText: {
...typography.title2,
color: colors.premium.gold,
marginLeft: spacing.sm,
letterSpacing: 1,
},
dailyLimitDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
premiumUpsellText: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
premiumButton: {
marginTop: spacing.sm,
borderRadius: radius.full,
overflow: 'hidden',
},
premiumButtonGradient: {
...StyleSheet.absoluteFillObject,
},
premiumButtonContent: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
},
premiumButtonText: {
...typography.subhead,
color: '#000',
marginLeft: spacing.sm,
fontWeight: '600',
},
});
@@ -0,0 +1,139 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { colors, spacing, typography } from "../../../theme";
import { useProgressShimmer } from "../hooks/useProgressShimmer";
interface SwipeHeaderProps {
currentIndex: number;
effectiveTotal: number;
totalQuestions: number;
mode?: string;
packContext: { name: string } | null;
showBackButton: boolean;
onBack: () => void;
}
export const SwipeHeader = ({
currentIndex,
effectiveTotal,
totalQuestions,
mode,
packContext,
showBackButton,
onBack,
}: SwipeHeaderProps) => {
const shimmerStyle = useProgressShimmer();
const total = effectiveTotal || totalQuestions;
const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0;
return (
<Animated.View
entering={FadeIn.duration(400)}
style={styles.header}
>
<View style={styles.headerRow}>
{showBackButton && (
<TouchableOpacity onPress={onBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
)}
<View style={styles.progressContainer}>
<Text style={styles.progressLabel}>
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
</Text>
<Text style={styles.progressText}>
{currentIndex + 1} of {total}
</Text>
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
styles.progressFill,
styles.progressFillPremium,
{ width: `${progressPercent}%` },
]}
>
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
<LinearGradient
colors={['transparent', 'rgba(212, 175, 55, 0.5)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</Animated.View>
</View>
</View>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
backButton: {
position: 'absolute',
left: 0,
padding: spacing.sm,
},
progressContainer: {
alignItems: "center",
},
progressLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
marginBottom: spacing.xs,
},
progressText: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
progressBar: {
width: 160,
height: 6,
backgroundColor: colors.glass.background,
borderRadius: 3,
overflow: "hidden",
},
progressBarPremium: {
width: 140,
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
progressFill: {
height: "100%",
borderRadius: 4,
overflow: "hidden",
},
progressFillPremium: {
backgroundColor: 'rgba(212, 175, 55, 0.5)',
borderRadius: 2,
},
progressGradient: {
flex: 1,
borderRadius: 4,
},
progressShimmer: {
position: 'absolute',
width: 60,
height: '100%',
top: 0,
},
});
@@ -0,0 +1,173 @@
import { StyleSheet, Text, TextStyle, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { DecorativeSeparator, GlassButton, GradientBackground } from "../../../components/ui";
import { colors, radius, spacing, typography } from "../../../theme";
export interface FeatureItem {
icon: keyof typeof Ionicons.glyphMap;
text: string;
color?: string;
}
interface SwipeInfoStateLayoutProps {
accentColor: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
title: string;
badgeText?: string;
description: string;
features: FeatureItem[];
teaser: string;
separatorVariant?: "rose" | "gold";
labelStyle?: TextStyle;
action?: {
label: string;
onPress: () => void;
variant?: "primary" | "secondary";
};
}
export const SwipeInfoStateLayout = ({
accentColor,
icon,
label,
title,
badgeText,
description,
features,
teaser,
separatorVariant = "rose",
labelStyle,
action,
}: SwipeInfoStateLayoutProps) => (
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.waitingContent}
>
<View style={styles.waitingIconContainer}>
<Ionicons name={icon} size={36} color={accentColor} />
</View>
<Text style={[styles.waitingLabel, labelStyle]}>{label}</Text>
<Text style={styles.waitingTitle}>{title}</Text>
<DecorativeSeparator variant={separatorVariant} />
{badgeText && (
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.waitingBadge}
>
<Text style={styles.waitingBadgeText}>{badgeText}</Text>
</Animated.View>
)}
<Text style={styles.waitingDescription}>{description}</Text>
<View style={styles.waitingFeatures}>
{features.map((feature, index) => (
<View key={`${feature.text}-${index}`} style={styles.waitingFeatureItem}>
<Ionicons name={feature.icon} size={16} color={feature.color || accentColor} />
<Text style={styles.waitingFeatureText}>{feature.text}</Text>
</View>
))}
</View>
<Text style={styles.waitingTeaser}>{teaser}</Text>
{action && (
<GlassButton
variant={action.variant}
onPress={action.onPress}
style={{ marginTop: spacing.lg }}
>
{action.label}
</GlassButton>
)}
</Animated.View>
</View>
</GradientBackground>
);
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
waitingContent: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
waitingIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
waitingLabel: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.rose,
textAlign: 'center',
marginBottom: spacing.xs,
},
waitingTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
waitingBadge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
marginBottom: spacing.xl,
},
waitingBadgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: colors.premium.rose,
},
waitingDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
waitingFeatures: {
marginBottom: spacing.xl,
},
waitingFeatureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
waitingFeatureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
waitingTeaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
});
@@ -0,0 +1,20 @@
import { ActivityIndicator, StyleSheet, View } from "react-native";
import { GradientBackground } from "../../../components/ui";
import { colors } from "../../../theme";
export const SwipeLoadingState = () => (
<GradientBackground>
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</GradientBackground>
);
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
@@ -0,0 +1,31 @@
import { colors } from "../../../theme";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPacksStateProps {
onBrowsePacks: () => void;
}
export const SwipeNoPacksState = ({ onBrowsePacks }: SwipeNoPacksStateProps) => {
const accent = colors.premium.rose;
return (
<SwipeInfoStateLayout
accentColor={accent}
icon="layers-outline"
label="GET STARTED"
title="Choose Packs"
badgeText="NO PACKS ENABLED"
description="Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship."
features={[
{ icon: "flame-outline", text: "From playful to passionate", color: accent },
{ icon: "shield-checkmark-outline", text: "Safe space to explore", color: accent },
{ icon: "infinite-outline", text: "New packs added regularly", color: accent },
]}
teaser="Your journey of discovery awaits"
action={{
label: "Browse Packs",
onPress: onBrowsePacks,
}}
/>
);
};
@@ -0,0 +1,35 @@
import { colors } from "../../../theme";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPartnerStateProps {
hasCouple: boolean;
onPairPress: () => void;
}
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
const accent = colors.premium.rose;
return (
<SwipeInfoStateLayout
accentColor={accent}
icon="heart"
label={hasCouple ? "ALMOST THERE" : "CONNECT"}
title={hasCouple ? "Waiting" : "Pair Up"}
badgeText={hasCouple ? "INVITE SENT" : "MADE FOR TWO"}
description={hasCouple
? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!"
: "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common."
}
features={[
{ icon: "lock-closed-outline", text: "Private and secure", color: accent },
{ icon: "sparkles", text: "Discover hidden desires", color: accent },
{ icon: "chatbubble-ellipses-outline", text: "Chat about your matches", color: accent },
]}
teaser={hasCouple ? "Your partner is just a code away" : "Begin your intimate journey together"}
action={{
label: hasCouple ? "View Invite Code" : "Pair Now",
onPress: onPairPress,
}}
/>
);
};
@@ -0,0 +1,81 @@
import { QuestionCard, QuestionCardAudio, QuestionCardPhoto, QuestionCardText, QuestionCardWhoLikely } from "../../../components/questions";
import type { AnswerType } from "../../../types";
import type { PackInfo, ResponseData } from "../types";
interface SwipeQuestionCardProps {
question: any;
packInfo: PackInfo | null;
user: { id: string; name?: string | null; avatar_url?: string | null } | null;
partner: { id: string; name?: string | null; avatar_url?: string | null } | null;
onAnswer: (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => void;
onReport: (questionId: string, questionText: string) => void;
}
export const SwipeQuestionCard = ({
question,
packInfo,
user,
partner,
onAnswer,
onReport,
}: SwipeQuestionCardProps) => {
const questionType = question.question_type || 'swipe';
const handleReport = () => onReport(question.id, question.text);
switch (questionType) {
case 'text_answer':
return (
<QuestionCardText
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => onAnswer(question.id, answer, responseData)}
onReport={handleReport}
/>
);
case 'photo':
return (
<QuestionCardPhoto
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => onAnswer(question.id, answer, responseData)}
onReport={handleReport}
/>
);
case 'audio':
return (
<QuestionCardAudio
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => onAnswer(question.id, answer, responseData)}
onReport={handleReport}
/>
);
case 'who_likely':
return (
<QuestionCardWhoLikely
key={question.id}
question={question}
packInfo={packInfo}
user={{ id: user?.id || '', name: user?.name || undefined, avatar_url: user?.avatar_url }}
partner={{ id: partner?.id || '', name: partner?.name || undefined, avatar_url: partner?.avatar_url }}
onAnswer={(responseData) => onAnswer(question.id, 'yes', responseData)}
onSkip={() => onAnswer(question.id, 'skip')}
onReport={handleReport}
/>
);
case 'swipe':
default:
return (
<QuestionCard
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer) => onAnswer(question.id, answer)}
onReport={handleReport}
/>
);
}
};
@@ -0,0 +1,39 @@
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { colors, radius, spacing, typography } from "../../../theme";
export const SwipeUploadOverlay = () => (
<View style={styles.uploadOverlay}>
<View style={styles.uploadSpinnerContainer}>
<ActivityIndicator size="large" color={colors.premium.gold} />
<Text style={styles.uploadText}>Uploading...</Text>
</View>
</View>
);
const styles = StyleSheet.create({
uploadOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
uploadSpinnerContainer: {
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
borderRadius: radius.lg,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
},
uploadText: {
...typography.subhead,
color: colors.text,
marginTop: spacing.sm,
},
});
@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { Easing, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from "react-native-reanimated";
export const useProgressShimmer = () => {
const shimmerPosition = useSharedValue(-1);
useEffect(() => {
shimmerPosition.value = withRepeat(
withTiming(2, { duration: 2500, easing: Easing.linear }),
-1,
false
);
}, [shimmerPosition]);
return useAnimatedStyle(() => ({
transform: [
{ translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) },
],
}));
};
@@ -0,0 +1,469 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { invokeWithAuthRetry } from "../../../lib/authErrorHandler";
import { Events } from "../../../lib/analytics";
import { getCategoryColor } from "../../../theme";
import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
import {
fetchAnswerGapStatus,
fetchDailyLimitStatus,
fetchPackContext,
fetchPendingQuestions,
fetchRecommendedQuestions,
uploadResponseMedia,
} from "../services/swipeService";
interface GapInfo {
unanswered: number;
threshold: number;
}
export const useSwipeScreen = () => {
const params = useLocalSearchParams();
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode;
const startQuestionId = Array.isArray(params.startQuestionId) ? params.startQuestionId[0] : params.startQuestionId;
const [questions, setQuestions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [feedbackQuestion, setFeedbackQuestion] = useState<{ id: string; text: string } | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<GapInfo | null>(null);
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
const [countdown, setCountdown] = useState<string>("");
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
const supportedTypes = ['swipe', 'text_answer', 'photo', 'audio', 'who_likely'];
const filteredQuestions = questions.filter(q =>
!q.question_type || supportedTypes.includes(q.question_type)
);
const hasTrackedExhausted = useRef(false);
const fetchRequestId = useRef(0);
const lastFetchedMode = useRef<string | undefined>(undefined);
const isFirstMount = useRef(true);
const checkAnswerGap = useCallback(async (): Promise<boolean> => {
if (!couple || !partner) {
setIsBlocked(false);
setGapInfo(null);
return false;
}
try {
const result = await fetchAnswerGapStatus();
if (result) {
setIsBlocked(result.is_blocked);
if (result.threshold > 0) {
setGapInfo({
unanswered: result.unanswered_by_partner,
threshold: result.threshold,
});
} else {
setGapInfo(null);
}
return result.is_blocked;
}
setIsBlocked(false);
setGapInfo(null);
return false;
} catch (error) {
console.error('Failed to check answer gap:', error);
setIsBlocked(false);
setGapInfo(null);
return false;
}
}, [couple, partner]);
const checkDailyLimit = useCallback(async (): Promise<boolean> => {
try {
const result = await fetchDailyLimitStatus();
if (result) {
setDailyLimitInfo(result);
return result.is_blocked;
}
setDailyLimitInfo(null);
return false;
} catch (error) {
console.error('Failed to check daily limit:', error);
setDailyLimitInfo(null);
return false;
}
}, []);
const fetchQuestions = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
try {
const [data, skippedIds] = await Promise.all([
fetchRecommendedQuestions(packId || undefined),
getSkippedQuestionIds(),
]);
if (currentRequestId !== fetchRequestId.current) {
return;
}
let filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
if (filtered.length === 0 && data && data.length > 0) {
filtered = data;
}
if (partner && !packId) {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
} else {
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
}
if (currentRequestId === fetchRequestId.current) {
setQuestions(filtered);
}
} catch (error) {
if (currentRequestId === fetchRequestId.current) {
console.error(error);
}
} finally {
if (currentRequestId === fetchRequestId.current) {
setIsLoading(false);
}
}
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
const fetchPending = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) {
if (currentRequestId === fetchRequestId.current) {
setQuestions([]);
setIsLoading(false);
}
return;
}
try {
const orderedQuestions = await fetchPendingQuestions({
userId,
coupleId,
startQuestionId: startQuestionId || undefined,
});
if (currentRequestId === fetchRequestId.current) {
setQuestions(orderedQuestions);
setCurrentIndex(0);
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
}
} catch (error) {
if (currentRequestId === fetchRequestId.current) {
console.error("Failed to fetch pending questions:", error);
setQuestions([]);
}
} finally {
if (currentRequestId === fetchRequestId.current) {
setIsLoading(false);
}
}
}, [startQuestionId]);
useEffect(() => {
if (!dailyLimitInfo?.is_blocked || !dailyLimitInfo.reset_at) {
setCountdown("");
return;
}
const updateCountdown = () => {
const now = new Date();
const reset = new Date(dailyLimitInfo.reset_at);
const diff = reset.getTime() - now.getTime();
if (diff <= 0) {
setCountdown("00:00:00");
fetchQuestions();
return;
}
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, [dailyLimitInfo?.is_blocked, dailyLimitInfo?.reset_at, fetchQuestions]);
useEffect(() => {
setCurrentIndex(0);
setIsLoading(true);
setIsBlocked(false);
setGapInfo(null);
hasTrackedExhausted.current = false;
lastFetchedMode.current = mode;
ensureEnabledPacksLoaded().then(() => {
if (mode === 'pending') {
fetchPending();
} else {
fetchQuestions();
}
});
}, [packId, mode, startQuestionId, ensureEnabledPacksLoaded, fetchQuestions, fetchPending]);
useEffect(() => {
if (!packId) {
setPackContext(null);
return;
}
const loadPackContext = async () => {
try {
const context = await fetchPackContext(packId);
setPackContext(context);
} catch (error) {
console.error('Failed to fetch pack context:', error);
setPackContext(null);
}
};
loadPackContext();
}, [packId]);
useFocusEffect(
useCallback(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
if (lastFetchedMode.current !== mode) {
return;
}
if (mode === 'pending') {
fetchPending();
} else {
fetchQuestions();
}
}, [mode, fetchPending, fetchQuestions])
);
useEffect(() => {
if (filteredQuestions.length > 0 && currentIndex >= filteredQuestions.length && !hasTrackedExhausted.current) {
hasTrackedExhausted.current = true;
Events.allQuestionsExhausted();
}
}, [currentIndex, filteredQuestions.length]);
useEffect(() => {
if (!packId && mode !== 'pending' && enabledPackIds.length > 0 && questions.length > 0) {
const enabledSet = new Set(enabledPackIds);
setQuestions(prev => {
const filtered = prev.filter(q => enabledSet.has(q.pack_id));
if (currentIndex >= filtered.length && filtered.length > 0) {
setCurrentIndex(filtered.length - 1);
}
return filtered;
});
}
}, [enabledPackIds, mode, packId, questions.length, currentIndex]);
const effectiveTotal = (() => {
let total = filteredQuestions.length;
if (gapInfo && gapInfo.threshold > 0) {
const remaining = gapInfo.threshold - gapInfo.unanswered;
total = Math.min(total, currentIndex + Math.max(0, remaining));
}
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0 && !dailyLimitInfo.is_blocked) {
total = Math.min(total, currentIndex + dailyLimitInfo.remaining);
}
return total;
})();
const handleAnswer = useCallback(async (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => {
if (answer === 'skip') {
setCurrentIndex(prev => prev + 1);
await skipQuestion(questionId);
Events.questionSkipped();
return;
}
try {
let finalResponseData = responseData;
if (responseData && user?.id) {
if (responseData.type === 'photo' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(
responseData.media_path,
questionId,
'photo',
user.id
);
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Photo upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
} else if (responseData.type === 'audio' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(
responseData.media_path,
questionId,
'audio',
user.id
);
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Audio upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
}
}
const { data, error } = await invokeWithAuthRetry("submit-response", {
body: {
question_id: questionId,
answer,
response_data: finalResponseData,
},
});
if (error) {
console.error("Submit response error:", error);
setCurrentIndex(prev => prev + 1);
return;
}
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
const hasMatch = data?.match != null;
if (hasMatch) {
setShowConfetti(true);
} else {
setCurrentIndex(prev => prev + 1);
}
if (mode !== 'pending') {
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
setDailyLimitInfo(prev => prev ? {
...prev,
responses_today: prev.responses_today + 1,
remaining: Math.max(0, prev.remaining - 1),
is_blocked: prev.responses_today + 1 >= prev.limit_value,
} : null);
}
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} catch (error) {
console.error("Failed to submit response", error);
setCurrentIndex(prev => prev + 1);
}
}, [checkAnswerGap, checkDailyLimit, currentIndex, dailyLimitInfo, filteredQuestions, mode, user?.id]);
const handleConfettiComplete = useCallback(() => {
setShowConfetti(false);
setCurrentIndex(prev => prev + 1);
}, []);
const getQuestionPackInfo = useCallback((question: any): PackInfo | null => {
if (packId) {
const pack = packs.find(p => p.id === packId);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
if (packContext) {
return { ...packContext, color: categoryColor };
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
if (question.pack_id) {
const pack = packs.find(p => p.id === question.pack_id);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
if (question.pack_name) {
return {
name: question.pack_name,
icon: question.pack_icon || 'layers',
color: categoryColor,
};
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
return null;
}, [packContext, packId, packs]);
return {
packId,
mode,
startQuestionId,
questions,
filteredQuestions,
currentIndex,
feedbackQuestion,
isLoading,
isUploading,
isBlocked,
gapInfo,
dailyLimitInfo,
showPaywall,
packContext,
showConfetti,
countdown,
user,
partner,
couple,
enabledPackIds,
effectiveTotal,
fetchQuestions,
handleAnswer,
handleConfettiComplete,
getQuestionPackInfo,
checkAnswerGap,
setFeedbackQuestion,
setShowPaywall,
};
};
@@ -0,0 +1,169 @@
import { Platform } from "react-native";
import * as FileSystem from "expo-file-system";
import { decode } from "base64-arraybuffer";
import { supabase } from "../../../lib/supabase";
import type { DailyLimitInfo } from "../types";
export const fetchPackContext = async (packId: string) => {
const { data, error } = await supabase
.from('question_packs')
.select('name, icon')
.eq('id', packId)
.single();
if (error) {
throw error;
}
return data ? { name: data.name, icon: data.icon || 'layers' } : null;
};
export const fetchRecommendedQuestions = async (packId?: string) => {
const { data, error } = await supabase.rpc("get_recommended_questions", {
target_pack_id: packId || null,
});
if (error) {
throw error;
}
return data ?? [];
};
export const fetchPendingQuestions = async ({
userId,
coupleId,
startQuestionId,
}: {
userId: string;
coupleId: string;
startQuestionId?: string;
}) => {
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
return [];
}
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", userId)
.eq("couple_id", coupleId);
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
const { data: partnerResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", partnerId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: true });
if (error) {
throw error;
}
const pendingQuestions = (partnerResponses ?? [])
.filter(r => {
const question = r.question as any;
return !answeredQuestionIds.has(r.question_id) && question && !question.deleted_at;
})
.map(r => {
const question = r.question as any;
return {
...question,
pack_id: question.pack?.id,
pack_name: question.pack?.name,
pack_icon: question.pack?.icon,
};
});
if (startQuestionId && pendingQuestions.length > 0) {
const startIndex = pendingQuestions.findIndex(q => q.id === startQuestionId);
if (startIndex !== -1) {
const tappedQuestion = pendingQuestions[startIndex];
const rest = pendingQuestions.filter((_, i) => i !== startIndex);
return [tappedQuestion, ...rest];
}
}
return pendingQuestions;
};
export const fetchAnswerGapStatus = async () => {
const { data, error } = await supabase.rpc('get_answer_gap_status');
if (error) {
throw error;
}
return data?.[0] ?? null;
};
export const fetchDailyLimitStatus = async () => {
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
if (error) {
throw error;
}
return data?.[0] as DailyLimitInfo | undefined;
};
export const uploadResponseMedia = async (
localUri: string,
questionId: string,
mediaType: 'photo' | 'audio',
userId: string
): Promise<string | null> => {
try {
const timestamp = Date.now();
const extMatch = localUri.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1] : (mediaType === 'photo' ? 'jpg' : 'm4a');
const fileName = `${userId}/${questionId}_${timestamp}.${ext}`;
const contentType = mediaType === 'photo'
? `image/${ext === 'jpg' ? 'jpeg' : ext}`
: `audio/${ext}`;
let fileBody: ArrayBuffer | Blob;
if (Platform.OS === 'web') {
const response = await fetch(localUri);
fileBody = await response.blob();
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
}
const { error } = await supabase.storage
.from('response-media')
.upload(fileName, fileBody, { contentType });
if (error) {
console.error('Media upload error:', error);
return null;
}
return fileName;
} catch (error) {
console.error('Media upload failed:', error);
return null;
}
};
+36
View File
@@ -0,0 +1,36 @@
export interface TextResponseData {
type: 'text_answer';
text: string;
}
export interface PhotoResponseData {
type: 'photo';
media_path: string;
}
export interface AudioResponseData {
type: 'audio';
media_path: string;
duration_seconds: number;
}
export interface WhoLikelyResponseData {
type: 'who_likely';
chosen_user_id: string;
}
export type ResponseData = TextResponseData | PhotoResponseData | AudioResponseData | WhoLikelyResponseData;
export interface DailyLimitInfo {
responses_today: number;
limit_value: number;
remaining: number;
reset_at: string;
is_blocked: boolean;
}
export interface PackInfo {
name: string;
icon: string;
color?: string;
}