This commit is contained in:
Jamie Brunton committed 2025-12-28 21:56:20 +00:00
1 parent 6ff5bc62f2
commit adab288a3a
204 files changed
+11334 -14295

No files matched your search

+224 -65
View File
@@ -1,67 +1,184 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
import { usePacksStore } from "../../src/store";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect } from "react";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
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 { Paywall } from "../../src/components/Paywall";
import { colors, spacing, typography, radius } from "../../src/theme";
import type { QuestionPack, Category } from "../../src/types";
interface CategoryWithPacks extends Category {
packs: QuestionPack[];
}
export default function PacksScreen() {
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [showPaywall, setShowPaywall] = useState(false);
// Check if user or partner has premium access
const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
useEffect(() => {
fetchPacks();
}, []);
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
// Handle toggling a pack - show paywall if premium pack without access
const handleTogglePack = useCallback((pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
setShowPaywall(true);
return;
}
togglePack(pack.id);
}, [hasPremiumAccess, togglePack]);
const renderItem = ({ item, index }: { item: any; index: number }) => {
const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id));
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 entering={FadeInRight.delay(index * 50).duration(300)}>
<GlassCard style={styles.card}>
<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.cardContent}
style={styles.packContent}
activeOpacity={0.7}
onPress={() => router.push(`/pack/${item.id}`)}
onPress={() => {
if (isPremiumLocked) {
setShowPaywall(true);
} else {
router.push(`/pack/${item.id}`);
}
}}
>
<View style={[styles.iconContainer, isEnabled && styles.iconContainerActive]}>
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
<View style={[
styles.packIcon,
isEnabled && styles.packIconActive,
isPremiumLocked && styles.packIconLocked
]}>
<Text style={styles.packEmoji}>{item.icon || "📦"}</Text>
</View>
<View style={styles.content}>
<View style={styles.headerRow}>
<Text style={styles.name}>{item.name}</Text>
<View style={styles.packInfo}>
<View style={styles.packHeader}>
<Text style={[
styles.packName,
isPremiumLocked && styles.packNameLocked
]}>
{item.name}
</Text>
{item.is_premium && (
<View style={styles.badge}>
<Ionicons name="star" size={10} color={colors.text} />
<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.description} numberOfLines={2}>
{item.description}
<Text style={[
styles.packDescription,
isPremiumLocked && styles.packDescriptionLocked
]} numberOfLines={1}>
{isPremiumLocked ? "Upgrade to unlock" : item.description}
</Text>
{item.question_count && (
<Text style={styles.questionCount}>
{item.question_count} questions
</Text>
)}
</View>
</TouchableOpacity>
<View style={styles.switchContainer}>
<Switch
value={isEnabled}
onValueChange={() => togglePack(item.id)}
trackColor={{
false: colors.glass.background,
true: colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor={colors.glass.background}
<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}
/>
</View>
</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>
);
@@ -76,13 +193,13 @@ export default function PacksScreen() {
>
<Text style={styles.title}>Question Packs</Text>
<Text style={styles.subtitle}>
{enabledCount} of {packs.length} packs enabled
{totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled
</Text>
</Animated.View>
<FlatList
data={packs}
renderItem={renderItem}
data={categoriesWithPacks}
renderItem={renderCategory}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
@@ -109,6 +226,15 @@ export default function PacksScreen() {
</Animated.View>
}
/>
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
// Refresh packs after successful purchase
fetchPacks();
}}
/>
</View>
</GradientBackground>
);
@@ -136,45 +262,88 @@ const styles = StyleSheet.create({
padding: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
card: {
categoryCard: {
marginBottom: spacing.md,
},
categoryHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.sm,
},
cardContent: {
categoryIcon: {
fontSize: 32,
marginRight: spacing.md,
},
categoryInfo: {
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",
},
iconContainer: {
width: 52,
height: 52,
borderRadius: 26,
packIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
iconContainerActive: {
packIconActive: {
backgroundColor: colors.primaryLight,
},
emoji: {
fontSize: 26,
packIconLocked: {
opacity: 0.5,
},
content: {
packEmoji: {
fontSize: 22,
},
packInfo: {
flex: 1,
marginRight: spacing.sm,
},
headerRow: {
packHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
marginBottom: 2,
},
name: {
packName: {
...typography.headline,
color: colors.text,
marginRight: spacing.sm,
},
packNameLocked: {
color: colors.textSecondary,
},
packDescription: {
...typography.caption1,
color: colors.textSecondary,
},
packDescriptionLocked: {
color: colors.textTertiary,
fontStyle: "italic",
},
badge: {
flexDirection: "row",
alignItems: "center",
@@ -184,24 +353,14 @@ const styles = StyleSheet.create({
borderRadius: radius.xs,
gap: 2,
},
badgeLocked: {
backgroundColor: colors.glass.backgroundLight,
},
badgeText: {
...typography.caption2,
color: colors.text,
fontWeight: "700",
},
description: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
questionCount: {
...typography.caption1,
color: colors.textTertiary,
marginTop: spacing.xs,
},
switchContainer: {
paddingLeft: spacing.sm,
},
emptyContainer: {
alignItems: "center",
paddingVertical: spacing.xxl,