mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Add intensity-based preferences and harden E2EE payload verification
This commit is contained in:
1 parent
e23f4557bb
commit
0a919f06fc
20 files changed
+2033
-251
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { colors, featureColors, spacing, typography } from '../../../theme';
|
||||
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
|
||||
import { Profile, Couple } from '../../../types';
|
||||
|
||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
@@ -15,30 +16,79 @@ interface CoupleStatusProps {
|
||||
onPairingPress: () => void;
|
||||
}
|
||||
|
||||
const formatPairedDate = (date: string | null | undefined): string | null => {
|
||||
if (!date) return null;
|
||||
const paired = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - paired.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Paired today';
|
||||
if (diffDays === 1) return 'Paired yesterday';
|
||||
if (diffDays < 7) return `Paired ${diffDays} days ago`;
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `Paired ${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
||||
}
|
||||
if (diffDays < 365) {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `Paired ${months} ${months === 1 ? 'month' : 'months'} ago`;
|
||||
}
|
||||
const years = Math.floor(diffDays / 365);
|
||||
return `Paired ${years} ${years === 1 ? 'year' : 'years'} ago`;
|
||||
};
|
||||
|
||||
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
|
||||
partner,
|
||||
couple,
|
||||
onUnpair,
|
||||
onPairingPress,
|
||||
}) => {
|
||||
const pairedLabel = formatPairedDate(couple?.created_at);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Partner" delay={300}>
|
||||
{partner ? (
|
||||
<View style={styles.rowContainer}>
|
||||
<View style={styles.rowLeft}>
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.partnerIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
{partner.avatar_url ? (
|
||||
<View style={styles.partnerAvatarContainer}>
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.partnerAvatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: partner.avatar_url }}
|
||||
style={styles.partnerAvatar}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.connectedBadge}>
|
||||
<Ionicons name="heart" size={10} color={colors.text} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.partnerIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Text style={styles.partnerInitial}>
|
||||
{(partner.name?.[0] || partner.email?.[0] || 'P').toUpperCase()}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>
|
||||
{partner.name || partner.email || 'Your partner'}
|
||||
</Text>
|
||||
<Text style={styles.rowLabel}>Connected</Text>
|
||||
<Text style={styles.rowLabel}>
|
||||
{pairedLabel || 'Connected'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@@ -108,20 +158,58 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
partnerIconGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
partnerAvatarContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
partnerAvatarGradient: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
padding: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyPartnerIcon: {
|
||||
partnerAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
connectedBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.success,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.backgroundLight,
|
||||
},
|
||||
partnerIconGradient: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
partnerInitial: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyPartnerIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
rowTextContainer: {
|
||||
marginLeft: spacing.md,
|
||||
|
||||
@@ -7,29 +7,54 @@ import { GlassCard, GlassButton } from '../../../components/ui';
|
||||
import { colors, spacing, radius, typography } from '../../../theme';
|
||||
|
||||
interface DangerZoneProps {
|
||||
onResetProgress?: () => Promise<void>;
|
||||
onDeleteRelationship?: () => Promise<void>;
|
||||
onDeleteAccount: () => Promise<void>;
|
||||
hasRelationship?: boolean;
|
||||
}
|
||||
|
||||
export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
onResetProgress,
|
||||
onDeleteRelationship,
|
||||
onDeleteAccount,
|
||||
hasRelationship = false
|
||||
}) => {
|
||||
const [showResetModal, setShowResetModal] = useState(false);
|
||||
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
||||
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
|
||||
const handleResetProgress = async () => {
|
||||
if (confirmText !== "RESET" || !onResetProgress) return;
|
||||
setIsResetting(true);
|
||||
try {
|
||||
await onResetProgress();
|
||||
setShowResetModal(false);
|
||||
setConfirmText("");
|
||||
} catch (error) {
|
||||
// Error handling relies on parent
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeResetModal = () => {
|
||||
if (!isResetting) {
|
||||
setShowResetModal(false);
|
||||
setConfirmText("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRelationship = async () => {
|
||||
if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||
if (confirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||
setIsDeletingRelationship(true);
|
||||
try {
|
||||
await onDeleteRelationship();
|
||||
setShowDeleteRelationshipModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
} catch (error) {
|
||||
// Error handling relies on parent throwing or managing its own alerts
|
||||
} finally {
|
||||
@@ -38,7 +63,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirmText !== "DELETE") return;
|
||||
if (confirmText !== "DELETE") return;
|
||||
setIsDeletingAccount(true);
|
||||
try {
|
||||
await onDeleteAccount();
|
||||
@@ -52,14 +77,14 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
const closeRelationshipModal = () => {
|
||||
if (!isDeletingRelationship) {
|
||||
setShowDeleteRelationshipModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAccountModal = () => {
|
||||
if (!isDeletingAccount) {
|
||||
setShowDeleteAccountModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,18 +96,45 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
>
|
||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||
|
||||
{/* Reset Progress - only show if in a relationship */}
|
||||
{hasRelationship && onResetProgress && (
|
||||
<GlassCard style={styles.warningCard}>
|
||||
<View style={styles.dangerContent}>
|
||||
<View style={styles.dangerInfo}>
|
||||
<View style={styles.warningIconContainer}>
|
||||
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||
</View>
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Reset Progress</Text>
|
||||
<Text style={styles.dangerDescription}>
|
||||
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.warningButton}
|
||||
onPress={() => setShowResetModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
||||
<Text style={styles.warningButtonText}>Reset</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Delete Relationship - only show if in a relationship */}
|
||||
{hasRelationship && onDeleteRelationship && (
|
||||
<GlassCard style={styles.dangerCard}>
|
||||
<GlassCard style={StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle}>
|
||||
<View style={styles.dangerContent}>
|
||||
<View style={styles.dangerInfo}>
|
||||
<View style={styles.dangerIconContainer}>
|
||||
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
||||
</View>
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Delete Relationship Data</Text>
|
||||
<Text style={styles.dangerTitle}>Delete Relationship</Text>
|
||||
<Text style={styles.dangerDescription}>
|
||||
Delete your relationship including all matches, chats, and shared photos. Your account will remain.
|
||||
End your connection and delete all shared data. Your account will remain.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -100,7 +152,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
)}
|
||||
|
||||
{/* Delete Account - always show */}
|
||||
<GlassCard style={hasRelationship ? StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle : styles.dangerCard}>
|
||||
<GlassCard style={StyleSheet.flatten([styles.dangerCard, (hasRelationship ? styles.dangerCardSpacing : {})]) as ViewStyle}>
|
||||
<View style={styles.dangerContent}>
|
||||
<View style={styles.dangerInfo}>
|
||||
<View style={styles.dangerIconContainer}>
|
||||
@@ -109,7 +161,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Delete Account</Text>
|
||||
<Text style={styles.dangerDescription}>
|
||||
Permanently delete your account and all associated data. This cannot be undone.
|
||||
Permanently delete your account and all data. This cannot be undone.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -126,6 +178,105 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Reset Progress Modal */}
|
||||
<Modal
|
||||
visible={showResetModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeResetModal}
|
||||
>
|
||||
<BlurView
|
||||
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||
tint="dark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.modalOverlay}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.warningModalIconContainer}>
|
||||
<Ionicons name="refresh" size={32} color={colors.warning} />
|
||||
</View>
|
||||
<Text style={styles.modalTitle}>Reset Progress?</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.modalDescription}>
|
||||
This will permanently delete:
|
||||
</Text>
|
||||
|
||||
<View style={styles.warningList}>
|
||||
<View style={styles.deleteListItem}>
|
||||
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
|
||||
<Text style={styles.deleteListText}>All your swipes</Text>
|
||||
</View>
|
||||
<View style={styles.deleteListItem}>
|
||||
<Ionicons name="heart" size={16} color={colors.warning} />
|
||||
<Text style={styles.deleteListText}>All matches</Text>
|
||||
</View>
|
||||
<View style={styles.deleteListItem}>
|
||||
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
|
||||
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.keepList}>
|
||||
<View style={styles.deleteListItem}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||
<Text style={styles.deleteListText}>Your partner connection stays</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.confirmLabel}>
|
||||
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.warningInput}
|
||||
value={confirmText}
|
||||
onChangeText={setConfirmText}
|
||||
placeholder="Type RESET"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
editable={!isResetting}
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={closeResetModal}
|
||||
disabled={isResetting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.warningActionButton,
|
||||
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleResetProgress}
|
||||
disabled={confirmText !== "RESET" || isResetting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isResetting ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="refresh" size={16} color={colors.text} />
|
||||
<Text style={styles.deleteButtonText}>Reset</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</BlurView>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Relationship Modal */}
|
||||
<Modal
|
||||
visible={showDeleteRelationshipModal}
|
||||
@@ -179,8 +330,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
|
||||
<TextInput
|
||||
style={styles.confirmInput}
|
||||
value={deleteConfirmText}
|
||||
onChangeText={setDeleteConfirmText}
|
||||
value={confirmText}
|
||||
onChangeText={setConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
@@ -201,10 +352,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
(deleteConfirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteRelationship}
|
||||
disabled={deleteConfirmText !== "DELETE" || isDeletingRelationship}
|
||||
disabled={confirmText !== "DELETE" || isDeletingRelationship}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDeletingRelationship ? (
|
||||
@@ -283,8 +434,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
|
||||
<TextInput
|
||||
style={styles.confirmInput}
|
||||
value={deleteConfirmText}
|
||||
onChangeText={setDeleteConfirmText}
|
||||
value={confirmText}
|
||||
onChangeText={setConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
@@ -305,10 +456,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
(deleteConfirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteAccount}
|
||||
disabled={deleteConfirmText !== "DELETE" || isDeletingAccount}
|
||||
disabled={confirmText !== "DELETE" || isDeletingAccount}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDeletingAccount ? (
|
||||
@@ -342,6 +493,36 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
warningCard: {
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
warningIconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
warningButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
gap: spacing.xs,
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
warningButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.warning,
|
||||
fontWeight: "600",
|
||||
},
|
||||
dangerCard: {
|
||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||
borderWidth: 1,
|
||||
@@ -431,6 +612,58 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(231, 76, 60, 0.2)',
|
||||
},
|
||||
warningList: {
|
||||
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(243, 156, 18, 0.2)',
|
||||
},
|
||||
keepList: {
|
||||
backgroundColor: "rgba(46, 204, 113, 0.1)",
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(46, 204, 113, 0.2)',
|
||||
},
|
||||
warningModalIconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
warningKeyword: {
|
||||
fontWeight: "bold",
|
||||
color: colors.warning,
|
||||
},
|
||||
warningInput: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
padding: spacing.md,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
warningActionButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.warning,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
deleteListItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SwitchItem } from './SwitchItem';
|
||||
import { colors, spacing } from '../../../theme';
|
||||
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
||||
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
||||
import type { IntensityLevel } from '../../../types';
|
||||
|
||||
interface PrivacySettingsProps {
|
||||
showExplicit: boolean;
|
||||
isUpdatingExplicit: boolean;
|
||||
onExplicitToggle: (value: boolean) => void;
|
||||
maxIntensity: IntensityLevel;
|
||||
isUpdatingIntensity: boolean;
|
||||
onIntensityChange: (value: IntensityLevel) => void;
|
||||
biometricAvailable: boolean;
|
||||
biometricEnabled: boolean;
|
||||
biometricType: string;
|
||||
isUpdatingBiometric: boolean;
|
||||
onBiometricToggle: (value: boolean) => void;
|
||||
// Partner info for slider comparison
|
||||
partnerIntensity?: IntensityLevel | null;
|
||||
partnerName?: string;
|
||||
partnerAvatar?: string | null;
|
||||
}
|
||||
|
||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
|
||||
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
showExplicit,
|
||||
isUpdatingExplicit,
|
||||
onExplicitToggle,
|
||||
maxIntensity,
|
||||
isUpdatingIntensity,
|
||||
onIntensityChange,
|
||||
biometricAvailable,
|
||||
biometricEnabled,
|
||||
biometricType,
|
||||
isUpdatingBiometric,
|
||||
onBiometricToggle,
|
||||
partnerIntensity,
|
||||
partnerName,
|
||||
partnerAvatar,
|
||||
}) => {
|
||||
return (
|
||||
<SettingsSection title="Preferences" delay={375}>
|
||||
<SwitchItem
|
||||
icon="flame"
|
||||
label="Spicy Content"
|
||||
description="Show spicy question packs"
|
||||
value={showExplicit}
|
||||
onValueChange={onExplicitToggle}
|
||||
disabled={isUpdatingExplicit}
|
||||
/>
|
||||
{/* Comfort Zone Header */}
|
||||
<View style={styles.comfortHeader}>
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.comfortIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.comfortText}>
|
||||
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
||||
<Text style={styles.comfortDescription}>
|
||||
Set the intimacy level you're comfortable with
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Intensity Slider */}
|
||||
<View style={styles.sliderContainer}>
|
||||
<IntensitySlider
|
||||
value={maxIntensity}
|
||||
onValueChange={onIntensityChange}
|
||||
disabled={isUpdatingIntensity}
|
||||
partnerValue={partnerIntensity}
|
||||
partnerName={partnerName}
|
||||
partnerAvatar={partnerAvatar}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Biometric Toggle */}
|
||||
{biometricAvailable && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
@@ -53,9 +89,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
comfortHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
comfortIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
comfortText: {
|
||||
flex: 1,
|
||||
},
|
||||
comfortLabel: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
comfortDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
sliderContainer: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.glass.border,
|
||||
marginVertical: spacing.md,
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
|
||||
import { ShimmerEffect } from '../../../components/ui';
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../../theme';
|
||||
|
||||
export interface SubscriptionCardProps {
|
||||
/** Whether user has premium access (own or partner's) */
|
||||
hasPremiumAccess: boolean;
|
||||
/** Whether this is the user's own subscription (vs partner's) */
|
||||
isOwnSubscription: boolean;
|
||||
/** Formatted expiration/renewal date */
|
||||
expirationDate: string;
|
||||
/** Callback when upgrade button is pressed */
|
||||
onUpgradePress: () => void;
|
||||
/** Callback when manage subscription is pressed */
|
||||
onManagePress: () => void;
|
||||
/** Callback when restore purchases is pressed */
|
||||
onRestorePress: () => void;
|
||||
/** Whether restore is in progress */
|
||||
isRestoring?: boolean;
|
||||
/** Animation delay */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const PREMIUM_FEATURES = [
|
||||
{ icon: 'layers' as const, text: '50+ Premium Packs' },
|
||||
{ icon: 'flame' as const, text: 'All Intensity Levels' },
|
||||
{ icon: 'sparkles' as const, text: 'New Packs Weekly' },
|
||||
];
|
||||
|
||||
export function SubscriptionCard({
|
||||
hasPremiumAccess,
|
||||
isOwnSubscription,
|
||||
expirationDate,
|
||||
onUpgradePress,
|
||||
onManagePress,
|
||||
onRestorePress,
|
||||
isRestoring = false,
|
||||
delay = 350,
|
||||
}: SubscriptionCardProps) {
|
||||
if (hasPremiumAccess) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(delay).duration(500)}
|
||||
style={styles.container}
|
||||
>
|
||||
<ProMemberCard
|
||||
isOwnSubscription={isOwnSubscription}
|
||||
expirationDate={expirationDate}
|
||||
onManagePress={onManagePress}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(delay).duration(500)}
|
||||
style={styles.container}
|
||||
>
|
||||
<FreeUserCard
|
||||
onUpgradePress={onUpgradePress}
|
||||
onRestorePress={onRestorePress}
|
||||
isRestoring={isRestoring}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Premium member card - celebratory gold treatment */
|
||||
function ProMemberCard({
|
||||
isOwnSubscription,
|
||||
expirationDate,
|
||||
onManagePress,
|
||||
}: {
|
||||
isOwnSubscription: boolean;
|
||||
expirationDate: string;
|
||||
onManagePress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.proCard}>
|
||||
{/* Gold gradient border effect */}
|
||||
<LinearGradient
|
||||
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.proBorderGradient}
|
||||
/>
|
||||
<View style={styles.proCardInner}>
|
||||
{/* Header with badge */}
|
||||
<View style={styles.proHeader}>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.proBadge}
|
||||
>
|
||||
<Ionicons name="star" size={16} color={colors.background} />
|
||||
</LinearGradient>
|
||||
<View style={styles.proHeaderText}>
|
||||
<Text style={styles.proTitle}>Pro Member</Text>
|
||||
<Text style={styles.proSubtitle}>
|
||||
{isOwnSubscription
|
||||
? `Renews ${expirationDate}`
|
||||
: "Via partner's subscription"
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
{isOwnSubscription && (
|
||||
<TouchableOpacity
|
||||
style={styles.manageButton}
|
||||
onPress={onManagePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.manageButtonText}>Manage</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Status row */}
|
||||
<View style={styles.proStatusRow}>
|
||||
<View style={styles.proStatusItem}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={colors.premium.gold} />
|
||||
<Text style={styles.proStatusText}>All Packs Unlocked</Text>
|
||||
</View>
|
||||
<View style={styles.proStatusItem}>
|
||||
<Ionicons name="infinite" size={16} color={colors.premium.gold} />
|
||||
<Text style={styles.proStatusText}>Unlimited Access</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Free user card - premium upsell with shimmer */
|
||||
function FreeUserCard({
|
||||
onUpgradePress,
|
||||
onRestorePress,
|
||||
isRestoring,
|
||||
}: {
|
||||
onUpgradePress: () => void;
|
||||
onRestorePress: () => void;
|
||||
isRestoring: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ShimmerEffect
|
||||
shimmerColor={colors.premium.gold}
|
||||
duration={5000}
|
||||
style={styles.shimmerContainer}
|
||||
>
|
||||
<View style={styles.freeCard}>
|
||||
{/* Gold gradient border */}
|
||||
<LinearGradient
|
||||
colors={[`${colors.premium.gold}60`, `${colors.premium.goldDark}40`]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.freeBorderGradient}
|
||||
/>
|
||||
<View style={styles.freeCardInner}>
|
||||
{/* Header */}
|
||||
<View style={styles.freeHeader}>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.freeHeaderBadge}
|
||||
>
|
||||
<Ionicons name="sparkles" size={14} color={colors.background} />
|
||||
<Text style={styles.freeHeaderBadgeText}>SAUCI PRO</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* Features list */}
|
||||
<View style={styles.featuresList}>
|
||||
{PREMIUM_FEATURES.map((feature, index) => (
|
||||
<View key={index} style={styles.featureItem}>
|
||||
<View style={styles.featureIconContainer}>
|
||||
<Ionicons
|
||||
name={feature.icon}
|
||||
size={16}
|
||||
color={colors.premium.gold}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.featureText}>{feature.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* CTA Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onUpgradePress}
|
||||
activeOpacity={0.8}
|
||||
style={styles.ctaButtonContainer}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.ctaButton}
|
||||
>
|
||||
<Ionicons name="star" size={18} color={colors.background} />
|
||||
<Text style={styles.ctaButtonText}>Upgrade to Pro</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Restore link */}
|
||||
<TouchableOpacity
|
||||
style={styles.restoreLink}
|
||||
onPress={onRestorePress}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
<Text style={styles.restoreLinkText}>
|
||||
{isRestoring ? "Restoring..." : "Restore Purchases"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ShimmerEffect>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: spacing.lg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
shimmerContainer: {
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
|
||||
// Pro Member Card Styles
|
||||
proCard: {
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
proBorderGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
opacity: 0.3,
|
||||
},
|
||||
proCardInner: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.8)',
|
||||
margin: 1,
|
||||
borderRadius: radius.lg - 1,
|
||||
padding: spacing.md,
|
||||
},
|
||||
proHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
proBadge: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...shadows.glow(colors.premium.gold),
|
||||
},
|
||||
proHeaderText: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
proTitle: {
|
||||
...typography.headline,
|
||||
color: colors.premium.champagne,
|
||||
fontWeight: '700',
|
||||
},
|
||||
proSubtitle: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
manageButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.premium.gold}40`,
|
||||
},
|
||||
manageButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.premium.gold,
|
||||
fontWeight: '600',
|
||||
},
|
||||
proStatusRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.glass.border,
|
||||
gap: spacing.lg,
|
||||
},
|
||||
proStatusItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
proStatusText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
|
||||
// Free User Card Styles
|
||||
freeCard: {
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
freeBorderGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
freeCardInner: {
|
||||
backgroundColor: 'rgba(22, 33, 62, 0.85)',
|
||||
margin: 1,
|
||||
borderRadius: radius.lg - 1,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
freeHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
freeHeaderBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
freeHeaderBadgeText: {
|
||||
...typography.caption1,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 2,
|
||||
color: colors.background,
|
||||
},
|
||||
featuresList: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
featureIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.premium.goldLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
featureText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
ctaButtonContainer: {
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
...shadows.glow(colors.premium.gold),
|
||||
},
|
||||
ctaButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
ctaButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.background,
|
||||
fontWeight: '700',
|
||||
},
|
||||
restoreLink: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
restoreLinkText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
});
|
||||
|
||||
export default SubscriptionCard;
|
||||
Reference in new issue
Block a user