mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge remote-tracking branch 'origin/master' into task/self-hosted-backend
# Conflicts: # apps/mobile/app/(app)/onboarding.tsx # apps/mobile/src/__tests__/stores/packsStore.test.ts # apps/mobile/src/features/profile/hooks/useProfileSettings.ts # apps/mobile/src/store/packsStore.ts
This commit is contained in:
commit
be20391788
77 files changed
+48548
-671
No files matched your search
@@ -7,7 +7,7 @@ describe('packsStore standalone API integration', () => {
|
||||
jest.restoreAllMocks();
|
||||
usePacksStore.setState({
|
||||
packs: [], categories: [], enabledPackIds: [], packProgress: new Map(),
|
||||
isLoading: false, showAllIntensities: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
|
||||
});
|
||||
|
||||
@@ -92,18 +92,6 @@ export function BoutiquePackCard({
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Explicit Badge (top-left) */}
|
||||
{pack.is_explicit && (
|
||||
<View style={styles.explicitBadge}>
|
||||
<Ionicons
|
||||
name="flame"
|
||||
size={10}
|
||||
color={colors.text}
|
||||
/>
|
||||
<Text style={styles.explicitBadgeText}>18+</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* PRO Badge (top-right) */}
|
||||
{pack.is_premium && (
|
||||
<View style={[
|
||||
@@ -242,24 +230,6 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.md,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
explicitBadge: {
|
||||
position: 'absolute',
|
||||
top: spacing.sm,
|
||||
left: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.error,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.xs,
|
||||
gap: 3,
|
||||
zIndex: 10,
|
||||
},
|
||||
explicitBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
proBadge: {
|
||||
position: 'absolute',
|
||||
top: spacing.sm,
|
||||
|
||||
@@ -54,12 +54,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.badges}>
|
||||
{pack.is_explicit && (
|
||||
<View style={styles.explicitBadge}>
|
||||
<Ionicons name="flame" size={10} color={colors.text} />
|
||||
<Text style={styles.explicitBadgeText}>18+</Text>
|
||||
</View>
|
||||
)}
|
||||
{isNew && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>NEW</Text>
|
||||
@@ -129,20 +123,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
explicitBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.error,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
gap: 2,
|
||||
},
|
||||
explicitBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
newBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
paddingHorizontal: spacing.sm,
|
||||
|
||||
@@ -34,7 +34,7 @@ const FEATURES = [
|
||||
{
|
||||
icon: "layers" as const,
|
||||
title: "All Premium Packs",
|
||||
description: "Unlock every intimate collection",
|
||||
description: "Unlock every question collection",
|
||||
},
|
||||
{
|
||||
icon: "sparkles" as const,
|
||||
@@ -206,7 +206,7 @@ export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProp
|
||||
|
||||
<Text style={styles.title}>Sauci Pro</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Unlock the full intimate experience
|
||||
Unlock the full couples experience
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const STEPS: TutorialStep[] = [
|
||||
{
|
||||
key: "photos",
|
||||
title: "Share Pictures Privately",
|
||||
description: "Send photos that only your partner can see. Your intimate moments stay between the two of you, safe and private.",
|
||||
description: "Send photos that only your partner can see. Your shared moments stay between the two of you, safe and private.",
|
||||
icon: "images",
|
||||
gradient: gradients.premiumRose as [string, string],
|
||||
},
|
||||
|
||||
@@ -8,17 +8,17 @@ export interface IntensityLevelConfig {
|
||||
}
|
||||
|
||||
export const INTENSITY_LEVELS: IntensityLevelConfig[] = [
|
||||
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Pure emotional connection & non-sexual bonding' },
|
||||
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romantic atmosphere & affectionate touch' },
|
||||
{ level: 3, label: 'Playful', emoji: '😏', description: 'Light sexual exploration & sensual discovery' },
|
||||
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Explicit sexual activities & moderate adventure' },
|
||||
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced/BDSM/Extreme exploration' },
|
||||
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Easy conversation and everyday connection' },
|
||||
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romance, appreciation, and thoughtful moments' },
|
||||
{ level: 3, label: 'Playful', emoji: '✨', description: 'Light-hearted questions and shared laughter' },
|
||||
{ level: 4, label: 'Adventurous', emoji: '🌍', description: 'New experiences and ideas to try together' },
|
||||
{ level: 5, label: 'Deep', emoji: '🌱', description: 'Meaningful reflection and honest conversation' },
|
||||
];
|
||||
|
||||
export const HEAT_COLORS = [
|
||||
'#9b59b6', // Purple - gentle
|
||||
'#e94560', // Rose - warm
|
||||
'#ff6b6b', // Coral - playful
|
||||
'#ff4757', // Red - steamy
|
||||
'#ff4757', // Red - adventurous
|
||||
'#ff3333', // Bright red - intense
|
||||
];
|
||||
@@ -160,9 +160,9 @@ export function ProfileScreen() {
|
||||
/>
|
||||
<View style={styles.divider} />
|
||||
<MenuItem
|
||||
icon="options-outline"
|
||||
label="Preferences"
|
||||
description="Comfort zone and security"
|
||||
icon="lock-closed-outline"
|
||||
label="App Lock"
|
||||
description="Biometric access controls"
|
||||
onPress={() => router.push('/(app)/settings/preferences')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Text, View, StyleSheet } from 'react-native';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SwitchItem } from './SwitchItem';
|
||||
import { colors, spacing } from '../../../theme';
|
||||
@@ -10,9 +10,6 @@ interface PrivacySettingsProps {
|
||||
biometricType: string;
|
||||
isUpdatingBiometric: boolean;
|
||||
onBiometricToggle: (value: boolean) => void;
|
||||
hideNsfw: boolean;
|
||||
isUpdatingHideNsfw: boolean;
|
||||
onHideNsfwToggle: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
@@ -21,44 +18,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
biometricType,
|
||||
isUpdatingBiometric,
|
||||
onBiometricToggle,
|
||||
hideNsfw,
|
||||
isUpdatingHideNsfw,
|
||||
onHideNsfwToggle,
|
||||
}) => {
|
||||
return (
|
||||
<SettingsSection title="Preferences" delay={375}>
|
||||
{/* Hide Adult Content Toggle */}
|
||||
<SwitchItem
|
||||
icon="eye-off-outline"
|
||||
label="Hide Adult Content"
|
||||
description="Only show mild, family-friendly question packs"
|
||||
value={hideNsfw}
|
||||
onValueChange={onHideNsfwToggle}
|
||||
disabled={isUpdatingHideNsfw}
|
||||
/>
|
||||
|
||||
{/* Biometric Toggle */}
|
||||
<SettingsSection title="Security" delay={375}>
|
||||
{biometricAvailable && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<SwitchItem
|
||||
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
|
||||
label={biometricType}
|
||||
description="Require unlock when opening app"
|
||||
value={biometricEnabled}
|
||||
onValueChange={onBiometricToggle}
|
||||
disabled={isUpdatingBiometric}
|
||||
/>
|
||||
</>
|
||||
<SwitchItem
|
||||
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
|
||||
label={biometricType}
|
||||
description="Require unlock when opening app"
|
||||
value={biometricEnabled}
|
||||
onValueChange={onBiometricToggle}
|
||||
disabled={isUpdatingBiometric}
|
||||
/>
|
||||
)}
|
||||
{!biometricAvailable && (
|
||||
<View style={styles.unavailableRow}>
|
||||
<Text style={styles.unavailableText}>
|
||||
Biometric app lock is not available on this device.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border,
|
||||
marginVertical: spacing.lg,
|
||||
unavailableRow: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
unavailableText: {
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Alert, Linking, Platform } from 'react-native';
|
||||
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
||||
import { useAuthStore, useSubscriptionStore } from '../../../store';
|
||||
import { profileSettingsApi } from '../../../lib/profileSettingsApi';
|
||||
import { Events } from '../../../lib/analytics';
|
||||
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
|
||||
@@ -16,15 +16,8 @@ import {
|
||||
getBiometricType,
|
||||
} from '../../../lib/biometricAuth';
|
||||
|
||||
// Intensity thresholds for backwards compatibility
|
||||
// When hide_nsfw=true, max_intensity=2 (mild content only)
|
||||
// When hide_nsfw=false, max_intensity=5 (all content)
|
||||
const NSFW_OFF_INTENSITY = 2;
|
||||
const NSFW_ON_INTENSITY = 5;
|
||||
|
||||
export function useProfileSettings() {
|
||||
const { user, fetchUser } = useAuthStore();
|
||||
const { fetchPacks } = usePacksStore();
|
||||
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||
|
||||
// Name Editing
|
||||
@@ -59,10 +52,6 @@ export function useProfileSettings() {
|
||||
onUploadError: handleAvatarUploadError,
|
||||
});
|
||||
|
||||
// Hide NSFW
|
||||
const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw);
|
||||
const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false);
|
||||
|
||||
// Notifications
|
||||
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
||||
const [isUpdatingPush, setIsUpdatingPush] = useState(false);
|
||||
@@ -82,7 +71,6 @@ export function useProfileSettings() {
|
||||
setNewName(user.name);
|
||||
}
|
||||
setPushEnabled(!!user?.push_token);
|
||||
setHideNsfw(!!user?.hide_nsfw);
|
||||
}, [user]);
|
||||
|
||||
// Check biometric availability
|
||||
@@ -211,37 +199,6 @@ export function useProfileSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideNsfwToggle = async (value: boolean) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const previous = hideNsfw;
|
||||
setHideNsfw(value);
|
||||
setIsUpdatingHideNsfw(true);
|
||||
|
||||
// Derive intensity from hide_nsfw for backwards compatibility
|
||||
const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
|
||||
const showExplicitContent = !value;
|
||||
|
||||
try {
|
||||
await profileSettingsApi.updateProfile({
|
||||
hide_nsfw: value,
|
||||
max_intensity: maxIntensity,
|
||||
show_explicit_content: showExplicitContent,
|
||||
});
|
||||
|
||||
// Refresh user data and packs to reflect the change
|
||||
await fetchUser();
|
||||
await fetchPacks();
|
||||
Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setHideNsfw(previous);
|
||||
Alert.alert("Error", "Failed to update preference. Please try again.");
|
||||
} finally {
|
||||
setIsUpdatingHideNsfw(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscription Actions
|
||||
const handleRestorePurchases = async () => {
|
||||
const restored = await restorePurchases();
|
||||
@@ -270,8 +227,6 @@ export function useProfileSettings() {
|
||||
biometricEnabled: biometricEnabledState,
|
||||
biometricType,
|
||||
isUpdatingBiometric,
|
||||
hideNsfw,
|
||||
isUpdatingHideNsfw,
|
||||
showPaywall,
|
||||
setShowPaywall,
|
||||
isPurchasing,
|
||||
@@ -282,7 +237,6 @@ export function useProfileSettings() {
|
||||
handleAvatarPress,
|
||||
handlePushToggle,
|
||||
handleBiometricToggle,
|
||||
handleHideNsfwToggle,
|
||||
handleRestorePurchases,
|
||||
handleManageSubscription,
|
||||
};
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useProfileSettings } from '../hooks';
|
||||
import { ScreenHeader, PrivacySettings } from '../components';
|
||||
|
||||
/**
|
||||
* Preferences sub-screen for content and biometric settings.
|
||||
* App-lock settings shared by iOS and Android.
|
||||
*/
|
||||
export function PreferencesScreen() {
|
||||
const settings = useProfileSettings();
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScreenHeader title="Preferences" />
|
||||
<ScreenHeader title="App Lock" />
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
@@ -26,10 +26,7 @@ export function PreferencesScreen() {
|
||||
biometricType={settings.biometricType}
|
||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
||||
onBiometricToggle={settings.handleBiometricToggle}
|
||||
hideNsfw={settings.hideNsfw}
|
||||
isUpdatingHideNsfw={settings.isUpdatingHideNsfw}
|
||||
onHideNsfwToggle={settings.handleHideNsfwToggle}
|
||||
/>
|
||||
/>
|
||||
</ScrollView>
|
||||
</GradientBackground>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const SwipeNoPacksState = ({ onBrowsePacks }: SwipeNoPacksStateProps) =>
|
||||
badgeText="NO PACKS ENABLED"
|
||||
description="Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship."
|
||||
features={[
|
||||
{ icon: "flame-outline", text: "From playful to passionate", color: accent },
|
||||
{ icon: "flame-outline", text: "From playful to adventurous", color: accent },
|
||||
{ icon: "shield-checkmark-outline", text: "Safe space to explore", color: accent },
|
||||
{ icon: "infinite-outline", text: "New packs added regularly", color: accent },
|
||||
]}
|
||||
|
||||
@@ -38,10 +38,10 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
|
||||
}
|
||||
features={[
|
||||
{ icon: "lock-closed-outline", text: "Private and secure", color: accent },
|
||||
{ icon: "sparkles", text: "Discover hidden desires", color: accent },
|
||||
{ icon: "sparkles", text: "Discover new things together", color: accent },
|
||||
{ icon: "chatbubble-ellipses-outline", text: "Chat about your matches", color: accent },
|
||||
]}
|
||||
teaser={hasCouple ? "Your partner is just a code away" : "Begin your intimate journey together"}
|
||||
teaser={hasCouple ? "Your partner is just a code away" : "Begin your journey together"}
|
||||
action={{
|
||||
label: hasCouple ? "View Invite Code" : "Pair Now",
|
||||
onPress: onPairPress,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NotificationPreferences } from '../store/notificationPreferencesSt
|
||||
export interface ProfileUpdate {
|
||||
name?: string;
|
||||
gender?: Gender | null;
|
||||
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
|
||||
usage_reason?: 'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
|
||||
max_intensity?: 1 | 2 | 3 | 4 | 5;
|
||||
show_explicit_content?: boolean;
|
||||
hide_nsfw?: boolean;
|
||||
|
||||
@@ -16,13 +16,11 @@ interface PacksState {
|
||||
enabledPackIds: string[];
|
||||
packProgress: Map<string, PackProgressData>;
|
||||
isLoading: boolean;
|
||||
showAllIntensities: boolean;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
fetchPackProgress: () => Promise<void>;
|
||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
setShowAllIntensities: (value: boolean) => void;
|
||||
clearPacks: () => void;
|
||||
getPackProgress: (packId: string) => PackProgressData | undefined;
|
||||
}
|
||||
@@ -33,14 +31,12 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
enabledPackIds: [],
|
||||
packProgress: new Map(),
|
||||
isLoading: false,
|
||||
showAllIntensities: false,
|
||||
|
||||
fetchPacks: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const showAllIntensities = get().showAllIntensities;
|
||||
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
|
||||
`/v1/packs?showAllIntensities=${showAllIntensities}`,
|
||||
'/v1/packs?showAllIntensities=false',
|
||||
);
|
||||
set({ packs: catalog.packs, categories: catalog.categories });
|
||||
await get().fetchEnabledPacks();
|
||||
@@ -111,12 +107,6 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
setShowAllIntensities: (value: boolean) => {
|
||||
set({ showAllIntensities: value });
|
||||
// Refetch packs with new filter setting
|
||||
get().fetchPacks();
|
||||
},
|
||||
|
||||
fetchPackProgress: async () => {
|
||||
if (!useAuthStore.getState().user?.id) {
|
||||
set({ packProgress: new Map() });
|
||||
|
||||
Reference in new issue
Block a user