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

No files matched your search

@@ -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;
+533
View File
@@ -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;
+89 -41
View File
@@ -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",
+133 -95
View File
@@ -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",
+25
View File
@@ -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,
};
}
+126
View File
@@ -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();
}
+240
View File
@@ -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;
+72
View File
@@ -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 };
+337 -9
View File
@@ -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,
});
},
}));
+63
View File
@@ -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;
}
+60 -22
View File
@@ -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: {