This commit is contained in:
Jamie Brunton committed 2025-12-31 10:40:17 +00:00
1 parent 71667d6104
commit f46f0d59df
24 files changed
+2647 -516

No files matched your search

+33 -3
View File
@@ -1,6 +1,14 @@
import { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
import Animated, {
FadeInDown,
FadeInRight,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
} from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
@@ -25,6 +33,28 @@ export default function HomeScreen() {
const isWideScreen = width > MAX_CONTENT_WIDTH;
const enabledPacksCount = packs.length;
// Subtle pulsating animation for the CTA icon
const pulseScale = useSharedValue(1);
useEffect(() => {
const startPulse = () => {
pulseScale.value = 1;
pulseScale.value = withRepeat(
withTiming(1.1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
-1,
true
);
};
const timeout = setTimeout(startPulse, 100);
return () => clearTimeout(timeout);
}, []);
const pulseAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: pulseScale.value }],
};
});
return (
<GradientBackground>
<ScrollView
@@ -191,9 +221,9 @@ export default function HomeScreen() {
Swipe through questions and discover what you both enjoy
</Text>
</View>
<View style={styles.ctaIconContainer}>
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
<Ionicons name="flame" size={24} color={colors.text} />
</View>
</Animated.View>
</LinearGradient>
</TouchableOpacity>
</Animated.View>
+29 -1
View File
@@ -1,6 +1,6 @@
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
import { useMatchStore, useAuthStore } from "../../src/store";
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
@@ -8,6 +8,8 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import MatchesTutorial from "../../src/components/MatchesTutorial";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
const MAX_CONTENT_WIDTH = 500;
@@ -17,6 +19,27 @@ export default function MatchesScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
// Check if tutorial should be shown when screen is focused or matches change
useFocusEffect(
useCallback(() => {
const checkTutorial = async () => {
if (matches.length > 0) {
const seen = await hasSeenMatchesTutorial();
if (!seen) {
setShowTutorial(true);
}
}
};
checkTutorial();
}, [matches.length])
);
const handleTutorialComplete = async () => {
await markMatchesTutorialSeen();
setShowTutorial(false);
};
useFocusEffect(
useCallback(() => {
@@ -187,6 +210,11 @@ export default function MatchesScreen() {
}
/>
</View>
{/* Matches Tutorial Overlay */}
{showTutorial && (
<MatchesTutorial onComplete={handleTutorialComplete} />
)}
</GradientBackground>
);
}
-1
View File
@@ -313,7 +313,6 @@ export default function OnboardingScreen() {
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>I'm using Sauci to...</Text>
<View style={styles.purposeList}>
{PURPOSE_OPTIONS.map((option) => (
<Pressable
+137 -267
View File
@@ -1,24 +1,23 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform, Alert } from "react-native";
import { View, Text, StyleSheet, ScrollView, RefreshControl, Platform, Alert } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useMemo, useState, useCallback } from "react";
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import Animated, { FadeInDown, FadeIn, Layout } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { Paywall } from "../../src/components/Paywall";
import { PackTeaser } from "../../src/components/PackTeaser";
import { colors, spacing, typography, radius } from "../../src/theme";
import type { QuestionPack, Category } from "../../src/types";
interface CategoryWithPacks extends Category {
packs: QuestionPack[];
}
import { CategoryTabs, FilterType } from "../../src/components/CategoryTabs";
import { BoutiquePackCard } from "../../src/components/BoutiquePackCard";
import { colors, spacing, typography } from "../../src/theme";
import type { QuestionPack } from "../../src/types";
export default function PacksScreen() {
const { category } = useLocalSearchParams<{ category?: string }>();
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [selectedFilter, setSelectedFilter] = useState<FilterType>('all');
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
@@ -32,6 +31,47 @@ export default function PacksScreen() {
fetchPacks();
}, []);
// Apply category filter from URL param
useEffect(() => {
if (category) {
setSelectedFilter(category);
}
}, [category]);
// Filter packs by selected filter (all, enabled, or category id)
const filteredPacks = useMemo(() => {
if (selectedFilter === 'all') return packs;
if (selectedFilter === 'enabled') return packs.filter(p => enabledPackIds.includes(p.id));
// Otherwise it's a category id
return packs.filter(p => p.category_id === selectedFilter);
}, [packs, selectedFilter, enabledPackIds]);
// Split packs into two columns for masonry layout
const { leftColumn, rightColumn } = useMemo(() => {
const left: { pack: QuestionPack; index: number }[] = [];
const right: { pack: QuestionPack; index: number }[] = [];
let leftHeight = 0;
let rightHeight = 0;
filteredPacks.forEach((pack, index) => {
// Estimate card height based on description length
const baseHeight = 180;
const descLength = pack.description?.length || 0;
const extraHeight = Math.min(Math.ceil(descLength / 40) * 10, 40);
const cardHeight = baseHeight + extraHeight;
if (leftHeight <= rightHeight) {
left.push({ pack, index });
leftHeight += cardHeight;
} else {
right.push({ pack, index });
rightHeight += cardHeight;
}
});
return { leftColumn: left, rightColumn: right };
}, [filteredPacks]);
// Handle toggling a pack - show teaser if premium pack without access
const handleTogglePack = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
@@ -52,6 +92,17 @@ export default function PacksScreen() {
}
}, [hasPremiumAccess, togglePack]);
// Handle pack press - navigate or show teaser
const handlePackPress = useCallback((pack: QuestionPack) => {
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
if (isPremiumLocked) {
setTeaserPack(pack);
setShowTeaser(true);
} else {
router.push(`/pack/${pack.id}`);
}
}, [hasPremiumAccess]);
// Handle teaser unlock button - show paywall
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
@@ -68,155 +119,10 @@ export default function PacksScreen() {
const enabledPackCount = enabledPacks.length;
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
// Group packs by category
const categoriesWithPacks = useMemo((): CategoryWithPacks[] => {
return categories
.map(category => ({
...category,
packs: packs.filter(p => p.category_id === category.id)
}))
.filter(cat => cat.packs.length > 0);
}, [packs, categories]);
const toggleCategory = useCallback((categoryId: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(categoryId)) {
next.delete(categoryId);
} else {
next.add(categoryId);
}
return next;
});
}, []);
const renderPack = (item: QuestionPack, index: number) => {
const isEnabled = enabledPackIds.includes(item.id);
const isPremiumLocked = item.is_premium && !hasPremiumAccess;
return (
<Animated.View
key={item.id}
entering={FadeIn.delay(index * 50).duration(200)}
exiting={FadeOut.duration(150)}
layout={Layout.springify()}
>
<View style={styles.packItem}>
<TouchableOpacity
style={styles.packContent}
activeOpacity={0.7}
onPress={() => {
if (isPremiumLocked) {
setTeaserPack(item);
setShowTeaser(true);
} else {
router.push(`/pack/${item.id}`);
}
}}
>
<View style={[
styles.packIcon,
isEnabled && styles.packIconActive,
isPremiumLocked && styles.packIconLocked
]}>
<Text style={styles.packEmoji}>{item.icon || "📦"}</Text>
</View>
<View style={styles.packInfo}>
<View style={styles.packHeader}>
<Text
style={[
styles.packName,
isPremiumLocked && styles.packNameLocked
]}
numberOfLines={1}
>
{item.name}
</Text>
{item.is_premium && (
<View style={[
styles.badge,
isPremiumLocked && styles.badgeLocked
]}>
<Ionicons
name={isPremiumLocked ? "lock-closed" : "star"}
size={10}
color={colors.text}
/>
<Text style={styles.badgeText}>PRO</Text>
</View>
)}
</View>
<Text style={[
styles.packDescription,
isPremiumLocked && styles.packDescriptionLocked
]} numberOfLines={1}>
{isPremiumLocked ? "Upgrade to unlock" : item.description}
</Text>
</View>
</TouchableOpacity>
<Switch
value={isEnabled}
onValueChange={() => handleTogglePack(item)}
disabled={isPremiumLocked}
trackColor={{
false: colors.glass.background,
true: colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor={colors.glass.background}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</Animated.View>
);
};
const renderCategory = ({ item: category, index }: { item: CategoryWithPacks; index: number }) => {
const isExpanded = expandedCategories.has(category.id);
const enabledInCategory = category.packs.filter(p => enabledPackIds.includes(p.id)).length;
return (
<Animated.View
entering={FadeInDown.delay(index * 100).duration(300)}
layout={Layout.springify()}
>
<GlassCard style={styles.categoryCard}>
<TouchableOpacity
style={styles.categoryHeader}
activeOpacity={0.7}
onPress={() => toggleCategory(category.id)}
>
<Text style={styles.categoryIcon}>{category.icon}</Text>
<View style={styles.categoryInfo}>
<Text style={styles.categoryName}>{category.name}</Text>
<Text style={styles.categoryMeta}>
{category.packs.length} packs · {enabledInCategory} enabled
</Text>
</View>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
{isExpanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packsContainer}
>
{category.packs.map((pack, idx) => renderPack(pack, idx))}
</Animated.View>
)}
</GlassCard>
</Animated.View>
);
};
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.header}
@@ -227,11 +133,18 @@ export default function PacksScreen() {
</Text>
</Animated.View>
<FlatList
data={categoriesWithPacks}
renderItem={renderCategory}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
{/* Category Tabs */}
<Animated.View entering={FadeInDown.delay(100).duration(400)}>
<CategoryTabs
categories={categories}
selectedFilter={selectedFilter}
onSelectFilter={setSelectedFilter}
enabledCount={enabledPackIds.length}
/>
</Animated.View>
{/* Masonry Grid */}
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
@@ -241,7 +154,9 @@ export default function PacksScreen() {
colors={[colors.primary]}
/>
}
ListEmptyComponent={
contentContainerStyle={styles.scrollContent}
>
{filteredPacks.length === 0 ? (
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
@@ -251,12 +166,59 @@ export default function PacksScreen() {
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptyText}>
Check back later for new question packs!
{selectedFilter === 'enabled'
? "No packs enabled yet. Enable some packs to see them here!"
: selectedFilter !== 'all'
? "No packs in this category. Try selecting a different one."
: "Check back later for new question packs!"}
</Text>
</Animated.View>
}
/>
) : (
<View style={styles.masonryContainer}>
{/* Left Column */}
<View style={styles.column}>
{leftColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
{/* Right Column */}
<View style={styles.column}>
{rightColumn.map(({ pack, index }, columnIndex) => (
<Animated.View
key={pack.id}
entering={FadeInDown.delay(columnIndex * 80 + 40).duration(400)}
layout={Layout.springify()}
>
<BoutiquePackCard
pack={pack}
index={index}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
onPress={() => handlePackPress(pack)}
onToggle={() => handleTogglePack(pack)}
/>
</Animated.View>
))}
</View>
</View>
)}
</ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
@@ -288,7 +250,7 @@ const styles = StyleSheet.create({
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
paddingBottom: spacing.sm,
},
title: {
...typography.title1,
@@ -299,114 +261,22 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: spacing.xs,
},
list: {
padding: spacing.lg,
scrollContent: {
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
categoryCard: {
marginBottom: spacing.md,
masonryContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
gap: spacing.md,
},
categoryHeader: {
flexDirection: "row",
alignItems: "center",
},
categoryIcon: {
fontSize: 32,
marginRight: spacing.md,
},
categoryInfo: {
column: {
flex: 1,
},
categoryName: {
...typography.title3,
color: colors.text,
},
categoryMeta: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
packsContainer: {
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
},
packItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.sm,
},
packContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
},
packIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
packIconActive: {
backgroundColor: colors.primaryLight,
},
packIconLocked: {
opacity: 0.5,
},
packEmoji: {
fontSize: 22,
},
packInfo: {
flex: 1,
marginRight: spacing.md,
},
packHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 2,
},
packName: {
...typography.headline,
color: colors.text,
marginRight: spacing.sm,
flex: 1,
flexShrink: 1,
},
packNameLocked: {
color: colors.textSecondary,
},
packDescription: {
...typography.caption1,
color: colors.textSecondary,
},
packDescriptionLocked: {
color: colors.textTertiary,
fontStyle: "italic",
},
badge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.primary,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
badgeLocked: {
backgroundColor: colors.glass.backgroundLight,
},
badgeText: {
...typography.caption2,
color: colors.text,
fontWeight: "700",
gap: spacing.md,
},
emptyContainer: {
alignItems: "center",
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
},
emptyIconContainer: {
width: 80,
+55
View File
@@ -6,6 +6,7 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { supabase } from "../../src/lib/supabase";
import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen";
import { resetMatchesTutorial } from "../../src/lib/matchesTutorialSeen";
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
@@ -821,6 +822,60 @@ export default function ProfileScreen() {
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
<View style={styles.preferencesDivider} />
<TouchableOpacity
style={styles.rowContainer}
onPress={async () => {
await resetMatchesTutorial();
Alert.alert("Success", "Matches tutorial has been reset. It will show again next time you visit the matches screen.");
}}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.debugIcon}>
<Ionicons name="heart" size={20} color={colors.warning} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Reset Matches Tutorial</Text>
<Text style={styles.rowLabel}>Show the tutorial again</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
<View style={styles.preferencesDivider} />
<TouchableOpacity
style={styles.rowContainer}
onPress={async () => {
if (!user?.id) return;
try {
const { error } = await supabase
.from('profiles')
.update({ onboarding_completed: false })
.eq('id', user.id);
if (error) throw error;
await fetchUser();
Alert.alert("Success", "Onboarding has been reset. You will see the onboarding flow on next app launch.");
} catch (error) {
Alert.alert("Error", "Failed to reset onboarding.");
}
}}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.debugIcon}>
<Ionicons name="school-outline" size={20} color={colors.warning} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Reset Onboarding</Text>
<Text style={styles.rowLabel}>Show onboarding flow again</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
</GlassCard>
</Animated.View>
)}