mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Update
This commit is contained in:
1 parent
6ff5bc62f2
commit
adab288a3a
204 files changed
+11334
-14295
No files matched your search
@@ -0,0 +1,533 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getDeviceInfo } from '../lib/deviceInfo';
|
||||
import { useAuthStore } from '../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../theme';
|
||||
import type { FeedbackType } from '../types';
|
||||
|
||||
interface FeedbackModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FEEDBACK_OPTIONS: { type: FeedbackType; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
|
||||
{ type: 'bug', label: 'Bug', icon: 'bug' },
|
||||
{ type: 'feature_request', label: 'Feature', icon: 'bulb' },
|
||||
{ type: 'general', label: 'General', icon: 'chatbubble-ellipses' },
|
||||
];
|
||||
|
||||
export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
|
||||
const { user } = useAuthStore();
|
||||
const [feedbackType, setFeedbackType] = useState<FeedbackType>('bug');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [screenshotUri, setScreenshotUri] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<{ title?: string; description?: string }>({});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const resetForm = () => {
|
||||
setFeedbackType('bug');
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setScreenshotUri(null);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickScreenshot = async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
quality: 0.7,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
setScreenshotUri(result.assets[0].uri);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadScreenshot = async (uri: string, userId: string): Promise<string> => {
|
||||
let fileBody;
|
||||
let ext = 'jpg';
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const response = await fetch(uri);
|
||||
const blob = await response.blob();
|
||||
fileBody = blob;
|
||||
|
||||
if (blob.type === 'image/png') ext = 'png';
|
||||
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
|
||||
else if (blob.type === 'image/gif') ext = 'gif';
|
||||
else if (blob.type === 'image/webp') ext = 'webp';
|
||||
} else {
|
||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
fileBody = decode(base64);
|
||||
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
}
|
||||
|
||||
const fileName = `${userId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('feedback-screenshots')
|
||||
.upload(fileName, fileBody, {
|
||||
contentType: `image/${ext === 'jpg' ? 'jpeg' : ext}`,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Get signed URL (private bucket)
|
||||
const { data } = await supabase.storage
|
||||
.from('feedback-screenshots')
|
||||
.createSignedUrl(fileName, 60 * 60 * 24 * 365); // 1 year expiry
|
||||
|
||||
return data?.signedUrl ?? '';
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate
|
||||
const newErrors: typeof errors = {};
|
||||
if (!title.trim()) newErrors.title = 'Title is required';
|
||||
if (!description.trim()) newErrors.description = 'Description is required';
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
Alert.alert('Error', 'You must be logged in to submit feedback.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
let screenshotUrl: string | null = null;
|
||||
|
||||
// Upload screenshot if provided
|
||||
if (screenshotUri) {
|
||||
screenshotUrl = await uploadScreenshot(screenshotUri, user.id);
|
||||
}
|
||||
|
||||
// Insert feedback
|
||||
const { error } = await supabase.from('feedback').insert({
|
||||
user_id: user.id,
|
||||
type: feedbackType,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
screenshot_url: screenshotUrl,
|
||||
device_info: deviceInfo,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
Alert.alert('Thank you!', 'Your feedback has been submitted successfully.', [
|
||||
{ text: 'OK', onPress: handleClose },
|
||||
]);
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOption = FEEDBACK_OPTIONS.find((o) => o.type === feedbackType)!;
|
||||
|
||||
return (
|
||||
<View style={styles.fullScreen}>
|
||||
<TouchableOpacity
|
||||
style={styles.backdrop}
|
||||
activeOpacity={1}
|
||||
onPress={handleClose}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.headerIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name={selectedOption.icon} size={28} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.headerTitle}>Send Feedback</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Help us improve Sauci
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Type Selector */}
|
||||
<View style={styles.typeSelector}>
|
||||
{FEEDBACK_OPTIONS.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.type}
|
||||
style={[
|
||||
styles.typeButton,
|
||||
feedbackType === option.type && styles.typeButtonActive,
|
||||
]}
|
||||
onPress={() => setFeedbackType(option.type)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={option.icon}
|
||||
size={18}
|
||||
color={
|
||||
feedbackType === option.type
|
||||
? colors.text
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
feedbackType === option.type && styles.typeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Title Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Title</Text>
|
||||
<TextInput
|
||||
style={[styles.input, errors.title && styles.inputError]}
|
||||
value={title}
|
||||
onChangeText={(text) => {
|
||||
setTitle(text);
|
||||
if (errors.title) setErrors((e) => ({ ...e, title: undefined }));
|
||||
}}
|
||||
placeholder="Brief summary of your feedback"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
{errors.title && <Text style={styles.errorText}>{errors.title}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Description Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Description</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
errors.description && styles.inputError,
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={(text) => {
|
||||
setDescription(text);
|
||||
if (errors.description)
|
||||
setErrors((e) => ({ ...e, description: undefined }));
|
||||
}}
|
||||
placeholder="Please provide details about the issue or suggestion..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
{errors.description && (
|
||||
<Text style={styles.errorText}>{errors.description}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Screenshot Picker */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Screenshot (optional)</Text>
|
||||
{screenshotUri ? (
|
||||
<View style={styles.screenshotPreview}>
|
||||
<Image
|
||||
source={{ uri: screenshotUri }}
|
||||
style={styles.screenshotImage}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeScreenshot}
|
||||
onPress={() => setScreenshotUri(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Ionicons name="close-circle" size={24} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.screenshotPicker}
|
||||
onPress={handlePickScreenshot}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
<Text style={styles.screenshotPickerText}>
|
||||
Tap to add a screenshot
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="send" size={16} color={colors.text} />
|
||||
<Text style={styles.submitButtonText}>Submit</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fullScreen: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.lg,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '80%',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSubtitle: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
typeSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
typeButtonActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
typeButtonText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
typeButtonTextActive: {
|
||||
color: colors.text,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
label: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: colors.error,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
errorText: {
|
||||
...typography.caption1,
|
||||
color: colors.error,
|
||||
marginTop: spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
screenshotPicker: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
borderStyle: 'dashed',
|
||||
padding: spacing.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
screenshotPickerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
screenshotPreview: {
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
screenshotImage: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.glass.background,
|
||||
},
|
||||
removeScreenshot: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: 12,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
cancelButtonText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
submitButtonText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useSubscriptionStore } from "../store";
|
||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||
import type { PurchasesPackage } from "../lib/revenuecat";
|
||||
|
||||
interface PaywallProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: "heart" as const,
|
||||
title: "Unlock All Packs",
|
||||
description: "Access every question pack",
|
||||
},
|
||||
{
|
||||
icon: "sparkles" as const,
|
||||
title: "Exclusive Content",
|
||||
description: "New packs added regularly",
|
||||
},
|
||||
{
|
||||
icon: "people" as const,
|
||||
title: "Share with Partner",
|
||||
description: "Both of you get Pro access",
|
||||
},
|
||||
];
|
||||
|
||||
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
const {
|
||||
offerings,
|
||||
isLoadingOfferings,
|
||||
isPurchasing,
|
||||
error,
|
||||
fetchOfferings,
|
||||
purchasePackage,
|
||||
restorePurchases,
|
||||
} = useSubscriptionStore();
|
||||
|
||||
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && !offerings) {
|
||||
fetchOfferings();
|
||||
}
|
||||
}, [visible, offerings, fetchOfferings]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-select annual package as default (better value)
|
||||
if (offerings?.availablePackages) {
|
||||
const annual = offerings.availablePackages.find(
|
||||
(p) => p.packageType === "ANNUAL"
|
||||
);
|
||||
setSelectedPackage(annual || offerings.availablePackages[0]);
|
||||
}
|
||||
}, [offerings]);
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedPackage) return;
|
||||
|
||||
const success = await purchasePackage(selectedPackage);
|
||||
if (success) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
const restored = await restorePurchases();
|
||||
if (restored) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (pkg: PurchasesPackage) => {
|
||||
const price = pkg.product.priceString;
|
||||
if (pkg.packageType === "ANNUAL") {
|
||||
const monthlyPrice = (pkg.product.price / 12).toFixed(2);
|
||||
return `${price}/year (${pkg.product.currencyCode} ${monthlyPrice}/mo)`;
|
||||
}
|
||||
return `${price}/month`;
|
||||
};
|
||||
|
||||
const calculateSavings = () => {
|
||||
if (!offerings?.availablePackages) return null;
|
||||
const monthly = offerings.availablePackages.find(
|
||||
(p) => p.packageType === "MONTHLY"
|
||||
);
|
||||
const annual = offerings.availablePackages.find(
|
||||
(p) => p.packageType === "ANNUAL"
|
||||
);
|
||||
|
||||
if (monthly && annual) {
|
||||
const yearlyFromMonthly = monthly.product.price * 12;
|
||||
const savings = Math.round(
|
||||
(1 - annual.product.price / yearlyFromMonthly) * 100
|
||||
);
|
||||
return savings;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const savings = calculateSavings();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.content}>
|
||||
<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}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="star" size={32} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.title}>Upgrade to Pro</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Unlock all premium content for you and your partner
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Features */}
|
||||
<View style={styles.features}>
|
||||
{FEATURES.map((feature) => (
|
||||
<View key={feature.title} style={styles.featureRow}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Ionicons
|
||||
name={feature.icon}
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.featureText}>
|
||||
<Text style={styles.featureTitle}>{feature.title}</Text>
|
||||
<Text style={styles.featureDescription}>
|
||||
{feature.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Packages */}
|
||||
<View style={styles.packages}>
|
||||
{isLoadingOfferings ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} size="large" />
|
||||
<Text style={styles.loadingText}>
|
||||
Loading subscription options...
|
||||
</Text>
|
||||
</View>
|
||||
) : offerings?.availablePackages ? (
|
||||
offerings.availablePackages.map((pkg) => {
|
||||
const isSelected =
|
||||
selectedPackage?.identifier === pkg.identifier;
|
||||
const isAnnual = pkg.packageType === "ANNUAL";
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={pkg.identifier}
|
||||
onPress={() => setSelectedPackage(pkg)}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.packageCard,
|
||||
isSelected && styles.packageCardSelected,
|
||||
]}
|
||||
>
|
||||
{isAnnual && savings && savings > 0 && (
|
||||
<View style={styles.savingsBadge}>
|
||||
<Text style={styles.savingsText}>
|
||||
SAVE {savings}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.packageContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.radioOuter,
|
||||
isSelected && styles.radioOuterSelected,
|
||||
]}
|
||||
>
|
||||
{isSelected && <View style={styles.radioInner} />}
|
||||
</View>
|
||||
<View style={styles.packageInfo}>
|
||||
<Text style={styles.packageTitle}>
|
||||
{isAnnual ? "Annual" : "Monthly"}
|
||||
</Text>
|
||||
<Text style={styles.packagePrice}>
|
||||
{formatPrice(pkg)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>
|
||||
Unable to load subscription options.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={fetchOfferings}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorBannerText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Purchase Button */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.purchaseButton,
|
||||
(!selectedPackage || isPurchasing) &&
|
||||
styles.purchaseButtonDisabled,
|
||||
]}
|
||||
onPress={handlePurchase}
|
||||
disabled={!selectedPackage || isPurchasing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isPurchasing ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.purchaseButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text style={styles.purchaseButtonText}>Subscribe Now</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.restoreButton}
|
||||
onPress={handleRestore}
|
||||
disabled={isPurchasing}
|
||||
>
|
||||
<Text style={styles.restoreText}>Restore Purchases</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Legal */}
|
||||
<View style={styles.legal}>
|
||||
<Text style={styles.legalText}>
|
||||
Payment will be charged to your Apple ID account at confirmation.
|
||||
Subscription automatically renews unless cancelled at least 24 hours
|
||||
before the end of the current period.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderTopLeftRadius: radius.xxl,
|
||||
borderTopRightRadius: radius.xxl,
|
||||
maxHeight: "90%",
|
||||
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,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
},
|
||||
features: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
featureIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primaryLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
featureText: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
featureDescription: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packages: {
|
||||
marginBottom: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: "center",
|
||||
padding: spacing.xl,
|
||||
},
|
||||
loadingText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
packageCard: {
|
||||
position: "relative",
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
overflow: "visible",
|
||||
},
|
||||
packageCardSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
savingsBadge: {
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
right: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
savingsText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
},
|
||||
packageContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
radioOuter: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.textSecondary,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
radioOuterSelected: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
radioInner: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
packageInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
packageTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
packagePrice: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
errorText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
retryButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
errorBanner: {
|
||||
backgroundColor: colors.errorLight,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
errorBannerText: {
|
||||
...typography.subhead,
|
||||
color: colors.error,
|
||||
textAlign: "center",
|
||||
},
|
||||
actions: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
purchaseButton: {
|
||||
borderRadius: radius.lg,
|
||||
overflow: "hidden",
|
||||
},
|
||||
purchaseButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
purchaseButtonGradient: {
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: "center",
|
||||
},
|
||||
purchaseButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
restoreButton: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
restoreText: {
|
||||
...typography.subhead,
|
||||
color: colors.primary,
|
||||
},
|
||||
legal: {
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
legalText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
textAlign: "center",
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default Paywall;
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getDeviceInfo } from '../lib/deviceInfo';
|
||||
import { useAuthStore } from '../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../theme';
|
||||
|
||||
interface QuestionFeedbackModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
questionId: string;
|
||||
questionText: string;
|
||||
}
|
||||
|
||||
const FEEDBACK_REASONS = [
|
||||
{ id: 'inappropriate', label: 'Inappropriate', icon: 'warning' as const },
|
||||
{ id: 'confusing', label: 'Confusing', icon: 'help-circle' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'other', label: 'Other', icon: 'ellipsis-horizontal' as const },
|
||||
];
|
||||
|
||||
export function QuestionFeedbackModal({
|
||||
visible,
|
||||
onClose,
|
||||
questionId,
|
||||
questionText,
|
||||
}: QuestionFeedbackModalProps) {
|
||||
const { user } = useAuthStore();
|
||||
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||
const [additionalInfo, setAdditionalInfo] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedReason(null);
|
||||
setAdditionalInfo('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedReason) {
|
||||
Alert.alert('Please select a reason', 'Choose why you want to report this question.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
Alert.alert('Error', 'You must be logged in to submit feedback.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
|
||||
|
||||
const { error } = await supabase.from('feedback').insert({
|
||||
user_id: user.id,
|
||||
type: 'question',
|
||||
title: `Question Feedback: ${reasonLabel}`,
|
||||
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
|
||||
question_id: questionId,
|
||||
device_info: deviceInfo,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
|
||||
{ text: 'OK', onPress: handleClose },
|
||||
]);
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.headerIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="flag" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.headerTitle}>Report Question</Text>
|
||||
</View>
|
||||
|
||||
{/* Question Preview */}
|
||||
<View style={styles.questionPreview}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
"{questionText}"
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Reason Selection */}
|
||||
<Text style={styles.label}>What's the issue?</Text>
|
||||
<View style={styles.reasonsGrid}>
|
||||
{FEEDBACK_REASONS.map((reason) => (
|
||||
<TouchableOpacity
|
||||
key={reason.id}
|
||||
style={[
|
||||
styles.reasonButton,
|
||||
selectedReason === reason.id && styles.reasonButtonActive,
|
||||
]}
|
||||
onPress={() => setSelectedReason(reason.id)}
|
||||
activeOpacity={0.7}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Ionicons
|
||||
name={reason.icon}
|
||||
size={20}
|
||||
color={
|
||||
selectedReason === reason.id
|
||||
? colors.text
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.reasonText,
|
||||
selectedReason === reason.id && styles.reasonTextActive,
|
||||
]}
|
||||
>
|
||||
{reason.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Text style={styles.label}>Additional details (optional)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={additionalInfo}
|
||||
onChangeText={setAdditionalInfo}
|
||||
placeholder="Tell us more..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
editable={!isSubmitting}
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleClose}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
!selectedReason && styles.submitButtonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting || !selectedReason}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="send" size={16} color={colors.text} />
|
||||
<Text style={styles.submitButtonText}>Submit</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.lg,
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
questionPreview: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
questionText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
},
|
||||
label: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
reasonsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
reasonButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
reasonButtonActive: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
reasonText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
reasonTextActive: {
|
||||
color: colors.text,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
minHeight: 80,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
cancelButtonText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
submitButtonText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default QuestionFeedbackModal;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text, StyleSheet, Dimensions, Platform } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { useState } from "react";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, {
|
||||
@@ -14,6 +15,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
import { QuestionFeedbackModal } from "./QuestionFeedbackModal";
|
||||
|
||||
// Haptics helper - not supported on web
|
||||
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
|
||||
@@ -27,8 +29,8 @@ const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') =>
|
||||
await Haptics.impactAsync(feedbackStyle);
|
||||
};
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
||||
const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
|
||||
@@ -38,6 +40,10 @@ interface Props {
|
||||
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
|
||||
|
||||
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const rotation = useSharedValue(0);
|
||||
@@ -57,7 +63,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
translateY.value = event.translationY + context.value.y;
|
||||
rotation.value = interpolate(
|
||||
translateX.value,
|
||||
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
||||
[-cardWidth / 2, cardWidth / 2],
|
||||
[-12, 12],
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
@@ -69,13 +75,13 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const direction = translateX.value > 0 ? "right" : "left";
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateX.value = withSpring(
|
||||
direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5,
|
||||
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
|
||||
animations.springGentle
|
||||
);
|
||||
runOnJS(onSwipe)(direction);
|
||||
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateY.value = withSpring(-SCREEN_WIDTH * 1.5, animations.springGentle);
|
||||
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
|
||||
runOnJS(onSwipe)("up");
|
||||
} else {
|
||||
translateX.value = withSpring(0, animations.spring);
|
||||
@@ -134,34 +140,49 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
|
||||
const useBlur = Platform.OS === 'ios';
|
||||
|
||||
const handleFeedbackPress = () => {
|
||||
setShowFeedbackModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.cardWrapper}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
|
||||
{useBlur ? (
|
||||
<BlurView
|
||||
intensity={blur.medium}
|
||||
tint="dark"
|
||||
style={styles.blurContainer}
|
||||
>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.fallbackContainer}>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
<>
|
||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
|
||||
{useBlur ? (
|
||||
<BlurView
|
||||
intensity={blur.medium}
|
||||
tint="dark"
|
||||
style={styles.blurContainer}
|
||||
>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.fallbackContainer}>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
<QuestionFeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
questionId={question.id}
|
||||
questionText={question.text}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,10 +190,12 @@ function CardContent({
|
||||
question,
|
||||
overlayStyle,
|
||||
handleButtonPress,
|
||||
onFeedbackPress,
|
||||
}: {
|
||||
question: Props['question'];
|
||||
overlayStyle: (direction: "left" | "right" | "up") => any;
|
||||
handleButtonPress: (direction: "left" | "right" | "up") => void;
|
||||
onFeedbackPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -182,20 +205,31 @@ function CardContent({
|
||||
style={styles.highlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Feedback Button */}
|
||||
<View style={styles.feedbackButton}>
|
||||
<Ionicons
|
||||
name="flag-outline"
|
||||
size={18}
|
||||
color={colors.textTertiary}
|
||||
onPress={onFeedbackPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Overlays */}
|
||||
<Animated.View style={[styles.overlay, overlayStyle("right")]}>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("left")]}>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("up")]}>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</View>
|
||||
@@ -250,7 +284,7 @@ function ActionButton({
|
||||
small?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Animated.View>
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<LinearGradient
|
||||
colors={buttonColors}
|
||||
style={[
|
||||
@@ -267,17 +301,16 @@ function ActionButton({
|
||||
size={small ? 20 : 28}
|
||||
color={colors.text}
|
||||
style={styles.buttonIcon}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cardWrapper: {
|
||||
position: "absolute",
|
||||
width: SCREEN_WIDTH - 48,
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
height: 480,
|
||||
},
|
||||
cardOuter: {
|
||||
@@ -309,6 +342,21 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { View, Text, StyleSheet, Dimensions, Animated, PanResponder, TouchableOpacity } from "react-native";
|
||||
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { QuestionFeedbackModal } from "./QuestionFeedbackModal";
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
||||
const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null };
|
||||
@@ -15,15 +16,19 @@ interface Props {
|
||||
* instead of react-native-gesture-handler which doesn't work on web.
|
||||
*/
|
||||
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(0)).current;
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const rotation = translateX.interpolate({
|
||||
inputRange: [-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
||||
inputRange: [-cardWidth / 2, cardWidth / 2],
|
||||
outputRange: ["-15deg", "15deg"],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
@@ -81,7 +86,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
|
||||
// Horizontal swipe
|
||||
const direction = gestureState.dx > 0 ? "right" : "left";
|
||||
const targetX = direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5;
|
||||
const targetX = direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5;
|
||||
|
||||
Animated.spring(translateX, {
|
||||
toValue: targetX,
|
||||
@@ -93,7 +98,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
|
||||
// Vertical swipe up
|
||||
Animated.spring(translateY, {
|
||||
toValue: -SCREEN_WIDTH * 1.5,
|
||||
toValue: -screenWidth * 1.5,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setDismissed(true);
|
||||
@@ -121,112 +126,131 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
transform: [
|
||||
{ translateX },
|
||||
{ translateY },
|
||||
{ rotate: rotation },
|
||||
{ scale },
|
||||
],
|
||||
},
|
||||
]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* Overlays */}
|
||||
<>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
|
||||
styles.card,
|
||||
{
|
||||
width: cardWidth,
|
||||
transform: [
|
||||
{ translateX },
|
||||
{ translateY },
|
||||
{ rotate: rotation },
|
||||
{ scale },
|
||||
],
|
||||
},
|
||||
]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</Animated.View>
|
||||
{/* Feedback Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.feedbackButton}
|
||||
onPress={() => setShowFeedbackModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="flag-outline" size={18} color="#888" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.intensityContainer}>
|
||||
{[...Array(question.intensity)].map((_, i) => (
|
||||
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||
))}
|
||||
{/* Overlays */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.intensityContainer}>
|
||||
{[...Array(question.intensity)].map((_, i) => (
|
||||
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
</View>
|
||||
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.noButton,
|
||||
hoveredButton === "no" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("left")}
|
||||
onMouseEnter={() => setHoveredButton("no")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.noButton,
|
||||
hoveredButton === "no" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("left")}
|
||||
onMouseEnter={() => setHoveredButton("no")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.maybeButton,
|
||||
hoveredButton === "maybe" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("up")}
|
||||
onMouseEnter={() => setHoveredButton("maybe")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.maybeButton,
|
||||
hoveredButton === "maybe" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("up")}
|
||||
onMouseEnter={() => setHoveredButton("maybe")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.yesButton,
|
||||
hoveredButton === "yes" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("right")}
|
||||
onMouseEnter={() => setHoveredButton("yes")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.yesButton,
|
||||
hoveredButton === "yes" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("right")}
|
||||
onMouseEnter={() => setHoveredButton("yes")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
<QuestionFeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
questionId={question.id}
|
||||
questionText={question.text}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
position: "absolute",
|
||||
width: SCREEN_WIDTH - 48,
|
||||
height: 500,
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
backgroundColor: "#16213e",
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
@@ -242,6 +266,20 @@ const styles = StyleSheet.create({
|
||||
overflow: "hidden",
|
||||
cursor: "grab",
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Platform, Dimensions } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import type { DeviceInfo } from '../types';
|
||||
|
||||
export function getDeviceInfo(): DeviceInfo {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
return {
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
osVersion: Platform.Version?.toString() ?? 'unknown',
|
||||
appVersion: Constants.expoConfig?.version ?? '1.0.0',
|
||||
buildNumber: Platform.select({
|
||||
ios: Constants.expoConfig?.ios?.buildNumber,
|
||||
android: Constants.expoConfig?.android?.versionCode?.toString(),
|
||||
default: undefined,
|
||||
}),
|
||||
deviceModel: Platform.select({
|
||||
ios: Constants.platform?.ios?.model,
|
||||
android: Constants.platform?.android?.model,
|
||||
default: undefined,
|
||||
}),
|
||||
screenWidth: width,
|
||||
screenHeight: height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as Notifications from "expo-notifications";
|
||||
import Constants from "expo-constants";
|
||||
import { Platform } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { supabase } from "./supabase";
|
||||
import { captureError } from "./sentry";
|
||||
|
||||
// Configure notification behavior for foreground
|
||||
// We don't show system alerts since the app handles notifications with in-app toasts
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Types
|
||||
export interface NotificationData {
|
||||
type: "match" | "message";
|
||||
match_id?: string;
|
||||
message_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions and get Expo push token.
|
||||
* Returns null if permissions denied, on web, or if registration fails.
|
||||
*/
|
||||
export async function registerForPushNotificationsAsync(): Promise<string | null> {
|
||||
// Push notifications don't work on web
|
||||
if (Platform.OS === "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check current permission status
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
// Request permission if not already granted
|
||||
if (existingStatus !== "granted") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
console.log("Push notification permission denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get Expo push token
|
||||
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
||||
if (!projectId) {
|
||||
console.warn("EAS project ID not configured - push notifications disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||
return tokenData.data;
|
||||
} catch (error) {
|
||||
// This typically fails in simulator or development
|
||||
console.error("Failed to get push token:", error);
|
||||
captureError(error as Error, { context: "push_token_registration" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save push token to user profile in Supabase.
|
||||
*/
|
||||
export async function savePushToken(userId: string, token: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ push_token: token })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to save push token:", error);
|
||||
captureError(new Error("Failed to save push token"), { userId, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear push token from user profile on sign out.
|
||||
*/
|
||||
export async function clearPushToken(userId: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ push_token: null })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to clear push token:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification tap - navigate to appropriate screen.
|
||||
*/
|
||||
export function handleNotificationResponse(
|
||||
response: Notifications.NotificationResponse
|
||||
): void {
|
||||
const data = response.notification.request.content.data as NotificationData;
|
||||
|
||||
if (!data?.match_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Both match and message notifications navigate to chat
|
||||
router.push(`/(app)/chat/${data.match_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listener for notification taps (when app is running).
|
||||
* Returns subscription that should be cleaned up on unmount.
|
||||
*/
|
||||
export function setupNotificationResponseListener(): Notifications.Subscription {
|
||||
return Notifications.addNotificationResponseReceivedListener(handleNotificationResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app was opened from a notification tap (cold start).
|
||||
*/
|
||||
export async function getInitialNotification(): Promise<Notifications.NotificationResponse | null> {
|
||||
return Notifications.getLastNotificationResponseAsync();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const REVENUECAT_IOS_API_KEY =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || "";
|
||||
const ENTITLEMENT_ID =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID || "pro"; // Your RevenueCat entitlement identifier
|
||||
|
||||
export interface SubscriptionState {
|
||||
isProUser: boolean;
|
||||
activeSubscription: string | null;
|
||||
expirationDate: Date | null;
|
||||
willRenew: boolean;
|
||||
}
|
||||
|
||||
// Type definitions for RevenueCat (minimal subset we use)
|
||||
interface CustomerInfo {
|
||||
entitlements: {
|
||||
active: {
|
||||
[key: string]: {
|
||||
productIdentifier: string;
|
||||
expirationDate: string | null;
|
||||
willRenew: boolean;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PurchasesPackage {
|
||||
identifier: string;
|
||||
packageType: string;
|
||||
product: {
|
||||
price: number;
|
||||
priceString: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PurchasesOffering {
|
||||
identifier: string;
|
||||
availablePackages: PurchasesPackage[];
|
||||
}
|
||||
|
||||
// Re-export for consumers
|
||||
export type { CustomerInfo, PurchasesPackage, PurchasesOffering };
|
||||
|
||||
class RevenueCatService {
|
||||
private initialized = false;
|
||||
private Purchases: any = null;
|
||||
|
||||
isAvailable(): boolean {
|
||||
return Platform.OS === "ios";
|
||||
}
|
||||
|
||||
private async loadPurchases(): Promise<boolean> {
|
||||
if (this.Purchases) return true;
|
||||
|
||||
try {
|
||||
const module = require("react-native-purchases");
|
||||
this.Purchases = module.default || module;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log("RevenueCat: Native module not available (running in Expo Go?)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Only initialize on iOS
|
||||
if (Platform.OS !== "ios") {
|
||||
console.log("RevenueCat: Skipping initialization on non-iOS platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const available = await this.loadPurchases();
|
||||
if (!available) return;
|
||||
|
||||
if (!REVENUECAT_IOS_API_KEY) {
|
||||
console.warn("RevenueCat: Missing iOS API key");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (__DEV__) {
|
||||
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
|
||||
}
|
||||
|
||||
await this.Purchases.configure({
|
||||
apiKey: REVENUECAT_IOS_API_KEY,
|
||||
appUserID: userId,
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
console.log("RevenueCat: Initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Failed to initialize:", error);
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async login(userId: string): Promise<CustomerInfo | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { customerInfo } = await this.Purchases.logIn(userId);
|
||||
console.log("RevenueCat: Logged in user", userId);
|
||||
return customerInfo;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Login error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (!this.initialized || !this.Purchases) return;
|
||||
|
||||
try {
|
||||
await this.Purchases.logOut();
|
||||
console.log("RevenueCat: Logged out");
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Logout error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getOfferings(): Promise<PurchasesOffering | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const offerings = await this.Purchases.getOfferings();
|
||||
return offerings.current;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error fetching offerings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async purchasePackage(pkg: PurchasesPackage): Promise<CustomerInfo> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
throw new Error("RevenueCat not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const { customerInfo } = await this.Purchases.purchasePackage(pkg);
|
||||
return customerInfo;
|
||||
} catch (error: any) {
|
||||
if (error.userCancelled) {
|
||||
throw new Error("Purchase cancelled");
|
||||
}
|
||||
console.error("RevenueCat: Purchase error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async restorePurchases(): Promise<CustomerInfo> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
throw new Error("RevenueCat not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const customerInfo = await this.Purchases.restorePurchases();
|
||||
return customerInfo;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Restore error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomerInfo(): Promise<CustomerInfo | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.Purchases.getCustomerInfo();
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error getting customer info:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
parseCustomerInfo(customerInfo: CustomerInfo | null): SubscriptionState {
|
||||
if (!customerInfo) {
|
||||
console.log("RevenueCat: No customer info");
|
||||
return {
|
||||
isProUser: false,
|
||||
activeSubscription: null,
|
||||
expirationDate: null,
|
||||
willRenew: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Debug: Log all active entitlements
|
||||
const activeEntitlements = Object.keys(customerInfo.entitlements.active);
|
||||
console.log("RevenueCat: Active entitlements:", activeEntitlements);
|
||||
console.log("RevenueCat: Looking for entitlement ID:", ENTITLEMENT_ID);
|
||||
|
||||
const entitlement = customerInfo.entitlements.active[ENTITLEMENT_ID];
|
||||
console.log("RevenueCat: Found entitlement:", entitlement ? "yes" : "no");
|
||||
|
||||
return {
|
||||
isProUser: !!entitlement,
|
||||
activeSubscription: entitlement?.productIdentifier || null,
|
||||
expirationDate: entitlement?.expirationDate
|
||||
? new Date(entitlement.expirationDate)
|
||||
: null,
|
||||
willRenew: entitlement?.willRenew || false,
|
||||
};
|
||||
}
|
||||
|
||||
addCustomerInfoUpdateListener(
|
||||
callback: (customerInfo: CustomerInfo) => void
|
||||
): (() => void) | null {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = this.Purchases.addCustomerInfoUpdateListener(callback);
|
||||
return () => {
|
||||
if (subscription && typeof subscription.remove === "function") {
|
||||
subscription.remove();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error adding listener:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const revenueCatService = new RevenueCatService();
|
||||
export default revenueCatService;
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN;
|
||||
|
||||
export function initSentry() {
|
||||
if (!SENTRY_DSN) {
|
||||
console.warn("Sentry DSN not configured - error tracking disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
debug: __DEV__,
|
||||
enabled: !__DEV__, // Only enable in production
|
||||
environment: __DEV__ ? "development" : "production",
|
||||
release: Constants.expoConfig?.version ?? "1.0.0",
|
||||
dist: Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode?.toString(),
|
||||
|
||||
// Performance monitoring
|
||||
tracesSampleRate: 0.2, // 20% of transactions for performance
|
||||
|
||||
// Session tracking
|
||||
enableAutoSessionTracking: true,
|
||||
sessionTrackingIntervalMillis: 30000,
|
||||
|
||||
// Capture unhandled promise rejections
|
||||
enableNative: true,
|
||||
|
||||
// Filter out noisy errors
|
||||
beforeSend(event) {
|
||||
// Filter out network errors that are expected (e.g., user offline)
|
||||
if (event.exception?.values?.[0]?.value?.includes("Network request failed")) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to capture errors with context
|
||||
export function captureError(error: Error, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
Sentry.setContext("additional", context);
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
// Set user context when they log in
|
||||
export function setUserContext(userId: string, email?: string) {
|
||||
Sentry.setUser({
|
||||
id: userId,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear user context on logout
|
||||
export function clearUserContext() {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
|
||||
// Add breadcrumb for debugging
|
||||
export function addBreadcrumb(message: string, category: string, data?: Record<string, unknown>) {
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
category,
|
||||
data,
|
||||
level: "info",
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
@@ -1,6 +1,14 @@
|
||||
import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import type { Profile, Couple, Match, QuestionPack } from "@/types";
|
||||
import revenueCatService, {
|
||||
SubscriptionState,
|
||||
PurchasesPackage,
|
||||
PurchasesOffering,
|
||||
} from "../lib/revenuecat";
|
||||
import type { Profile, Couple, Match, QuestionPack, Category } from "@/types";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
@@ -43,9 +51,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
// If user has a couple, fetch couple data
|
||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||
if (profile?.couple_id) {
|
||||
await get().fetchCouple();
|
||||
} else {
|
||||
set({ couple: null, partner: null });
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
@@ -77,16 +87,44 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
await supabase.auth.signOut();
|
||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||
set({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isAuthenticated: false
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
|
||||
// Then try to sign out from Supabase (don't block on this)
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
} catch (error) {
|
||||
console.error("Supabase signOut error:", error);
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
setUser: (user) => {
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading: false,
|
||||
// Clear couple/partner when user is null (signed out)
|
||||
...(user === null && { couple: null, partner: null })
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Match store for managing matches
|
||||
@@ -97,6 +135,7 @@ interface MatchState {
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
clearMatches: () => void;
|
||||
}
|
||||
|
||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
@@ -104,6 +143,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
newMatchesCount: 0,
|
||||
|
||||
fetchMatches: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
|
||||
const { data: matches } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
@@ -121,9 +162,32 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
.select("*")
|
||||
.in("question_id", questionIds);
|
||||
|
||||
// Fetch unread message counts per match
|
||||
const matchIds = matches.map(m => m.id);
|
||||
let unreadCounts: Record<string, number> = {};
|
||||
|
||||
if (userId && matchIds.length > 0) {
|
||||
const { data: unreadMessages, error } = await supabase
|
||||
.from("messages")
|
||||
.select("match_id")
|
||||
.in("match_id", matchIds)
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching unread messages:", error);
|
||||
}
|
||||
|
||||
// Count unread messages per match
|
||||
unreadMessages?.forEach(msg => {
|
||||
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
const data = matches.map(match => ({
|
||||
...match,
|
||||
responses: responses?.filter(r => r.question_id === match.question_id) || []
|
||||
responses: responses?.filter(r => r.question_id === match.question_id) || [],
|
||||
unreadCount: unreadCounts[match.id] || 0
|
||||
}));
|
||||
|
||||
const newCount = data.filter((m) => m.is_new).length;
|
||||
@@ -165,36 +229,54 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
newMatchesCount: state.newMatchesCount + 1,
|
||||
}));
|
||||
},
|
||||
|
||||
clearMatches: () => {
|
||||
set({ matches: [], newMatchesCount: 0 });
|
||||
},
|
||||
}));
|
||||
|
||||
// Packs store
|
||||
interface PacksState {
|
||||
packs: QuestionPack[];
|
||||
categories: Category[];
|
||||
enabledPackIds: string[];
|
||||
isLoading: boolean;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
togglePack: (packId: string) => Promise<void>;
|
||||
clearPacks: () => void;
|
||||
}
|
||||
|
||||
export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
packs: [],
|
||||
categories: [],
|
||||
enabledPackIds: [],
|
||||
isLoading: false,
|
||||
|
||||
fetchPacks: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = await supabase
|
||||
.from("categories")
|
||||
.select("*")
|
||||
.order("sort_order");
|
||||
|
||||
// Fetch packs with category info and question count
|
||||
const { data: packs } = await supabase
|
||||
.from("question_packs")
|
||||
.select("*")
|
||||
.select("*, category:categories(*), questions(count)")
|
||||
.eq("is_public", true)
|
||||
.order("sort_order");
|
||||
|
||||
// Also fetch enabled packs if logged in
|
||||
await get().fetchEnabledPacks();
|
||||
|
||||
set({ packs: packs ?? [], isLoading: false });
|
||||
set({
|
||||
packs: packs ?? [],
|
||||
categories: categories ?? [],
|
||||
isLoading: false
|
||||
});
|
||||
},
|
||||
|
||||
fetchEnabledPacks: async () => {
|
||||
@@ -241,5 +323,251 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
// Revert by refetching
|
||||
get().fetchEnabledPacks();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearPacks: () => {
|
||||
set({ enabledPackIds: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
// Message notification store
|
||||
interface MessageWithMatch extends Message {
|
||||
match?: {
|
||||
id: string;
|
||||
question: {
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
lastMessage: MessageWithMatch | null;
|
||||
activeMatchId: string | null; // Track which chat is currently open
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
addMessage: (message: MessageWithMatch) => void;
|
||||
clearLastMessage: () => void;
|
||||
setActiveMatchId: (matchId: string | null) => void;
|
||||
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
lastMessage: null,
|
||||
activeMatchId: null,
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) {
|
||||
set({ unreadCount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { count } = await supabase
|
||||
.from("messages")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
set({ unreadCount: count || 0 });
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const activeMatchId = get().activeMatchId;
|
||||
|
||||
// Only show notification if message is from partner and not in the active chat
|
||||
if (message.user_id !== userId && message.match_id !== activeMatchId) {
|
||||
set((state) => ({
|
||||
unreadCount: state.unreadCount + 1,
|
||||
lastMessage: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
clearLastMessage: () => {
|
||||
set({ lastMessage: null });
|
||||
},
|
||||
|
||||
setActiveMatchId: (matchId) => {
|
||||
set({ activeMatchId: matchId });
|
||||
},
|
||||
|
||||
markMatchMessagesAsRead: async (matchId) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.eq("match_id", matchId)
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
// Refetch unread count
|
||||
await get().fetchUnreadCount();
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
|
||||
},
|
||||
}));
|
||||
|
||||
// Subscription store for RevenueCat
|
||||
interface SubscriptionStoreState {
|
||||
subscription: SubscriptionState;
|
||||
offerings: PurchasesOffering | null;
|
||||
isLoadingOfferings: boolean;
|
||||
isPurchasing: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
initializeRevenueCat: (userId: string) => Promise<void>;
|
||||
fetchOfferings: () => Promise<void>;
|
||||
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
|
||||
restorePurchases: () => Promise<boolean>;
|
||||
refreshSubscriptionStatus: () => Promise<void>;
|
||||
clearSubscription: () => void;
|
||||
}
|
||||
|
||||
const defaultSubscription: SubscriptionState = {
|
||||
isProUser: false,
|
||||
activeSubscription: null,
|
||||
expirationDate: null,
|
||||
willRenew: false,
|
||||
};
|
||||
|
||||
export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) => ({
|
||||
subscription: defaultSubscription,
|
||||
offerings: null,
|
||||
isLoadingOfferings: false,
|
||||
isPurchasing: false,
|
||||
error: null,
|
||||
|
||||
initializeRevenueCat: async (userId: string) => {
|
||||
try {
|
||||
// Check if RevenueCat is available (won't work in Expo Go)
|
||||
if (!revenueCatService.isAvailable()) {
|
||||
console.log("RevenueCat not available, skipping initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
await revenueCatService.initialize(userId);
|
||||
await revenueCatService.login(userId);
|
||||
|
||||
// Set up listener for subscription changes
|
||||
const removeListener = revenueCatService.addCustomerInfoUpdateListener(
|
||||
(customerInfo) => {
|
||||
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
|
||||
set({ subscription });
|
||||
|
||||
// Update user's is_premium in auth store if changed
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
|
||||
useAuthStore.getState().fetchUser();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get initial subscription status
|
||||
await get().refreshSubscriptionStatus();
|
||||
} catch (error) {
|
||||
console.error("RevenueCat initialization error:", error);
|
||||
// Don't set error - just silently fail to not break the app
|
||||
}
|
||||
},
|
||||
|
||||
fetchOfferings: async () => {
|
||||
set({ isLoadingOfferings: true, error: null });
|
||||
try {
|
||||
const offerings = await revenueCatService.getOfferings();
|
||||
set({ offerings, isLoadingOfferings: false });
|
||||
} catch (error) {
|
||||
console.error("Error fetching offerings:", error);
|
||||
set({ error: "Failed to load subscription options", isLoadingOfferings: false });
|
||||
}
|
||||
},
|
||||
|
||||
purchasePackage: async (pkg: PurchasesPackage) => {
|
||||
set({ isPurchasing: true, error: null });
|
||||
try {
|
||||
const customerInfo = await revenueCatService.purchasePackage(pkg);
|
||||
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
|
||||
set({ subscription, isPurchasing: false });
|
||||
|
||||
// Refresh user profile to get updated is_premium
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.message === "Purchase cancelled"
|
||||
? "Purchase was cancelled"
|
||||
: "Purchase failed. Please try again.";
|
||||
set({ error: message, isPurchasing: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
restorePurchases: async () => {
|
||||
set({ isPurchasing: true, error: null });
|
||||
try {
|
||||
const customerInfo = await revenueCatService.restorePurchases();
|
||||
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
|
||||
set({ subscription, isPurchasing: false });
|
||||
|
||||
// Refresh user profile
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
return subscription.isProUser;
|
||||
} catch (error) {
|
||||
set({ error: "Failed to restore purchases", isPurchasing: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
refreshSubscriptionStatus: async () => {
|
||||
try {
|
||||
const customerInfo = await revenueCatService.getCustomerInfo();
|
||||
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
|
||||
set({ subscription });
|
||||
|
||||
// Sync with server (server verifies with RevenueCat API)
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
|
||||
console.log("Syncing subscription status with server...");
|
||||
|
||||
// Get the session token to pass to the function
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
console.error("No session available for sync");
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.functions.invoke("sync-subscription", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
console.error("Error syncing subscription:", error);
|
||||
} else {
|
||||
// Refresh user to get updated is_premium
|
||||
await useAuthStore.getState().fetchUser();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing subscription:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearSubscription: () => {
|
||||
revenueCatService.logout();
|
||||
set({
|
||||
subscription: defaultSubscription,
|
||||
offerings: null,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -18,6 +18,16 @@ export interface Couple {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Category for grouping packs
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Question pack
|
||||
export interface QuestionPack {
|
||||
id: string;
|
||||
@@ -27,6 +37,9 @@ export interface QuestionPack {
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
category?: Category;
|
||||
questions?: { count: number }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -78,3 +91,53 @@ export interface PackProgress {
|
||||
answered_questions: number;
|
||||
matches_count: number;
|
||||
}
|
||||
|
||||
// Feedback types
|
||||
export type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||
export type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
export interface DeviceInfo {
|
||||
platform: 'ios' | 'android' | 'web';
|
||||
osVersion: string;
|
||||
appVersion: string;
|
||||
buildNumber?: string;
|
||||
deviceModel?: string;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
screenshot_url: string | null;
|
||||
device_info: DeviceInfo;
|
||||
status: FeedbackStatus;
|
||||
admin_notes: string | null;
|
||||
question_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Subscription types
|
||||
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revenuecat_app_user_id: string;
|
||||
product_id: string;
|
||||
status: SubscriptionStatus;
|
||||
entitlement_ids: string[];
|
||||
purchased_at: string;
|
||||
expires_at: string | null;
|
||||
original_transaction_id: string;
|
||||
store: string;
|
||||
is_sandbox: boolean;
|
||||
cancel_reason: string | null;
|
||||
grace_period_expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -14,6 +14,33 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
categories: {
|
||||
Row: {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
sort_order: number | null
|
||||
created_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
name: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
sort_order?: number | null
|
||||
created_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
sort_order?: number | null
|
||||
created_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
couple_packs: {
|
||||
Row: {
|
||||
couple_id: string
|
||||
@@ -114,6 +141,7 @@ export type Database = {
|
||||
Row: {
|
||||
content: string | null
|
||||
created_at: string | null
|
||||
delivered_at: string | null
|
||||
id: string
|
||||
match_id: string
|
||||
media_path: string | null
|
||||
@@ -124,6 +152,7 @@ export type Database = {
|
||||
Insert: {
|
||||
content?: string | null
|
||||
created_at?: string | null
|
||||
delivered_at?: string | null
|
||||
id?: string
|
||||
match_id: string
|
||||
media_path?: string | null
|
||||
@@ -134,6 +163,7 @@ export type Database = {
|
||||
Update: {
|
||||
content?: string | null
|
||||
created_at?: string | null
|
||||
delivered_at?: string | null
|
||||
id?: string
|
||||
match_id?: string
|
||||
media_path?: string | null
|
||||
@@ -197,39 +227,47 @@ export type Database = {
|
||||
}
|
||||
question_packs: {
|
||||
Row: {
|
||||
created_at: string | null
|
||||
description: string | null
|
||||
display_order: number
|
||||
id: string
|
||||
image_url: string | null
|
||||
is_explicit: boolean | null
|
||||
is_pro: boolean | null
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
is_premium: boolean | null
|
||||
is_public: boolean | null
|
||||
title: string
|
||||
sort_order: number | null
|
||||
category_id: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
display_order?: number
|
||||
id?: string
|
||||
image_url?: string | null
|
||||
is_explicit?: boolean | null
|
||||
is_pro?: boolean | null
|
||||
name: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
is_premium?: boolean | null
|
||||
is_public?: boolean | null
|
||||
title: string
|
||||
sort_order?: number | null
|
||||
category_id?: string | null
|
||||
created_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
display_order?: number
|
||||
id?: string
|
||||
image_url?: string | null
|
||||
is_explicit?: boolean | null
|
||||
is_pro?: boolean | null
|
||||
name?: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
is_premium?: boolean | null
|
||||
is_public?: boolean | null
|
||||
title?: string
|
||||
sort_order?: number | null
|
||||
category_id?: string | null
|
||||
created_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "question_packs_category_id_fkey"
|
||||
columns: ["category_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "categories"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
questions: {
|
||||
Row: {
|
||||
|
||||
Reference in new issue
Block a user