mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add question types, nudge feature, and share modal
- Add new question card components (photo, audio, text, who-likely) - Add share preview modal for matches - Add nudge notification system with rate limiting - Add session-based notification improvements - Add match confetti celebration animation - Update onboarding flow with paywall tracking - Remove deprecated swipe card and tutorial components - Update GlassCard/GlassInput to use solid backgrounds - Fix routing issues after packs screen removal
This commit is contained in:
1 parent
8612b16b93
commit
1daf386765
114 files changed
+14877
-7911
No files matched your search
@@ -1,19 +1,18 @@
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
|
||||
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||
import { syncBadgeCount } from "../../src/lib/badge";
|
||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import type { MatchWithQuestion, AnswerType } from "../../src/types";
|
||||
import { needsOnboarding } from "../../src/constants/onboarding";
|
||||
import type { MatchWithQuestion } from "../../src/types";
|
||||
import type { Database } from "../../src/types/supabase";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
@@ -54,157 +53,21 @@ function TabBarBackground() {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PlayTabButtonProps {
|
||||
children?: React.ReactNode;
|
||||
onPress?: (e: any) => void;
|
||||
accessibilityState?: { selected?: boolean };
|
||||
isMenuOpen?: boolean;
|
||||
onToggleMenu?: () => void;
|
||||
}
|
||||
|
||||
interface RadialMenuItem {
|
||||
id: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
angle: number; // degrees from top (0 = top, -45 = left, 45 = right)
|
||||
variant: 'primary' | 'gold' | 'rose' | 'muted';
|
||||
}
|
||||
|
||||
// Brand color palette for navigation
|
||||
const NAV_COLORS = {
|
||||
// Primary brand colors (from logo)
|
||||
primary: colors.primary,
|
||||
primaryRgba: 'rgba(233, 69, 96, ',
|
||||
secondary: colors.secondary,
|
||||
secondaryRgba: 'rgba(155, 89, 182, ',
|
||||
// Feature-specific colors
|
||||
gold: colors.premium.gold,
|
||||
goldRgba: 'rgba(212, 175, 55, ',
|
||||
rose: colors.premium.rose,
|
||||
roseRgba: 'rgba(232, 164, 174, ',
|
||||
dark: '#0d0d1a',
|
||||
};
|
||||
|
||||
const RADIAL_MENU_ITEMS: RadialMenuItem[] = [
|
||||
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' },
|
||||
{ id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' },
|
||||
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' },
|
||||
];
|
||||
|
||||
const RADIAL_DISTANCE = 100; // Distance from center button
|
||||
|
||||
// Store for sharing state between PlayTabButton and AppLayout
|
||||
let globalMenuToggle: (() => void) | null = null;
|
||||
let globalMenuOpen = false;
|
||||
|
||||
function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
|
||||
const isSelected = accessibilityState?.selected;
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
// Subscribe to global state changes
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
forceUpdate({});
|
||||
}, 50);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.playButtonContainer}>
|
||||
{/* Outer glow effect - primary brand */}
|
||||
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
|
||||
|
||||
{/* Main circle button - premium dark with primary gradient border */}
|
||||
<Pressable onPress={() => globalMenuToggle?.()}>
|
||||
<Animated.View style={styles.playButtonCircle}>
|
||||
<View style={styles.playButtonInner}>
|
||||
{/* Silk highlight at top - primary gradient */}
|
||||
<LinearGradient
|
||||
colors={[`${NAV_COLORS.primaryRgba}0.2)`, `${NAV_COLORS.secondaryRgba}0.1)`, 'transparent']}
|
||||
style={styles.playButtonHighlight}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Ionicons
|
||||
name={globalMenuOpen ? "close" : "flame"}
|
||||
size={28}
|
||||
color={globalMenuOpen ? colors.textSecondary : NAV_COLORS.primary}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount, pendingQuestions, fetchPendingQuestions } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const { fetchStreak } = useStreakStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const hasCheckedInitialBiometric = useRef(false);
|
||||
const wentToBackgroundAt = useRef<number | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [isRadialMenuOpen, setIsRadialMenuOpen] = useState(false);
|
||||
const radialMenuAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const toggleRadialMenu = useCallback(() => {
|
||||
const toValue = isRadialMenuOpen ? 0 : 1;
|
||||
setIsRadialMenuOpen(!isRadialMenuOpen);
|
||||
globalMenuOpen = !isRadialMenuOpen;
|
||||
|
||||
Animated.spring(radialMenuAnim, {
|
||||
toValue,
|
||||
useNativeDriver: true,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
}).start();
|
||||
}, [isRadialMenuOpen, radialMenuAnim]);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setIsRadialMenuOpen(false);
|
||||
globalMenuOpen = false;
|
||||
Animated.timing(radialMenuAnim, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [radialMenuAnim]);
|
||||
|
||||
const handleRadialMenuItemPress = useCallback((itemId: string) => {
|
||||
closeRadialMenu();
|
||||
|
||||
// Navigate based on item
|
||||
switch (itemId) {
|
||||
case 'match':
|
||||
router.push('/(app)/swipe');
|
||||
break;
|
||||
case 'dares':
|
||||
router.push('/(app)/dares');
|
||||
break;
|
||||
case 'quiz':
|
||||
router.push('/(app)/quiz');
|
||||
break;
|
||||
}
|
||||
}, [router, closeRadialMenu]);
|
||||
|
||||
// Register global menu toggle for PlayTabButton
|
||||
useEffect(() => {
|
||||
globalMenuToggle = toggleRadialMenu;
|
||||
return () => {
|
||||
globalMenuToggle = null;
|
||||
};
|
||||
}, [toggleRadialMenu]);
|
||||
|
||||
// Check if we're on screens that should hide the tab bar
|
||||
const segmentStrings = segments as string[];
|
||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||
@@ -213,11 +76,11 @@ export default function AppLayout() {
|
||||
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
|
||||
|
||||
// Redirect to login when not authenticated, or to onboarding if not completed
|
||||
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace("/(auth)/login");
|
||||
} else if (!isLoading && isAuthenticated && user && !user.onboarding_completed && !isOnOnboarding) {
|
||||
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
|
||||
router.replace("/(app)/onboarding");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
|
||||
@@ -316,10 +179,17 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [user?.couple_id, fetchStreak]);
|
||||
|
||||
// Sync app icon badge count whenever matches or messages change
|
||||
// Fetch pending questions (Your Turn) when user is in a couple
|
||||
useEffect(() => {
|
||||
syncBadgeCount(newMatchesCount, unreadCount);
|
||||
}, [newMatchesCount, unreadCount]);
|
||||
if (user?.couple_id) {
|
||||
fetchPendingQuestions();
|
||||
}
|
||||
}, [user?.couple_id, fetchPendingQuestions]);
|
||||
|
||||
// Sync app icon badge count whenever matches, messages, or pending questions change
|
||||
useEffect(() => {
|
||||
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
|
||||
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
|
||||
|
||||
// Initialize RevenueCat for subscription management (iOS and Android only)
|
||||
useEffect(() => {
|
||||
@@ -367,7 +237,7 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [user?.id, user?.couple_id]);
|
||||
|
||||
// Subscribe to realtime match notifications
|
||||
// Subscribe to realtime match notifications (for badge updates and analytics)
|
||||
useEffect(() => {
|
||||
if (!user?.couple_id) return;
|
||||
|
||||
@@ -401,42 +271,12 @@ export default function AppLayout() {
|
||||
Events.milestoneMatch(currentMatchCount + 1);
|
||||
}
|
||||
|
||||
// Add to store
|
||||
// Add to store (updates badge count via newMatchesCount)
|
||||
addMatch(matchWithQuestion);
|
||||
Events.matchCreated(matchWithQuestion.match_type || "unknown");
|
||||
|
||||
// Fetch individual responses to show what each person said
|
||||
const { data: responses } = await supabase
|
||||
.from("responses")
|
||||
.select("user_id, answer")
|
||||
.eq("question_id", matchWithQuestion.question_id)
|
||||
.eq("couple_id", user.couple_id);
|
||||
|
||||
let userResponse: AnswerType | undefined;
|
||||
let partnerResponse: AnswerType | undefined;
|
||||
|
||||
if (responses && responses.length >= 2) {
|
||||
for (const r of responses) {
|
||||
if (r.user_id === user.id) {
|
||||
userResponse = r.answer as AnswerType;
|
||||
} else {
|
||||
partnerResponse = r.answer as AnswerType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get partner name from auth store
|
||||
const { partner } = useAuthStore.getState();
|
||||
|
||||
// Show notification with response data
|
||||
setMatchNotification({
|
||||
id: matchWithQuestion.id,
|
||||
match_type: matchWithQuestion.match_type,
|
||||
question: matchWithQuestion.question,
|
||||
userResponse,
|
||||
partnerResponse,
|
||||
partnerName: partner?.name,
|
||||
});
|
||||
// Note: No modal popup - user will see badge on matches tab
|
||||
// Confetti animation is shown inline on swipe screen when user creates match
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -561,18 +401,6 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [lastMessage]);
|
||||
|
||||
const dismissNotification = useCallback(() => {
|
||||
setMatchNotification(null);
|
||||
}, []);
|
||||
|
||||
const handleMatchChat = useCallback(() => {
|
||||
if (matchNotification) {
|
||||
const matchId = matchNotification.id;
|
||||
setMatchNotification(null);
|
||||
router.push(`/(app)/chat/${matchId}`);
|
||||
}
|
||||
}, [matchNotification, router]);
|
||||
|
||||
const dismissMessageToast = useCallback(() => {
|
||||
Animated.timing(messageToastAnim, {
|
||||
toValue: -100,
|
||||
@@ -591,7 +419,7 @@ export default function AppLayout() {
|
||||
}, [lastMessage, dismissMessageToast, router]);
|
||||
|
||||
// Show loading state while checking authentication or redirecting to onboarding
|
||||
if (isLoading || !isAuthenticated || (user && !user.onboarding_completed && !isOnOnboarding)) {
|
||||
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
@@ -633,20 +461,10 @@ export default function AppLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="packs"
|
||||
options={{
|
||||
title: "Packs",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="layers" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="swipe"
|
||||
options={{
|
||||
title: "",
|
||||
tabBarButton: (props) => <PlayTabButton {...props} />,
|
||||
href: null, // Hide from tab bar - accessed from home screen
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
@@ -656,7 +474,7 @@ export default function AppLayout() {
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="heart" size={size} color={color} />
|
||||
),
|
||||
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
|
||||
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.premium.rose,
|
||||
fontSize: 9,
|
||||
@@ -720,128 +538,6 @@ export default function AppLayout() {
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Radial Menu Overlay */}
|
||||
{isRadialMenuOpen && (
|
||||
<Pressable
|
||||
style={radialStyles.overlay}
|
||||
onPress={closeRadialMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Radial Menu Items - positioned above the center button */}
|
||||
<Animated.View
|
||||
style={[
|
||||
radialStyles.menuContainer,
|
||||
{ opacity: radialMenuAnim },
|
||||
]}
|
||||
pointerEvents={isRadialMenuOpen ? 'auto' : 'none'}
|
||||
>
|
||||
{RADIAL_MENU_ITEMS.map((item) => {
|
||||
// Convert angle to radians and calculate position
|
||||
const angleRad = (item.angle - 90) * (Math.PI / 180); // -90 to make 0 = top
|
||||
const translateX = Math.cos(angleRad) * RADIAL_DISTANCE;
|
||||
const translateY = Math.sin(angleRad) * RADIAL_DISTANCE;
|
||||
|
||||
const itemScale = radialMenuAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 1],
|
||||
});
|
||||
|
||||
const itemTranslateX = radialMenuAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, translateX],
|
||||
});
|
||||
|
||||
const itemTranslateY = radialMenuAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, translateY],
|
||||
});
|
||||
|
||||
// Brand and feature variant colors
|
||||
const getVariantStyles = () => {
|
||||
switch (item.variant) {
|
||||
case 'primary':
|
||||
return {
|
||||
bg: `${NAV_COLORS.primaryRgba}0.15)`,
|
||||
border: `${NAV_COLORS.primaryRgba}0.35)`,
|
||||
icon: NAV_COLORS.primary,
|
||||
label: NAV_COLORS.primary,
|
||||
};
|
||||
case 'gold':
|
||||
return {
|
||||
bg: `${NAV_COLORS.goldRgba}0.15)`,
|
||||
border: `${NAV_COLORS.goldRgba}0.35)`,
|
||||
icon: NAV_COLORS.gold,
|
||||
label: NAV_COLORS.gold,
|
||||
};
|
||||
case 'rose':
|
||||
return {
|
||||
bg: `${NAV_COLORS.roseRgba}0.15)`,
|
||||
border: `${NAV_COLORS.roseRgba}0.35)`,
|
||||
icon: NAV_COLORS.rose,
|
||||
label: NAV_COLORS.rose,
|
||||
};
|
||||
case 'muted':
|
||||
default:
|
||||
return {
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
border: 'rgba(255, 255, 255, 0.15)',
|
||||
icon: colors.textSecondary,
|
||||
label: colors.textSecondary,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const variantStyles = getVariantStyles();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
style={[
|
||||
radialStyles.menuItem,
|
||||
{
|
||||
transform: [
|
||||
{ translateX: itemTranslateX },
|
||||
{ translateY: itemTranslateY },
|
||||
{ scale: itemScale },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => handleRadialMenuItemPress(item.id)}
|
||||
style={radialStyles.menuItemButton}
|
||||
>
|
||||
<View style={[
|
||||
radialStyles.menuItemCircle,
|
||||
{
|
||||
backgroundColor: variantStyles.bg,
|
||||
borderColor: variantStyles.border,
|
||||
},
|
||||
]}>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
size={22}
|
||||
color={variantStyles.icon}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[radialStyles.menuItemLabel, { color: variantStyles.label }]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
|
||||
{/* Match Notification Modal - Premium styling with response details */}
|
||||
<MatchNotificationModal
|
||||
visible={!!matchNotification}
|
||||
match={matchNotification}
|
||||
onDismiss={dismissNotification}
|
||||
onChat={handleMatchChat}
|
||||
/>
|
||||
|
||||
{/* Message Toast Notification */}
|
||||
{lastMessage && (
|
||||
<Animated.View
|
||||
@@ -923,98 +619,4 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Play button styles - Premium boutique
|
||||
playButtonContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: -28,
|
||||
},
|
||||
playButtonGlow: {
|
||||
position: 'absolute',
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
backgroundColor: colors.primaryGlow,
|
||||
opacity: 0.4,
|
||||
},
|
||||
playButtonGlowActive: {
|
||||
opacity: 0.7,
|
||||
...shadows.glow(colors.primary),
|
||||
},
|
||||
playButtonCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand
|
||||
...shadows.lg,
|
||||
},
|
||||
playButtonInner: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playButtonHighlight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '50%',
|
||||
},
|
||||
playButtonGradient: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
// Radial menu styles - Premium boutique
|
||||
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64;
|
||||
|
||||
const radialStyles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(13, 13, 26, 0.85)',
|
||||
zIndex: 998,
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
bottom: TAB_BAR_HEIGHT + 30, // Position above tab bar, centered on button
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
menuItem: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemCircle: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...shadows.md,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
menuItemLabel: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
marginTop: 8,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
});
|
||||
@@ -162,15 +162,36 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
badge: {
|
||||
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)',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
@@ -198,35 +219,14 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.15)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
});
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
+130
-105
@@ -1,22 +1,23 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore, usePacksStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow } from '../../src/components/discovery';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 600;
|
||||
|
||||
// Group packs by category
|
||||
function groupPacksByCategory(
|
||||
packs: QuestionPack[],
|
||||
categories: Category[]
|
||||
): { category: Category; packs: QuestionPack[] }[] {
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useAuthStore, usePacksStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow } from '../../src/components/discovery';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 600;
|
||||
|
||||
// Group packs by category
|
||||
function groupPacksByCategory(
|
||||
packs: QuestionPack[],
|
||||
categories: Category[]
|
||||
): { category: Category; packs: QuestionPack[] }[] {
|
||||
const categoryMap = new Map<string, QuestionPack[]>();
|
||||
const uncategorized: QuestionPack[] = [];
|
||||
|
||||
@@ -67,88 +68,97 @@ function groupPacksByCategory(
|
||||
export default function DiscoveryScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
|
||||
const isPremiumUser = user?.is_premium ?? false;
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
// Refresh data when screen gains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPacks();
|
||||
}, [])
|
||||
);
|
||||
|
||||
// Group packs by category
|
||||
const packsByCategory = useMemo(
|
||||
() => groupPacksByCategory(packs, categories),
|
||||
[packs, categories]
|
||||
);
|
||||
|
||||
// Check if user has no packs enabled (empty state)
|
||||
const hasNoPacks = packs.length === 0;
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
{/* Compact Header */}
|
||||
<CompactHeader user={user} partner={partner} couple={couple} />
|
||||
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
key={group.category.id}
|
||||
title={group.category.name}
|
||||
packs={group.packs}
|
||||
isPremiumUser={isPremiumUser}
|
||||
categoryId={group.category.id}
|
||||
delay={200 + index * 100}
|
||||
getPackProgress={getPackProgress}
|
||||
onShowPaywall={() => setShowPaywall(true)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state when no packs */}
|
||||
{hasNoPacks && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500).springify()}
|
||||
style={styles.emptyState}
|
||||
>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Ionicons name="layers-outline" size={48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No packs available</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
Check back soon for new content
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => {
|
||||
setShowPaywall(false);
|
||||
fetchPacks(); // Refresh packs to update lock status
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
const { width } = useWindowDimensions();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(130);
|
||||
|
||||
const isPremiumUser = user?.is_premium ?? false;
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
// Refresh data when screen gains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPacks();
|
||||
}, [])
|
||||
);
|
||||
|
||||
// Group packs by category
|
||||
const packsByCategory = useMemo(
|
||||
() => groupPacksByCategory(packs, categories),
|
||||
[packs, categories]
|
||||
);
|
||||
|
||||
// Check if user has no packs enabled (empty state)
|
||||
const hasNoPacks = packs.length === 0;
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View
|
||||
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
|
||||
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
|
||||
>
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
|
||||
<CompactHeader user={user} partner={partner} couple={couple} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
{ paddingTop: headerHeight },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
{/* Content Rows by Category */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
key={group.category.id}
|
||||
title={group.category.name}
|
||||
packs={group.packs}
|
||||
isPremiumUser={isPremiumUser}
|
||||
categoryId={group.category.id}
|
||||
delay={200 + index * 100}
|
||||
getPackProgress={getPackProgress}
|
||||
onShowPaywall={() => setShowPaywall(true)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state when no packs */}
|
||||
{hasNoPacks && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500).springify()}
|
||||
style={styles.emptyState}
|
||||
>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Ionicons name="layers-outline" size={48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No packs available</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
Check back soon for new content
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => {
|
||||
setShowPaywall(false);
|
||||
fetchPacks(); // Refresh packs to update lock status
|
||||
}}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -194,7 +204,22 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
});
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
stickyHeader: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
overflow: 'hidden', // Ensures blur doesn't bleed if we rounded corners (optional)
|
||||
},
|
||||
stickyHeaderWide: {
|
||||
left: '50%',
|
||||
right: 'auto',
|
||||
width: MAX_CONTENT_WIDTH,
|
||||
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
||||
},
|
||||
});
|
||||
|
||||
+555
-258
File diff suppressed because it is too large.
Load diff
@@ -48,7 +48,7 @@ type GroupByOption = "pack" | "date" | "answer";
|
||||
|
||||
export default function MyAnswersScreen() {
|
||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||
const { user } = useAuthStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -152,6 +152,9 @@ export default function MyAnswersScreen() {
|
||||
index={index}
|
||||
onEditPress={() => handleEditPress(item)}
|
||||
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
|
||||
viewerId={user?.id}
|
||||
viewerName={user?.name}
|
||||
partnerName={partner?.name}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -210,7 +213,7 @@ export default function MyAnswersScreen() {
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs */}
|
||||
{/* Ambient Orbs - Commented out for flat look
|
||||
<Animated.View
|
||||
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
|
||||
pointerEvents="none"
|
||||
@@ -233,16 +236,13 @@ export default function MyAnswersScreen() {
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
*/}
|
||||
|
||||
<View style={styles.container}>
|
||||
{/* Fixed Nav Bar */}
|
||||
<View style={styles.navBar}>
|
||||
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||
)}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
|
||||
</Animated.View>
|
||||
|
||||
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
|
||||
@@ -322,7 +322,7 @@ export default function MyAnswersScreen() {
|
||||
come back here to review and change your answers anytime.
|
||||
</Text>
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
onPress={() => router.push("/")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Start Swiping
|
||||
@@ -390,7 +390,8 @@ const styles = StyleSheet.create({
|
||||
navBarBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
|
||||
borderBottomColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
overflow: "hidden",
|
||||
},
|
||||
navBarBackButton: {
|
||||
@@ -401,12 +402,52 @@ const styles = StyleSheet.create({
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
countBadgePremium: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
// ...
|
||||
groupByButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
groupByButtonActive: {
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
// ...
|
||||
emptyIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
|
||||
navBarTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
@@ -439,17 +480,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
},
|
||||
countBadgePremium: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
|
||||
countTextPremium: {
|
||||
...typography.caption2,
|
||||
fontWeight: "600",
|
||||
@@ -463,20 +494,8 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
groupByButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
groupByButtonActive: {
|
||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||
borderColor: `${ACCENT_RGBA}0.3)`,
|
||||
},
|
||||
|
||||
|
||||
groupByText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
@@ -518,15 +537,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xxl,
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
|
||||
emptyTitle: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
|
||||
@@ -25,13 +25,16 @@ import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||
import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||
import { GlassToggle } from '../../src/components/ui/GlassToggle';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||
import { useAuthStore } from '../../src/store';
|
||||
import { supabase } from '../../src/lib/supabase';
|
||||
import { getProfileError } from '../../src/lib/errors';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
|
||||
import type { Gender } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
@@ -69,7 +72,9 @@ export default function OnboardingScreen() {
|
||||
const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const paywallNavigationRef = useRef(false);
|
||||
|
||||
const {
|
||||
avatarUri,
|
||||
@@ -187,6 +192,7 @@ export default function OnboardingScreen() {
|
||||
show_explicit_content: showExplicitContent,
|
||||
hide_nsfw: hideNsfw,
|
||||
onboarding_completed: true,
|
||||
onboarding_version: REQUIRED_ONBOARDING_VERSION,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -210,6 +216,15 @@ export default function OnboardingScreen() {
|
||||
console.log('[Onboarding] User fetched, navigating to home...');
|
||||
Events.profileUpdated(updatedFields);
|
||||
Events.onboardingComplete();
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
if (currentUser?.id && !currentUser.is_premium) {
|
||||
const hasSeenPaywall = await hasSeenOnboardingPaywall(currentUser.id);
|
||||
if (!hasSeenPaywall) {
|
||||
paywallNavigationRef.current = true;
|
||||
setShowPaywall(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.replace('/');
|
||||
} catch (err: any) {
|
||||
console.error('Onboarding error:', err);
|
||||
@@ -219,6 +234,18 @@ export default function OnboardingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaywallClose = () => {
|
||||
setShowPaywall(false);
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
if (currentUser?.id) {
|
||||
void markOnboardingPaywallSeen(currentUser.id);
|
||||
}
|
||||
if (paywallNavigationRef.current) {
|
||||
paywallNavigationRef.current = false;
|
||||
router.replace('/');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStage = () => {
|
||||
switch (stage) {
|
||||
case 'avatar':
|
||||
@@ -248,12 +275,11 @@ export default function OnboardingScreen() {
|
||||
{avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[colors.glass.background, colors.glass.backgroundLight]}
|
||||
<View
|
||||
style={styles.avatarPlaceholder}
|
||||
>
|
||||
<Ionicons name="camera" size={40} color={colors.textSecondary} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.avatarBadge}>
|
||||
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
|
||||
@@ -662,6 +688,12 @@ export default function OnboardingScreen() {
|
||||
{renderStage()}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={handlePaywallClose}
|
||||
offeringId={ONBOARDING_OFFERING_ID}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
@@ -685,9 +717,92 @@ const styles = StyleSheet.create({
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.glass.background,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
progressDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
avatarPlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
// ...
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
// ...
|
||||
genderOption: {
|
||||
width: '48%',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
// ...
|
||||
purposeOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
},
|
||||
// ...
|
||||
purposeIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
radioOuter: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// ...
|
||||
notificationFeature: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
backButtonPlaceholder: {
|
||||
width: 40,
|
||||
@@ -697,12 +812,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
progressDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
|
||||
progressDotActive: {
|
||||
backgroundColor: colors.primary,
|
||||
width: 24,
|
||||
@@ -747,16 +857,7 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 3,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
|
||||
avatarBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
@@ -800,15 +901,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
@@ -823,17 +916,7 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
genderOption: {
|
||||
width: '48%',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
|
||||
genderOptionSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primaryLight,
|
||||
@@ -850,28 +933,12 @@ const styles = StyleSheet.create({
|
||||
purposeList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
purposeOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
},
|
||||
|
||||
purposeOptionSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
purposeIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
|
||||
purposeIconSelected: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
@@ -921,15 +988,7 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
radioOuter: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
radioOuterSelected: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
@@ -956,13 +1015,7 @@ const styles = StyleSheet.create({
|
||||
footer: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
notificationFeature: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
|
||||
notificationFeatureLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { colors } from "../../../src/theme";
|
||||
|
||||
export default function PacksLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
animation: "fade",
|
||||
animationDuration: 150,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { GradientBackground } from "../../../src/components/ui";
|
||||
import { usePacksStore } from "../../../src/store";
|
||||
import { colors, spacing, radius, typography, shadows } from "../../../src/theme";
|
||||
|
||||
interface PackTypeCard {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
color: string;
|
||||
colorRgba: string;
|
||||
gradient: [string, string];
|
||||
comingSoon: boolean;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
const PACK_TYPES: PackTypeCard[] = [
|
||||
{
|
||||
id: "questions",
|
||||
title: "Question Packs",
|
||||
description: "Explore intimate questions together and discover what you both want",
|
||||
icon: "layers",
|
||||
color: colors.primary,
|
||||
colorRgba: "rgba(233, 69, 96, ",
|
||||
gradient: ["rgba(233, 69, 96, 0.25)", "rgba(155, 89, 182, 0.25)"],
|
||||
comingSoon: false,
|
||||
route: "/(app)/packs/questions",
|
||||
},
|
||||
{
|
||||
id: "dares",
|
||||
title: "Dare Packs",
|
||||
description: "Spice things up with exciting dares for adventurous couples",
|
||||
icon: "flash",
|
||||
color: colors.premium.gold,
|
||||
colorRgba: "rgba(212, 175, 55, ",
|
||||
gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"],
|
||||
comingSoon: true,
|
||||
route: "/(app)/dares",
|
||||
},
|
||||
{
|
||||
id: "quiz",
|
||||
title: "Quiz Packs",
|
||||
description: "Test how well you know each other with fun couple quizzes",
|
||||
icon: "help-circle",
|
||||
color: colors.premium.rose,
|
||||
colorRgba: "rgba(232, 164, 174, ",
|
||||
gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"],
|
||||
comingSoon: true,
|
||||
route: "/(app)/quiz",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PackTypeSelection() {
|
||||
const { fetchPacks } = usePacksStore();
|
||||
|
||||
// Prefetch packs data so it's ready when navigating to questions
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, []);
|
||||
|
||||
const handlePackTypePress = (packType: PackTypeCard) => {
|
||||
if (packType.route) {
|
||||
router.push(packType.route as any);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(600).springify()}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.label}>EXPLORE</Text>
|
||||
<Text style={styles.title}>Content Packs</Text>
|
||||
|
||||
{/* Decorative Separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
|
||||
<Text style={styles.subtitle}>
|
||||
Choose a pack type to explore
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Pack Type Cards */}
|
||||
<View style={styles.cardsContainer}>
|
||||
{PACK_TYPES.map((packType, index) => (
|
||||
<Animated.View
|
||||
key={packType.id}
|
||||
entering={FadeInDown.delay(100 + index * 100).duration(500).springify()}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => handlePackTypePress(packType)}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
pressed && !packType.comingSoon && styles.cardPressed,
|
||||
packType.comingSoon && styles.cardDisabled,
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={packType.gradient as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.cardGradient}
|
||||
>
|
||||
{/* Icon Circle */}
|
||||
<View
|
||||
style={[
|
||||
styles.iconCircle,
|
||||
{
|
||||
backgroundColor: `${packType.colorRgba}0.15)`,
|
||||
borderColor: `${packType.colorRgba}0.3)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={packType.icon}
|
||||
size={32}
|
||||
color={packType.comingSoon ? colors.textTertiary : packType.color}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.cardTitle,
|
||||
packType.comingSoon && styles.cardTitleDisabled,
|
||||
]}
|
||||
>
|
||||
{packType.title}
|
||||
</Text>
|
||||
{packType.comingSoon && (
|
||||
<View style={styles.comingSoonBadge}>
|
||||
<Text style={styles.comingSoonText}>Coming Soon</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.cardDescription,
|
||||
packType.comingSoon && styles.cardDescriptionDisabled,
|
||||
]}
|
||||
>
|
||||
{packType.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Arrow */}
|
||||
{!packType.comingSoon && (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
...typography.caption1,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 3,
|
||||
color: colors.primary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
},
|
||||
separator: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginVertical: spacing.md,
|
||||
width: 140,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: "rgba(233, 69, 96, 0.3)",
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: colors.primary,
|
||||
transform: [{ rotate: "45deg" }],
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
subtitle: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
},
|
||||
cardsContainer: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
card: {
|
||||
borderRadius: radius.lg,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
...shadows.md,
|
||||
},
|
||||
cardPressed: {
|
||||
opacity: 0.9,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
cardDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
cardGradient: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
cardTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
cardTitleDisabled: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
cardDescription: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
cardDescriptionDisabled: {
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
comingSoonBadge: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
comingSoonText: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
fontWeight: "500",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -1,864 +0,0 @@
|
||||
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
|
||||
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
useAnimatedScrollHandler,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from "react-native-reanimated";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
|
||||
import { Paywall } from "../../../src/components/paywall";
|
||||
import { PackTeaser } from "../../../src/components/PackTeaser";
|
||||
import { colors, spacing, radius, typography } from "../../../src/theme";
|
||||
import { getPackIconName } from "../../../src/lib/packIcons";
|
||||
import type { QuestionPack, Category } from "../../../src/types";
|
||||
|
||||
const HEADER_MAX_HEIGHT = 180;
|
||||
const HEADER_MIN_HEIGHT = 100;
|
||||
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
|
||||
|
||||
const INTENSITY_LABELS: Record<number, string> = {
|
||||
1: "Light",
|
||||
2: "Mild",
|
||||
3: "Moderate",
|
||||
4: "Spicy",
|
||||
5: "Intense",
|
||||
};
|
||||
|
||||
const clampIntensity = (value: number) => Math.min(5, Math.max(1, Math.round(value)));
|
||||
|
||||
const isValidIntensity = (value?: number | null): value is number =>
|
||||
typeof value === "number" && value >= 1 && value <= 5;
|
||||
|
||||
const getIntensityDisplay = (pack: QuestionPack) => {
|
||||
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
|
||||
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
|
||||
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
|
||||
const fallback = avg ?? (pack.is_explicit ? 5 : 2);
|
||||
|
||||
const minCount = min ?? max ?? fallback;
|
||||
const maxCount = max ?? min ?? fallback;
|
||||
const label = minCount === maxCount
|
||||
? INTENSITY_LABELS[minCount]
|
||||
: `${INTENSITY_LABELS[minCount]} - ${INTENSITY_LABELS[maxCount]}`;
|
||||
|
||||
return { minCount, maxCount, label };
|
||||
};
|
||||
|
||||
|
||||
interface PackRowProps {
|
||||
pack: QuestionPack;
|
||||
isEnabled: boolean;
|
||||
isPremiumLocked: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onPress: () => void;
|
||||
onInfoPress: () => void;
|
||||
}
|
||||
|
||||
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
|
||||
const questionCount = pack.questions?.[0]?.count || 0;
|
||||
const intensityDisplay = getIntensityDisplay(pack);
|
||||
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.packRow}>
|
||||
<View style={styles.packRowMain}>
|
||||
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
|
||||
<Ionicons
|
||||
name={getPackIconName(pack.icon)}
|
||||
size={20}
|
||||
color={isEnabled ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.packContent}>
|
||||
<View style={styles.packTitleRow}>
|
||||
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
|
||||
{pack.is_premium && (
|
||||
<View style={[styles.premiumBadge, isPremiumLocked && styles.premiumBadgeLocked]}>
|
||||
<Ionicons
|
||||
name={isPremiumLocked ? "lock-closed" : "sparkles"}
|
||||
size={12}
|
||||
color={colors.premium.gold}
|
||||
/>
|
||||
<Text style={styles.premiumBadgeText}>PRO</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.packMeta}>
|
||||
{questionCount} {questionCount === 1 ? "question" : "questions"}
|
||||
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
|
||||
</Text>
|
||||
<View style={styles.packIntensityRow}>
|
||||
<View style={styles.packIntensityFlames}>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const level = index + 1;
|
||||
const isInRange = level >= intensityDisplay.minCount && level <= intensityDisplay.maxCount;
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
key={`intensity-${pack.id}-${index}`}
|
||||
name={isInRange ? "flame" : "flame-outline"}
|
||||
size={12}
|
||||
color={isInRange ? colors.primary : colors.textTertiary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Ionicons
|
||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isExpanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
style={styles.packExpanded}
|
||||
>
|
||||
{pack.description && (
|
||||
<Text style={styles.packDescription}>{pack.description}</Text>
|
||||
)}
|
||||
<View style={styles.packActions}>
|
||||
<Pressable style={styles.packInfoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="eye-outline" size={18} color={colors.textSecondary} />
|
||||
<Text style={styles.packInfoButtonText}>Preview</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.packToggleButton,
|
||||
isEnabled && styles.packToggleButtonEnabled,
|
||||
isPremiumLocked && styles.packToggleButtonLocked,
|
||||
]}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{isPremiumLocked ? (
|
||||
<>
|
||||
<Ionicons name="lock-closed" size={16} color={colors.premium.gold} />
|
||||
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextLocked]}>
|
||||
Unlock
|
||||
</Text>
|
||||
</>
|
||||
) : isEnabled ? (
|
||||
<>
|
||||
<Ionicons name="checkmark" size={16} color={colors.primary} />
|
||||
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextEnabled]}>
|
||||
Enabled
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="add" size={16} color={colors.text} />
|
||||
<Text style={styles.packToggleButtonText}>Enable</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
category: Category;
|
||||
packs: QuestionPack[];
|
||||
enabledPackIds: string[];
|
||||
hasPremiumAccess: boolean;
|
||||
expandedPackId: string | null;
|
||||
onPackPress: (packId: string) => void;
|
||||
onPackToggle: (pack: QuestionPack) => void;
|
||||
onPackInfo: (pack: QuestionPack) => void;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
packs,
|
||||
enabledPackIds,
|
||||
hasPremiumAccess,
|
||||
expandedPackId,
|
||||
onPackPress,
|
||||
onPackToggle,
|
||||
onPackInfo,
|
||||
}: CategorySectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||
|
||||
const rotateAnim = useSharedValue(0);
|
||||
|
||||
const chevronStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotateAnim.value}deg` }],
|
||||
}));
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 });
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.categorySection}>
|
||||
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
|
||||
<View style={styles.categoryHeaderLeft}>
|
||||
<Ionicons
|
||||
name={getPackIconName(category.icon)}
|
||||
size={16}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
<Text style={styles.categoryName}>{category.name}</Text>
|
||||
{enabledCount > 0 && (
|
||||
<View style={styles.categoryBadge}>
|
||||
<Text style={styles.categoryBadgeText}>{enabledCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Animated.View style={chevronStyle}>
|
||||
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
style={styles.packsList}
|
||||
>
|
||||
{packs.map((pack) => (
|
||||
<PackRow
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
isEnabled={enabledPackIds.includes(pack.id)}
|
||||
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
|
||||
isExpanded={expandedPackId === pack.id}
|
||||
onPress={() => onPackPress(pack.id)}
|
||||
onToggle={() => onPackToggle(pack)}
|
||||
onInfoPress={() => onPackInfo(pack)}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuestionPacksScreen() {
|
||||
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks, showAllIntensities, setShowAllIntensities } = usePacksStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
|
||||
const [expandedPackId, setExpandedPackId] = useState<string | null>(null);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showTeaser, setShowTeaser] = useState(false);
|
||||
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
|
||||
|
||||
const scrollY = useSharedValue(0);
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
|
||||
const hasPremiumAccess = useMemo(() => {
|
||||
return user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
|
||||
|
||||
const packsByCategory = useMemo(() => {
|
||||
const grouped = new Map<string, QuestionPack[]>();
|
||||
categories.forEach(cat => grouped.set(cat.id, []));
|
||||
grouped.set("uncategorized", []);
|
||||
packs.forEach(pack => {
|
||||
const catId = pack.category_id || "uncategorized";
|
||||
const existing = grouped.get(catId) || [];
|
||||
existing.push(pack);
|
||||
grouped.set(catId, existing);
|
||||
});
|
||||
return grouped;
|
||||
}, [packs, categories]);
|
||||
|
||||
const handlePackPress = useCallback((packId: string) => {
|
||||
setExpandedPackId(current => current === packId ? null : packId);
|
||||
}, []);
|
||||
|
||||
const handlePackToggle = useCallback(async (pack: QuestionPack) => {
|
||||
if (pack.is_premium && !hasPremiumAccess) {
|
||||
setTeaserPack(pack);
|
||||
setShowTeaser(true);
|
||||
return;
|
||||
}
|
||||
const result = await togglePack(pack.id);
|
||||
if (!result.success && result.reason === "no_couple") {
|
||||
Alert.alert(
|
||||
"Pair with Partner",
|
||||
"You need to pair with your partner before you can customise question packs.",
|
||||
[
|
||||
{ text: "Later", style: "cancel" },
|
||||
{ text: "Pair Now", onPress: () => router.push("/pairing") }
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [hasPremiumAccess, togglePack]);
|
||||
|
||||
const handlePackInfo = useCallback((pack: QuestionPack) => {
|
||||
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
|
||||
if (isPremiumLocked) {
|
||||
setTeaserPack(pack);
|
||||
setShowTeaser(true);
|
||||
} else {
|
||||
router.push(`/pack/${pack.id}`);
|
||||
}
|
||||
}, [hasPremiumAccess]);
|
||||
|
||||
const handleTeaserUnlock = useCallback(() => {
|
||||
setShowTeaser(false);
|
||||
setShowPaywall(true);
|
||||
}, []);
|
||||
|
||||
const handleTeaserClose = useCallback(() => {
|
||||
setShowTeaser(false);
|
||||
setTeaserPack(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleComfortZone = useCallback((value: boolean) => {
|
||||
setShowAllIntensities(value);
|
||||
}, [setShowAllIntensities]);
|
||||
|
||||
const enabledPackCount = enabledPackIds.length;
|
||||
const totalQuestions = packs
|
||||
.filter(p => enabledPackIds.includes(p.id))
|
||||
.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
|
||||
|
||||
// Animated styles for collapsing header
|
||||
const largeHeaderStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE * 0.5],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const translateY = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE],
|
||||
[0, -20],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return { opacity, transform: [{ translateY }] };
|
||||
});
|
||||
|
||||
const compactHeaderStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.container}>
|
||||
{/* Fixed Nav Bar */}
|
||||
<View style={styles.navBar}>
|
||||
{/* Nav bar background with blur */}
|
||||
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Back button */}
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
{/* Compact title (fades in on scroll) */}
|
||||
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]}>
|
||||
Question Packs
|
||||
</Animated.Text>
|
||||
|
||||
{/* Spacer for alignment */}
|
||||
<View style={styles.navBarSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={fetchPacks}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
progressViewOffset={HEADER_MAX_HEIGHT}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* Large Header (fades out on scroll) */}
|
||||
<Animated.View style={[styles.largeHeader, largeHeaderStyle]}>
|
||||
<Text style={styles.label}>COLLECTION</Text>
|
||||
<Text style={styles.title}>Question Packs</Text>
|
||||
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.statsBadge}>
|
||||
<Ionicons name="checkmark-circle" size={14} color={colors.primary} />
|
||||
<Text style={styles.statsBadgeText}>
|
||||
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.comfortZoneToggle}
|
||||
onPress={() => handleToggleComfortZone(!showAllIntensities)}
|
||||
>
|
||||
<View style={styles.comfortZoneToggleLeft}>
|
||||
<Ionicons
|
||||
name={showAllIntensities ? "flame" : "flame-outline"}
|
||||
size={16}
|
||||
color={showAllIntensities ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
<Text style={styles.comfortZoneToggleText}>
|
||||
Show all intensities
|
||||
</Text>
|
||||
</View>
|
||||
<GlassToggle
|
||||
value={showAllIntensities}
|
||||
onValueChange={handleToggleComfortZone}
|
||||
/>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{/* Categories */}
|
||||
<View style={styles.categoriesContainer}>
|
||||
{categories.map(category => {
|
||||
const categoryPacks = packsByCategory.get(category.id) || [];
|
||||
if (categoryPacks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CategorySection
|
||||
key={category.id}
|
||||
category={category}
|
||||
packs={categoryPacks}
|
||||
enabledPackIds={enabledPackIds}
|
||||
hasPremiumAccess={hasPremiumAccess}
|
||||
expandedPackId={expandedPackId}
|
||||
onPackPress={handlePackPress}
|
||||
onPackToggle={handlePackToggle}
|
||||
onPackInfo={handlePackInfo}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
|
||||
<CategorySection
|
||||
category={{
|
||||
id: "uncategorized",
|
||||
name: "Other",
|
||||
description: null,
|
||||
icon: "📁",
|
||||
color: null,
|
||||
sort_order: 999,
|
||||
created_at: "",
|
||||
is_public: true
|
||||
}}
|
||||
packs={packsByCategory.get("uncategorized") || []}
|
||||
enabledPackIds={enabledPackIds}
|
||||
hasPremiumAccess={hasPremiumAccess}
|
||||
expandedPackId={expandedPackId}
|
||||
onPackPress={handlePackPress}
|
||||
onPackToggle={handlePackToggle}
|
||||
onPackInfo={handlePackInfo}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
{/* Modals */}
|
||||
{teaserPack && (
|
||||
<PackTeaser
|
||||
visible={showTeaser}
|
||||
packId={teaserPack.id}
|
||||
packName={teaserPack.name}
|
||||
packIcon={teaserPack.icon}
|
||||
onClose={handleTeaserClose}
|
||||
onUnlock={handleTeaserUnlock}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => fetchPacks()}
|
||||
/>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_BAR_HEIGHT = 44;
|
||||
const STATUS_BAR_HEIGHT = 60; // Match other pages
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Fixed Nav Bar
|
||||
navBar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingTop: STATUS_BAR_HEIGHT - 10,
|
||||
paddingHorizontal: spacing.md,
|
||||
zIndex: 100,
|
||||
},
|
||||
navBarBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "rgba(255, 255, 255, 0.1)",
|
||||
overflow: "hidden",
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
navBarTitle: {
|
||||
flex: 1,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
marginRight: 40, // Balance with back button
|
||||
},
|
||||
navBarSpacer: {
|
||||
width: 0,
|
||||
},
|
||||
|
||||
// Large Header
|
||||
largeHeader: {
|
||||
alignItems: "center",
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
label: {
|
||||
...typography.caption1,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 3,
|
||||
color: colors.primary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
},
|
||||
separator: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginVertical: spacing.md,
|
||||
width: 140,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: "rgba(233, 69, 96, 0.3)",
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: colors.primary,
|
||||
transform: [{ rotate: "45deg" }],
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
statsBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(233, 69, 96, 0.2)",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statsBadgeText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "500",
|
||||
},
|
||||
comfortZoneToggle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
marginTop: spacing.md,
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
},
|
||||
comfortZoneToggleLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
comfortZoneToggleText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "500",
|
||||
},
|
||||
|
||||
// Scroll Content
|
||||
scrollContent: {
|
||||
paddingTop: 60, // Match other pages
|
||||
paddingBottom: Platform.OS === "ios" ? 120 : 100,
|
||||
},
|
||||
categoriesContainer: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
|
||||
// Category Section
|
||||
categorySection: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
categoryHeaderLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
categoryName: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
fontSize: 13,
|
||||
},
|
||||
categoryBadge: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
categoryBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
},
|
||||
packsList: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
// Pack Row
|
||||
packRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
packRowMain: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
packIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
packIconEnabled: {
|
||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||
borderColor: "rgba(233, 69, 96, 0.2)",
|
||||
},
|
||||
packContent: {
|
||||
flex: 1,
|
||||
},
|
||||
packTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
packName: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
flexShrink: 1,
|
||||
},
|
||||
premiumBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: "rgba(212, 175, 55, 0.12)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(212, 175, 55, 0.25)",
|
||||
},
|
||||
premiumBadgeLocked: {
|
||||
backgroundColor: "rgba(212, 175, 55, 0.18)",
|
||||
borderColor: "rgba(212, 175, 55, 0.35)",
|
||||
},
|
||||
premiumBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.premium.gold,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
packMeta: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
packMetaEnabled: {
|
||||
color: colors.primary,
|
||||
},
|
||||
packIntensityRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
packIntensityFlames: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
packIntensityText: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
// Expanded Pack
|
||||
packExpanded: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
paddingTop: 0,
|
||||
},
|
||||
packDescription: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
packActions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
packInfoButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
packInfoButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packToggleButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||
},
|
||||
packToggleButtonEnabled: {
|
||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||
borderColor: "rgba(233, 69, 96, 0.3)",
|
||||
},
|
||||
packToggleButtonLocked: {
|
||||
backgroundColor: "rgba(212, 175, 55, 0.1)",
|
||||
borderColor: "rgba(212, 175, 55, 0.3)",
|
||||
},
|
||||
packToggleButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
},
|
||||
packToggleButtonTextEnabled: {
|
||||
color: colors.primary,
|
||||
},
|
||||
packToggleButtonTextLocked: {
|
||||
color: colors.premium.gold,
|
||||
},
|
||||
});
|
||||
@@ -414,15 +414,61 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
input: {
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
@@ -462,29 +508,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
letterSpacing: 4,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
|
||||
divider: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
|
||||
dividerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
@@ -501,15 +531,7 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 6,
|
||||
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
|
||||
},
|
||||
copyIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
|
||||
tapToCopy: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
@@ -520,15 +542,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
waitingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
|
||||
waitingText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
|
||||
@@ -162,15 +162,36 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.6,
|
||||
},
|
||||
badge: {
|
||||
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,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
@@ -198,35 +219,14 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
interestButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(232, 164, 174, 0.1)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(232, 164, 174, 0.2)',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
interestButtonActive: {
|
||||
backgroundColor: 'rgba(232, 164, 174, 0.15)',
|
||||
borderColor: 'rgba(232, 164, 174, 0.3)',
|
||||
},
|
||||
interestButtonText: {
|
||||
...typography.subhead,
|
||||
fontWeight: '600',
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
});
|
||||
teaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
interestButtonContainer: {
|
||||
marginTop: spacing.xl,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
+343
-110
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
|
||||
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { decode } from "base64-arraybuffer";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
@@ -17,16 +19,45 @@ import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore, useAuthStore } from "../../src/store";
|
||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
|
||||
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
|
||||
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
|
||||
// import SwipeCard from "../../src/components/SwipeCard";
|
||||
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
|
||||
import { SwipeTutorial } from "../../src/components/tutorials";
|
||||
import {
|
||||
QuestionCard,
|
||||
QuestionCardText,
|
||||
QuestionCardPhoto,
|
||||
QuestionCardAudio,
|
||||
QuestionCardWhoLikely
|
||||
} from "../../src/components/questions";
|
||||
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
||||
import { MatchConfetti } from "../../src/components/MatchConfetti";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { Paywall } from "../../src/components/paywall";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
import { colors, gradients, spacing, typography, radius, shadows, getCategoryColor } from "../../src/theme";
|
||||
import type { AnswerType } from "../../src/types";
|
||||
|
||||
// Response data types for different question types
|
||||
interface TextResponseData {
|
||||
type: 'text_answer';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PhotoResponseData {
|
||||
type: 'photo';
|
||||
media_path: string;
|
||||
}
|
||||
|
||||
interface AudioResponseData {
|
||||
type: 'audio';
|
||||
media_path: string;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
interface WhoLikelyResponseData {
|
||||
type: 'who_likely';
|
||||
chosen_user_id: string;
|
||||
}
|
||||
|
||||
type ResponseData = TextResponseData | PhotoResponseData | AudioResponseData | WhoLikelyResponseData;
|
||||
|
||||
interface DailyLimitInfo {
|
||||
responses_today: number;
|
||||
@@ -47,15 +78,25 @@ export default function SwipeScreen() {
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | 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 { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
|
||||
// Supported question types - filter out types the UI doesn't support yet
|
||||
const supportedTypes = ['swipe', 'text_answer', 'photo', 'audio', 'who_likely'];
|
||||
|
||||
// Filter questions to only supported types (safety during rollout)
|
||||
// Must be calculated early since it's used by effectiveTotal and throughout rendering
|
||||
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);
|
||||
@@ -134,8 +175,7 @@ export default function SwipeScreen() {
|
||||
fetchQuestions();
|
||||
}
|
||||
});
|
||||
checkTutorial();
|
||||
}, [packId, mode]);
|
||||
}, [packId, mode, startQuestionId]);
|
||||
|
||||
// Fetch pack context when packId is provided
|
||||
useEffect(() => {
|
||||
@@ -252,7 +292,7 @@ export default function SwipeScreen() {
|
||||
|
||||
// Calculate effective total questions (accounting for gap limit AND daily limit)
|
||||
const effectiveTotal = (() => {
|
||||
let total = questions.length;
|
||||
let total = filteredQuestions.length;
|
||||
|
||||
// Apply gap limit if active
|
||||
if (gapInfo && gapInfo.threshold > 0) {
|
||||
@@ -270,23 +310,11 @@ export default function SwipeScreen() {
|
||||
|
||||
// Track when all questions are exhausted
|
||||
useEffect(() => {
|
||||
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
|
||||
if (filteredQuestions.length > 0 && currentIndex >= filteredQuestions.length && !hasTrackedExhausted.current) {
|
||||
hasTrackedExhausted.current = true;
|
||||
Events.allQuestionsExhausted();
|
||||
}
|
||||
}, [currentIndex, questions.length]);
|
||||
|
||||
const checkTutorial = async () => {
|
||||
const seen = await hasSeenSwipeTutorial();
|
||||
if (!seen) {
|
||||
setShowTutorial(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
await markSwipeTutorialSeen();
|
||||
setShowTutorial(false);
|
||||
};
|
||||
}, [currentIndex, filteredQuestions.length]);
|
||||
|
||||
// Filter out questions from disabled packs immediately when enabledPackIds changes
|
||||
// Skip this filter in pending mode - user should answer ALL questions partner swiped on
|
||||
@@ -328,7 +356,12 @@ export default function SwipeScreen() {
|
||||
}
|
||||
|
||||
// Filter out recently skipped questions
|
||||
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
||||
let filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
|
||||
|
||||
// If no unskipped questions remain, show skipped ones again
|
||||
if (filtered.length === 0 && data && data.length > 0) {
|
||||
filtered = data;
|
||||
}
|
||||
|
||||
// Always check answer gap and daily limit when user has partner and not viewing specific pack
|
||||
if (partner && !packId) {
|
||||
@@ -469,33 +502,123 @@ export default function SwipeScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
||||
const question = questions[currentIndex];
|
||||
// Upload media (photo/audio) to Supabase storage
|
||||
const uploadResponseMedia = async (
|
||||
localUri: string,
|
||||
questionId: string,
|
||||
mediaType: 'photo' | 'audio'
|
||||
): Promise<string | null> => {
|
||||
if (!user?.id) return null;
|
||||
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const extMatch = localUri.match(/\.(\w+)$/);
|
||||
const ext = extMatch ? extMatch[1] : (mediaType === 'photo' ? 'jpg' : 'm4a');
|
||||
const fileName = `${user.id}/${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;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle answer submission from question cards (supports response_data for new question types)
|
||||
const handleAnswer = async (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => {
|
||||
const question = filteredQuestions[currentIndex];
|
||||
|
||||
// Handle skip - don't submit a response, just store it for later
|
||||
if (direction === "down") {
|
||||
await skipQuestion(question.id);
|
||||
if (answer === 'skip') {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
await skipQuestion(questionId);
|
||||
Events.questionSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
||||
|
||||
try {
|
||||
const { error } = await invokeWithAuthRetry("submit-response", {
|
||||
// Upload media if response contains photo or audio with local URI
|
||||
let finalResponseData = responseData;
|
||||
if (responseData) {
|
||||
if (responseData.type === 'photo' && responseData.media_path) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const uploadedPath = await uploadResponseMedia(responseData.media_path, questionId, 'photo');
|
||||
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');
|
||||
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: question.id,
|
||||
question_id: questionId,
|
||||
answer,
|
||||
response_data: finalResponseData,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Submit response error:", error);
|
||||
// Advance to next question even on error
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
} else {
|
||||
Events.questionAnswered(answer, question.pack_id);
|
||||
|
||||
|
||||
// Check if a match was created
|
||||
const hasMatch = data?.match != null;
|
||||
|
||||
if (hasMatch) {
|
||||
// Show confetti celebration - delay advancing to next question
|
||||
setShowConfetti(true);
|
||||
// Confetti component will call onAnimationComplete which advances
|
||||
} else {
|
||||
// No match - advance immediately
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}
|
||||
|
||||
// Skip limit checks in pending mode - user is just catching up
|
||||
if (mode !== 'pending') {
|
||||
// Increment local daily count if we have limit info
|
||||
@@ -514,6 +637,131 @@ export default function SwipeScreen() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit response", error);
|
||||
// Advance to next question even on error
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle confetti animation completion
|
||||
const handleConfettiComplete = useCallback(() => {
|
||||
setShowConfetti(false);
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Get pack info for a question - use packContext if viewing a specific pack, otherwise question's pack
|
||||
const getQuestionPackInfo = (question: any) => {
|
||||
// If viewing specific pack, use that context but try to enrich with color from store
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// For pending mode or mixed mode, look up pack details
|
||||
if (question.pack_id) {
|
||||
const pack = packs.find(p => p.id === question.pack_id);
|
||||
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
|
||||
|
||||
// Pending questions might have name/icon embedded if pack not found in store
|
||||
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;
|
||||
};
|
||||
|
||||
// Render the appropriate question card based on question_type
|
||||
const renderQuestionCard = (question: any) => {
|
||||
// For backwards compatibility: default to 'swipe' if no question_type
|
||||
const questionType = question.question_type || 'swipe';
|
||||
const packInfo = getQuestionPackInfo(question);
|
||||
|
||||
switch (questionType) {
|
||||
case 'text_answer':
|
||||
return (
|
||||
<QuestionCardText
|
||||
key={question.id}
|
||||
question={question}
|
||||
packInfo={packInfo}
|
||||
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: question.id,
|
||||
text: question.text
|
||||
})}
|
||||
/>
|
||||
);
|
||||
case 'photo':
|
||||
return (
|
||||
<QuestionCardPhoto
|
||||
key={question.id}
|
||||
question={question}
|
||||
packInfo={packInfo}
|
||||
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: question.id,
|
||||
text: question.text
|
||||
})}
|
||||
/>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<QuestionCardAudio
|
||||
key={question.id}
|
||||
question={question}
|
||||
packInfo={packInfo}
|
||||
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: question.id,
|
||||
text: question.text
|
||||
})}
|
||||
/>
|
||||
);
|
||||
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) => handleAnswer(question.id, 'yes', responseData)}
|
||||
onSkip={() => handleAnswer(question.id, 'skip')}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: question.id,
|
||||
text: question.text
|
||||
})}
|
||||
/>
|
||||
);
|
||||
case 'swipe':
|
||||
default:
|
||||
// Standard swipe card for 'swipe' type and any unknown types (backwards compatibility)
|
||||
return (
|
||||
<QuestionCard
|
||||
key={question.id}
|
||||
question={question}
|
||||
packInfo={packInfo}
|
||||
onAnswer={(answer) => handleAnswer(question.id, answer)}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: question.id,
|
||||
text: question.text
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -600,7 +848,7 @@ export default function SwipeScreen() {
|
||||
const LIMIT_ACCENT = colors.premium.gold;
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs - Gold/warm glow */}
|
||||
{/* Ambient Orbs - Gold/warm glow - Commented out for flat design
|
||||
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.premium.goldGlow, 'transparent']}
|
||||
@@ -617,6 +865,7 @@ export default function SwipeScreen() {
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
*/}
|
||||
|
||||
<View style={styles.centerContainer}>
|
||||
<Animated.View
|
||||
@@ -826,10 +1075,10 @@ export default function SwipeScreen() {
|
||||
<Text style={styles.waitingTeaser}>Your journey of discovery awaits</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => router.push("/packs")}
|
||||
onPress={() => router.push("/")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Choose Packs
|
||||
Browse Packs
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -837,7 +1086,7 @@ export default function SwipeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (currentIndex >= questions.length) {
|
||||
if (currentIndex >= filteredQuestions.length) {
|
||||
const CAUGHT_UP_ACCENT = colors.premium.rose;
|
||||
const isPackMode = !!packId;
|
||||
const isPendingMode = mode === 'pending';
|
||||
@@ -878,8 +1127,8 @@ export default function SwipeScreen() {
|
||||
{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. Explore other packs to discover more!"
|
||||
: "You've answered all available questions. New questions are added regularly, or explore different packs."
|
||||
? "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."
|
||||
}
|
||||
</Text>
|
||||
|
||||
@@ -907,8 +1156,8 @@ export default function SwipeScreen() {
|
||||
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Try different packs</Text>
|
||||
<Ionicons name="home-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Explore from home</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
@@ -936,10 +1185,10 @@ export default function SwipeScreen() {
|
||||
</GlassButton>
|
||||
) : isPackMode ? (
|
||||
<GlassButton
|
||||
onPress={() => router.back()}
|
||||
onPress={() => router.push("/")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Explore More Packs
|
||||
Back to Home
|
||||
</GlassButton>
|
||||
) : (
|
||||
<GlassButton
|
||||
@@ -958,7 +1207,7 @@ export default function SwipeScreen() {
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs - Premium gold/rose */}
|
||||
{/* Ambient Orbs - Premium gold/rose - Commented out for flat design
|
||||
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.premium.goldGlow, 'transparent']}
|
||||
@@ -975,6 +1224,7 @@ export default function SwipeScreen() {
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
*/}
|
||||
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
@@ -995,14 +1245,14 @@ export default function SwipeScreen() {
|
||||
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
|
||||
</Text>
|
||||
<Text style={styles.progressText}>
|
||||
{currentIndex + 1} of {effectiveTotal || questions.length}
|
||||
{currentIndex + 1} of {effectiveTotal || filteredQuestions.length}
|
||||
</Text>
|
||||
<View style={[styles.progressBar, styles.progressBarPremium]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
styles.progressFillPremium,
|
||||
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
|
||||
{ width: `${((currentIndex + 1) / (effectiveTotal || filteredQuestions.length || 1)) * 100}%` }
|
||||
]}
|
||||
>
|
||||
{/* Shimmer sweep */}
|
||||
@@ -1025,52 +1275,23 @@ export default function SwipeScreen() {
|
||||
{/* Card Stack */}
|
||||
<View style={styles.cardContainer}>
|
||||
{/* Third card (deepest) */}
|
||||
{questions[currentIndex + 2] && (
|
||||
{filteredQuestions[currentIndex + 2] && (
|
||||
<View style={[styles.stackCard, styles.stackCardThird]} />
|
||||
)}
|
||||
|
||||
{/* Second card */}
|
||||
{questions[currentIndex + 1] && (
|
||||
{filteredQuestions[currentIndex + 1] && (
|
||||
<View style={[styles.stackCard, styles.stackCardSecond]} />
|
||||
)}
|
||||
|
||||
{/* Active card */}
|
||||
<SwipeCard
|
||||
key={questions[currentIndex].id}
|
||||
question={questions[currentIndex]}
|
||||
onSwipe={handleSwipe}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: questions[currentIndex].id,
|
||||
text: questions[currentIndex].text
|
||||
})}
|
||||
/>
|
||||
{/* Active card - render appropriate component based on question_type */}
|
||||
{renderQuestionCard(filteredQuestions[currentIndex])}
|
||||
</View>
|
||||
|
||||
{/* Hint - Premium styling */}
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(500).duration(400)}
|
||||
style={styles.hintContainer}
|
||||
>
|
||||
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
|
||||
style={styles.editLinkButton}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.editLinkText}>Edit previous answers</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Bottom spacing for tab bar */}
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<SwipeTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
|
||||
{/* Question Feedback Modal */}
|
||||
<QuestionFeedbackModal
|
||||
visible={!!feedbackQuestion}
|
||||
@@ -1079,6 +1300,12 @@ export default function SwipeScreen() {
|
||||
questionText={feedbackQuestion?.text || ''}
|
||||
/>
|
||||
|
||||
{/* Match Confetti Animation */}
|
||||
<MatchConfetti
|
||||
visible={showConfetti}
|
||||
onAnimationComplete={handleConfettiComplete}
|
||||
/>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
@@ -1089,6 +1316,16 @@ export default function SwipeScreen() {
|
||||
fetchQuestions();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Upload spinner overlay */}
|
||||
{isUploading && (
|
||||
<View style={styles.uploadOverlay}>
|
||||
<View style={styles.uploadSpinnerContainer}>
|
||||
<ActivityIndicator size="large" color={colors.premium.gold} />
|
||||
<Text style={styles.uploadText}>Uploading...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
@@ -1193,11 +1430,11 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Card stack - Premium styling
|
||||
// Card stack - Premium styling (background cards)
|
||||
stackCard: {
|
||||
position: "absolute",
|
||||
width: "85%",
|
||||
height: 500,
|
||||
height: "85%",
|
||||
backgroundColor: '#0d0d1a',
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -1213,32 +1450,6 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.2,
|
||||
...shadows.sm,
|
||||
},
|
||||
hintContainer: {
|
||||
alignItems: "center",
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
hintText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
hintTextPremium: {
|
||||
...typography.caption1,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
editLinkButton: {
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
editLinkText: {
|
||||
...typography.caption2,
|
||||
color: colors.premium.gold,
|
||||
textDecorationLine: 'underline',
|
||||
opacity: 0.8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
@@ -1443,4 +1654,26 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
},
|
||||
// Upload overlay styles
|
||||
uploadOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
},
|
||||
uploadSpinnerContainer: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.lg,
|
||||
borderRadius: radius.lg,
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
uploadText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
},
|
||||
});
|
||||
@@ -768,10 +768,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
modeToggleContainer: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: colors.glass.background,
|
||||
backgroundColor: colors.background, // Flat background
|
||||
borderRadius: radius.md,
|
||||
padding: 4,
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
modeToggle: {
|
||||
flex: 1,
|
||||
@@ -832,11 +840,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
|
||||
dividerText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
|
||||
+16
-15
@@ -1,7 +1,8 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { View, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { needsOnboarding } from "../src/constants/onboarding";
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
|
||||
@@ -19,8 +20,8 @@ export default function Index() {
|
||||
return <Redirect href="/(auth)/login" />;
|
||||
}
|
||||
|
||||
// If user hasn't completed onboarding, go to onboarding first
|
||||
if (!user?.onboarding_completed) {
|
||||
// If user hasn't completed onboarding or needs to re-onboard, go to onboarding
|
||||
if (needsOnboarding(user?.onboarding_completed, user?.onboarding_version)) {
|
||||
return <Redirect href="/(app)/onboarding" />;
|
||||
}
|
||||
|
||||
@@ -28,12 +29,12 @@ export default function Index() {
|
||||
// No forced redirect to pairing for users without a couple
|
||||
return <Redirect href="/(app)" />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a2e",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#1a1a2e",
|
||||
},
|
||||
});
|
||||
+115
-89
@@ -242,12 +242,19 @@ export default function PackDetailsScreen() {
|
||||
{category ? category.name.toUpperCase() : 'PREVIEW'}
|
||||
</Text>
|
||||
|
||||
{/* Explicit badge */}
|
||||
{pack?.is_explicit && (
|
||||
<View style={styles.explicitBadge}>
|
||||
<Ionicons name="flame" size={12} color={colors.text} />
|
||||
<Text style={styles.explicitBadgeText}>18+ EXPLICIT</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Icon with enhanced glow for premium */}
|
||||
<View style={styles.iconWrapper}>
|
||||
{pack?.is_premium && (
|
||||
<>
|
||||
<View style={styles.iconGlowOuter} />
|
||||
<View style={styles.iconGlow} />
|
||||
{/* Glow effects removed for flat design */}
|
||||
</>
|
||||
)}
|
||||
<View style={[
|
||||
@@ -329,11 +336,7 @@ export default function PackDetailsScreen() {
|
||||
{/* Fixed Nav Bar */}
|
||||
<View style={styles.navBar}>
|
||||
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||
)}
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
|
||||
</Animated.View>
|
||||
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
@@ -440,8 +443,87 @@ const styles = StyleSheet.create({
|
||||
navBarBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(233, 69, 96, 0.15)',
|
||||
borderBottomColor: colors.border,
|
||||
overflow: "hidden",
|
||||
backgroundColor: colors.background, // Flat
|
||||
},
|
||||
// ...
|
||||
|
||||
// ...
|
||||
premiumBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
// Removed heavy shadow
|
||||
borderWidth: 1,
|
||||
borderColor: colors.premium.gold,
|
||||
},
|
||||
// ...
|
||||
iconWrapper: {
|
||||
position: 'relative',
|
||||
marginBottom: spacing.md,
|
||||
// Removed glowing background elements
|
||||
},
|
||||
// ...
|
||||
iconContainer: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
iconContainerPremium: {
|
||||
borderColor: colors.premium.gold,
|
||||
borderWidth: 2,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
// Removed heavy shadows
|
||||
},
|
||||
// ...
|
||||
statsBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
questionCard: {
|
||||
backgroundColor: colors.backgroundLight, // Flat
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// ...
|
||||
partnerTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
backgroundColor: colors.background, // Nested flat container
|
||||
borderRadius: radius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
// ...
|
||||
tooltipContainer: {
|
||||
maxWidth: 320,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.backgroundLight, // Flat
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
backButton: {
|
||||
zIndex: 10,
|
||||
@@ -482,17 +564,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
// Premium Banner
|
||||
premiumBanner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
shadowColor: colors.premium.gold,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
|
||||
premiumBannerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -519,49 +591,32 @@ const styles = StyleSheet.create({
|
||||
labelPremium: {
|
||||
color: colors.premium.gold,
|
||||
},
|
||||
iconWrapper: {
|
||||
position: 'relative',
|
||||
explicitBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.error,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
explicitBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
// Removed glow styles
|
||||
iconGlowOuter: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: -20,
|
||||
right: -20,
|
||||
bottom: -20,
|
||||
borderRadius: 60,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.08)',
|
||||
display: 'none',
|
||||
},
|
||||
iconGlow: {
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: -10,
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.2)',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
iconContainerPremium: {
|
||||
borderColor: colors.premium.gold,
|
||||
borderWidth: 2,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.15)',
|
||||
shadowColor: colors.premium.gold,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
|
||||
crownBadge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
@@ -647,17 +702,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statsBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
|
||||
statsBadgePremium: {
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.1)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.3)',
|
||||
@@ -676,13 +721,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
questionCard: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.4)',
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
|
||||
questionContent: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
@@ -697,17 +736,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
lineHeight: 22,
|
||||
},
|
||||
partnerTextContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
backgroundColor: 'rgba(232, 164, 174, 0.1)',
|
||||
borderRadius: radius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
|
||||
partnerText: {
|
||||
...typography.caption1,
|
||||
color: colors.premium.rose,
|
||||
@@ -755,10 +784,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
tooltipContainer: {
|
||||
maxWidth: 320,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
tooltipTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
|
||||
Reference in new issue
Block a user