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:
jjbrunton committed 2026-08-28 14:44:51 +01:00
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,
+1 -1
View File
@@ -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;
+1 -11
View File
@@ -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() });