mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
209dc652d2
commit
8d4d9d8750
55 files changed
+11127
-1681
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 34,
|
||||
"versionCode": 35,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
|
||||
+1
-1675
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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user