mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Update
This commit is contained in:
1 parent
71667d6104
commit
f46f0d59df
24 files changed
+2647
-516
No files matched your search
+137
-267
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user