This commit is contained in:
Jamie Brunton committed 2025-12-29 21:03:25 +00:00
1 parent d1ca114285
commit 8acc43981e
22 files changed
+1731 -97

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
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 { supabase } from "../lib/supabase";
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 { data, error } = await supabase.rpc("get_pack_teaser_questions", {
target_pack_id: packId,
});
if (error) {
console.error("Error fetching teaser questions:", error);
return;
}
// 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]}>
<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}>
<View style={styles.iconContainer}>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
</View>
<Text style={styles.title}>{packName}</Text>
<Text style={styles.subtitle}>
Preview a few questions from this pack
</Text>
</View>
{/* Questions Preview */}
<View style={styles.questionsContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} 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(question.intensity)].map((_, i) => (
<Ionicons
key={i}
name="flame"
size={12}
color={colors.primary}
/>
))}
</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="lock-closed"
size={16}
color={colors.textTertiary}
/>
<Text style={styles.moreHintText}>
+ many more questions to unlock
</Text>
</View>
)}
{/* Unlock Button */}
<View style={styles.actions}>
<TouchableOpacity
style={styles.unlockButton}
onPress={onUnlock}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.unlockButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons
name="star"
size={20}
color={colors.text}
style={{ marginRight: spacing.sm }}
/>
<Text style={styles.unlockButtonText}>
Unlock with Pro
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "flex-end",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
content: {
backgroundColor: colors.backgroundLight,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "85%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
},
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: colors.glass.background,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
packEmoji: {
fontSize: 36,
},
title: {
...typography.title1,
color: colors.text,
marginBottom: spacing.xs,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
questionsContainer: {
marginBottom: spacing.lg,
gap: spacing.md,
},
loadingContainer: {
alignItems: "center",
padding: spacing.xl,
},
loadingText: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.md,
},
questionCard: {
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
},
questionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.sm,
},
intensityContainer: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
gap: 1,
},
questionNumber: {
...typography.caption2,
color: colors.textTertiary,
},
questionText: {
...typography.body,
color: colors.text,
lineHeight: 22,
},
emptyContainer: {
alignItems: "center",
padding: spacing.xl,
},
emptyText: {
...typography.subhead,
color: colors.textSecondary,
},
moreHint: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
gap: spacing.xs,
},
moreHintText: {
...typography.caption1,
color: colors.textTertiary,
},
actions: {
marginBottom: spacing.md,
},
unlockButton: {
borderRadius: radius.lg,
overflow: "hidden",
},
unlockButtonGradient: {
flexDirection: "row",
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
},
unlockButtonText: {
...typography.headline,
color: colors.text,
},
});
export default PackTeaser;
+5
View File
@@ -235,6 +235,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<Text style={styles.errorText}>
Unable to load subscription options.
</Text>
{error && (
<Text style={[styles.errorText, { fontSize: 12, marginTop: 4 }]}>
{error}
</Text>
)}
<TouchableOpacity
style={styles.retryButton}
onPress={fetchOfferings}
@@ -34,7 +34,7 @@ const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
onSwipe: (direction: "left" | "right" | "up") => void;
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
@@ -83,6 +83,10 @@ export default function SwipeCard({ question, onSwipe }: Props) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
@@ -99,7 +103,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
],
}));
const overlayStyle = (direction: "left" | "right" | "up") =>
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
@@ -118,13 +122,20 @@ export default function SwipeCard({ question, onSwipe }: Props) {
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else {
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(108, 117, 125, 0.4)']
) as string;
}
return {
@@ -193,7 +204,7 @@ function CardContent({
onFeedbackPress,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up") => any;
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void;
}) {
@@ -234,6 +245,11 @@ function CardContent({
<Text style={styles.overlayText}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.textTertiary }]}>
<Text style={styles.overlayText}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
+24 -1
View File
@@ -8,7 +8,7 @@ const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null };
onSwipe: (direction: "left" | "right" | "up") => void;
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
/**
@@ -51,6 +51,12 @@ export default function SwipeCard({ question, onSwipe }: Props) {
extrapolate: "clamp",
});
const downOverlayOpacity = translateY.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
@@ -104,6 +110,15 @@ export default function SwipeCard({ question, onSwipe }: Props) {
setDismissed(true);
onSwipe("up");
});
} else if (gestureState.dy > SWIPE_THRESHOLD) {
// Vertical swipe down - skip
Animated.spring(translateY, {
toValue: screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("down");
});
} else {
// Reset position
Animated.parallel([
@@ -176,6 +191,14 @@ export default function SwipeCard({ question, onSwipe }: Props) {
>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
]}
>
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
+40 -14
View File
@@ -73,18 +73,21 @@ class RevenueCatService {
return;
}
const available = await this.loadPurchases();
if (!available) return;
if (!REVENUECAT_IOS_API_KEY) {
console.warn("RevenueCat: Missing iOS API key");
// Skip if API key is missing or placeholder
if (!REVENUECAT_IOS_API_KEY || REVENUECAT_IOS_API_KEY === "your_revenuecat_ios_api_key") {
console.log("RevenueCat: Skipping - no valid API key configured");
return;
}
const available = await this.loadPurchases();
if (!available) return;
try {
if (__DEV__) {
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
}
// Always enable debug logging for now to diagnose issues
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
const keyPrefix = REVENUECAT_IOS_API_KEY.substring(0, 10);
console.log("RevenueCat: Configuring with API key prefix:", keyPrefix + "...");
await this.Purchases.configure({
apiKey: REVENUECAT_IOS_API_KEY,
@@ -92,9 +95,10 @@ class RevenueCatService {
});
this.initialized = true;
console.log("RevenueCat: Initialized successfully");
console.log("RevenueCat: Initialized successfully with user:", userId || "anonymous");
} catch (error) {
console.error("RevenueCat: Failed to initialize:", error);
// Don't rethrow - allow app to continue without subscriptions
}
}
@@ -129,16 +133,38 @@ class RevenueCatService {
}
async getOfferings(): Promise<PurchasesOffering | null> {
const result = await this.getOfferingsDebug();
return result.current;
}
async getOfferingsDebug(): Promise<{
current: PurchasesOffering | null;
availableOfferings: string[] | null;
error: string | null;
}> {
if (!this.initialized || !this.Purchases) {
return null;
return {
current: null,
availableOfferings: null,
error: "RevenueCat not initialized"
};
}
try {
const offerings = await this.Purchases.getOfferings();
return offerings.current;
} catch (error) {
console.error("RevenueCat: Error fetching offerings:", error);
return null;
const availableOfferings = Object.keys(offerings.all || {});
return {
current: offerings.current,
availableOfferings,
error: null
};
} catch (error: any) {
return {
current: null,
availableOfferings: null,
error: error.message || "Failed to fetch offerings"
};
}
}
+75
View File
@@ -0,0 +1,75 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "skipped_questions";
const SKIP_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
interface SkippedQuestion {
id: string;
skippedAt: number;
}
async function getStorage(): Promise<SkippedQuestion[]> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
if (data) {
return JSON.parse(data);
}
} catch (error) {
console.error("Error reading skipped questions:", error);
}
return [];
}
async function setStorage(questions: SkippedQuestion[]): Promise<void> {
try {
const data = JSON.stringify(questions);
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) {
console.error("Error saving skipped questions:", error);
}
}
export async function skipQuestion(questionId: string): Promise<void> {
const skipped = await getStorage();
const now = Date.now();
// Remove old entries (older than SKIP_DURATION_MS)
const filtered = skipped.filter(q => now - q.skippedAt < SKIP_DURATION_MS);
// Add new skipped question (or update timestamp if already exists)
const existingIndex = filtered.findIndex(q => q.id === questionId);
if (existingIndex >= 0) {
filtered[existingIndex].skippedAt = now;
} else {
filtered.push({ id: questionId, skippedAt: now });
}
await setStorage(filtered);
}
export async function getSkippedQuestionIds(): Promise<Set<string>> {
const skipped = await getStorage();
const now = Date.now();
// Return only question IDs that are still within the skip duration
const validSkipped = skipped.filter(q => now - q.skippedAt < SKIP_DURATION_MS);
return new Set(validSkipped.map(q => q.id));
}
+55 -5
View File
@@ -256,6 +256,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
fetchPacks: async () => {
set({ isLoading: true });
// Get user's explicit content preference
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? false;
// Fetch categories
const { data: categories } = await supabase
.from("categories")
@@ -263,12 +266,19 @@ export const usePacksStore = create<PacksState>((set, get) => ({
.order("sort_order");
// Fetch packs with category info and question count
const { data: packs } = await supabase
let query = supabase
.from("question_packs")
.select("*, category:categories(*), questions(count)")
.eq("is_public", true)
.order("sort_order");
// Filter out explicit packs if user doesn't want to see them
if (!showExplicit) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query;
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
@@ -481,11 +491,51 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
fetchOfferings: async () => {
set({ isLoadingOfferings: true, error: null });
try {
const offerings = await revenueCatService.getOfferings();
set({ offerings, isLoadingOfferings: false });
} catch (error) {
// Check if RevenueCat is initialized
if (!revenueCatService.isInitialized()) {
set({
offerings: null,
isLoadingOfferings: false,
error: "Subscriptions not available"
});
return;
}
const result = await revenueCatService.getOfferingsDebug();
if (result.error) {
set({
offerings: null,
isLoadingOfferings: false,
error: result.error
});
return;
}
const offerings = result.current;
if (!offerings) {
set({
offerings: null,
isLoadingOfferings: false,
error: `No current offering. Available: ${result.availableOfferings?.join(", ") || "none"}`
});
return;
}
if (!offerings.availablePackages || offerings.availablePackages.length === 0) {
set({
offerings: null,
isLoadingOfferings: false,
error: "No packages in offering. Check RevenueCat dashboard."
});
return;
}
set({ offerings, isLoadingOfferings: false, error: null });
} catch (error: any) {
console.error("Error fetching offerings:", error);
set({ error: "Failed to load subscription options", isLoadingOfferings: false });
set({ error: `Error: ${error.message || "Unknown"}`, isLoadingOfferings: false });
}
},
+8 -2
View File
@@ -21,6 +21,10 @@ export const colors = {
primaryDark: '#c73a52',
primaryLight: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.3)',
secondary: '#9b59b6',
secondaryDark: '#8e44ad',
secondaryLight: 'rgba(155, 89, 182, 0.15)',
secondaryGlow: 'rgba(155, 89, 182, 0.3)',
// Text
text: '#ffffff',
@@ -37,8 +41,10 @@ export const colors = {
};
export const gradients = {
primary: ['#e94560', '#c73a52'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(199, 58, 82, 0.8)'],
primary: ['#e94560', '#9b59b6'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(155, 89, 182, 0.8)'],
primaryReverse: ['#9b59b6', '#e94560'],
primaryVertical: ['#e94560', '#9b59b6'],
background: ['#1a1a2e', '#0d0d1a'],
backgroundReverse: ['#0d0d1a', '#1a1a2e'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'],
+6
View File
@@ -1,4 +1,6 @@
// User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export interface Profile {
id: string;
name: string | null;
@@ -7,6 +9,9 @@ export interface Profile {
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
}
@@ -36,6 +41,7 @@ export interface QuestionPack {
icon: string | null;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
sort_order: number;
category_id: string | null;
category?: Category;