Files
Sauci/apps/mobile/src/components/PackTeaser.tsx
T
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00

504 lines
17 KiB
TypeScript

import React, { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
ActivityIndicator,
ScrollView,
Platform,
Pressable,
} from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { appDataApi } from "../lib/appDataApi";
import { getPackIconName } from "../lib/packIcons";
import { colors, gradients, spacing, radius, typography } from "../theme";
interface PackTeaserProps {
visible: boolean;
packId: string;
packName: string;
packIcon: string | null;
onClose: () => void;
onUnlock: () => void;
}
interface PreviewQuestion {
id: string;
text: string;
intensity: number;
}
const TEASER_QUESTION_COUNT = 3;
export function PackTeaser({
visible,
packId,
packName,
packIcon,
onClose,
onUnlock,
}: PackTeaserProps) {
const [questions, setQuestions] = useState<PreviewQuestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const overlayOpacity = useSharedValue(0);
const contentTranslateY = useSharedValue(300);
const overlayStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: contentTranslateY.value }],
}));
useEffect(() => {
if (visible) {
setModalVisible(true);
overlayOpacity.value = withTiming(1, { duration: 250 });
contentTranslateY.value = withTiming(0, { duration: 300 });
} else {
overlayOpacity.value = withTiming(0, { duration: 200 });
contentTranslateY.value = withTiming(300, { duration: 250 }, () => {
runOnJS(setModalVisible)(false);
});
}
}, [visible]);
useEffect(() => {
if (visible && packId) {
fetchQuestions();
}
}, [visible, packId]);
const fetchQuestions = async () => {
setIsLoading(true);
try {
const { questions: data } = await appDataApi.packTeaser(packId);
// Take only the first 3 questions for the teaser
const previewQuestions = (data || [])
.slice(0, TEASER_QUESTION_COUNT)
.map((q: any) => ({
id: q.id,
text: q.text,
intensity: q.intensity,
}));
setQuestions(previewQuestions);
} catch (error) {
console.error("Failed to fetch teaser questions:", error);
} finally {
setIsLoading(false);
}
};
return (
<Modal
visible={modalVisible}
transparent
animationType="none"
onRequestClose={onClose}
>
<View style={styles.container}>
<Animated.View style={[styles.overlay, overlayStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.content, contentStyle]}>
{/* Premium gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
{/* Header */}
<View style={styles.header}>
{/* Premium label */}
<Text style={styles.label}>PREMIUM COLLECTION</Text>
{/* Icon with glow */}
<View style={styles.iconWrapper}>
<View style={styles.iconContainer}>
<LinearGradient
colors={['rgba(212, 175, 55, 0.15)', 'rgba(184, 134, 11, 0.1)']}
style={StyleSheet.absoluteFill}
/>
<Ionicons
name={getPackIconName(packIcon)}
size={36}
color={colors.premium.gold}
/>
</View>
{/* Crown badge */}
<View style={styles.crownBadge}>
<Ionicons name="star" size={12} color={colors.premium.gold} />
</View>
</View>
<Text style={styles.title}>{packName}</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Preview a few questions from this exclusive pack
</Text>
</View>
{/* Questions Preview */}
<View style={styles.questionsContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.premium.gold} size="large" />
<Text style={styles.loadingText}>
Loading preview...
</Text>
</View>
) : questions.length > 0 ? (
questions.map((question, index) => (
<View key={question.id} style={styles.questionCard}>
<View style={styles.questionHeader}>
<View style={styles.intensityContainer}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={12}
color={i < question.intensity ? colors.premium.gold : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
<Text style={styles.questionNumber}>
{index + 1} of {questions.length}
</Text>
</View>
<Text style={styles.questionText}>{question.text}</Text>
</View>
))
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No preview available
</Text>
</View>
)}
</View>
{/* More questions hint */}
{questions.length > 0 && (
<View style={styles.moreHint}>
<Ionicons
name="diamond"
size={16}
color={colors.premium.gold}
/>
<Text style={styles.moreHintText}>
+ many more exclusive questions to unlock
</Text>
</View>
)}
{/* Unlock Button */}
<View style={styles.actions}>
<Pressable
style={styles.unlockButton}
onPress={onUnlock}
>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.unlockButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons
name="diamond"
size={18}
color={colors.background}
style={{ marginRight: spacing.sm }}
/>
<Text style={styles.unlockButtonText}>
Unlock with Pro
</Text>
</LinearGradient>
</Pressable>
</View>
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "flex-end",
},
overlay: {
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0, 0, 0, 0.85)",
},
content: {
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "85%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: 'rgba(212, 175, 55, 0.2)',
overflow: 'hidden',
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 150,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
},
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: {
padding: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: Platform.OS === "ios" ? 40 : spacing.lg,
},
closeButton: {
position: "absolute",
top: spacing.md,
right: spacing.md,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconWrapper: {
position: 'relative',
marginBottom: spacing.md,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: "center",
alignItems: "center",
overflow: 'hidden',
borderWidth: 2,
borderColor: colors.premium.gold,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
},
crownBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.premium.gold,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
elevation: 4,
},
title: {
...typography.title1,
color: colors.text,
marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
questionsContainer: {
marginBottom: spacing.lg,
gap: spacing.md,
},
loadingContainer: {
alignItems: "center",
padding: spacing.xl,
},
loadingText: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.md,
},
questionCard: {
backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
padding: spacing.md,
},
questionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.sm,
},
intensityContainer: {
flexDirection: "row",
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
gap: 2,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
questionNumber: {
...typography.caption2,
color: colors.premium.champagne,
opacity: 0.7,
},
questionText: {
...typography.body,
color: colors.text,
lineHeight: 24,
},
emptyContainer: {
alignItems: "center",
padding: spacing.xl,
},
emptyText: {
...typography.subhead,
color: colors.textSecondary,
},
moreHint: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
gap: spacing.sm,
backgroundColor: 'rgba(212, 175, 55, 0.08)',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
alignSelf: 'center',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
moreHintText: {
...typography.caption1,
color: colors.premium.champagne,
fontWeight: '500',
},
actions: {
marginBottom: spacing.md,
},
unlockButton: {
borderRadius: radius.lg,
overflow: "hidden",
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
unlockButtonGradient: {
flexDirection: "row",
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
},
unlockButtonText: {
...typography.headline,
color: colors.background,
fontWeight: '700',
},
});
export default PackTeaser;