feat(onboarding): skippable avatar with first-match prompt and usage-reason pack picks

Lets new users skip the onboarding avatar step and instead defers the ask
to a lightweight, dismissible prompt shown once after the couple's first
match. Also personalizes the home screen's first pack recommendation using
the usage_reason already collected during onboarding, which now round-trips
through GET /v1/me.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:12:56 +01:00
1 parent f22d4a3649
commit fd8981fa28
14 files changed
+493 -3

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
is_premium: row.isPremium, is_premium: row.isPremium,
couple_id: row.coupleId, couple_id: row.coupleId,
gender: row.gender, gender: row.gender,
usage_reason: row.usageReason,
show_explicit_content: row.showExplicitContent, show_explicit_content: row.showExplicitContent,
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5, max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
created_at: row.createdAt.toISOString(), created_at: row.createdAt.toISOString(),
+3
View File
@@ -10,6 +10,9 @@ export const profiles = pgTable('profiles', {
isPremium: boolean('is_premium').notNull().default(false), isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'), coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(), gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
usageReason: text('usage_reason').$type<
'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null
>(),
showExplicitContent: boolean('show_explicit_content').notNull().default(true), showExplicitContent: boolean('show_explicit_content').notNull().default(true),
maxIntensity: integer('max_intensity').notNull().default(5), maxIntensity: integer('max_intensity').notNull().default(5),
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(), publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
+19
View File
@@ -13,6 +13,7 @@ import { StreakDisplay } from '../../src/components/StreakDisplay';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme'; import { colors, spacing, typography } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types'; import type { QuestionPack, Category } from '../../src/types';
import { pickRecommendedPacks, RECOMMENDED_PACKS_LABEL } from '../../src/features/home/recommendedPacks';
const MAX_CONTENT_WIDTH = 600; const MAX_CONTENT_WIDTH = 600;
@@ -112,6 +113,13 @@ export default function DiscoveryScreen() {
[packs, categories] [packs, categories]
); );
// A small, cheap personalization: highlight packs that match what the user
// said brought them to Sauci during onboarding, ahead of the full catalog.
const recommendedPacks = useMemo(
() => pickRecommendedPacks(packs, categories, user?.usage_reason ?? null),
[packs, categories, user?.usage_reason]
);
// Check if user has no packs enabled (empty state) // Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0; const hasNoPacks = packs.length === 0;
@@ -169,6 +177,17 @@ export default function DiscoveryScreen() {
{/* Quiz */} {/* Quiz */}
<QuizTile delay={225} /> <QuizTile delay={225} />
{/* Personalized pick based on the onboarding usage reason */}
{recommendedPacks.length > 0 && (
<ContentRow
title={RECOMMENDED_PACKS_LABEL}
packs={recommendedPacks}
isPremiumUser={isPremiumUser}
delay={240}
onShowPaywall={() => setShowPaywall(true)}
/>
)}
{/* Content Rows by Category */} {/* Content Rows by Category */}
{packsByCategory.map((group, index) => ( {packsByCategory.map((group, index) => (
<ContentRow <ContentRow
+15 -3
View File
@@ -34,7 +34,7 @@ import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen'; import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker'; import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding'; import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import type { Gender } from '../../src/types'; import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' }, { value: 'male', label: 'Male', icon: 'male' },
@@ -43,8 +43,6 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' }, { value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
]; ];
type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' }, { value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' }, { value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
@@ -94,6 +92,13 @@ export default function OnboardingScreen() {
Events.onboardingStageComplete('avatar'); Events.onboardingStageComplete('avatar');
}; };
const handleAvatarSkip = () => {
setError(null);
setStage('name');
Events.avatarSkipped();
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => { const handleNameSubmit = () => {
if (!name.trim()) { if (!name.trim()) {
setError('Please enter your name'); setError('Please enter your name');
@@ -280,6 +285,13 @@ export default function OnboardingScreen() {
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg"> <GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue Continue
</GlassButton> </GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View> </View>
</Animated.View> </Animated.View>
); );
@@ -0,0 +1,89 @@
import { pickRecommendedPacks, RECOMMENDED_PACKS_LIMIT } from "@/features/home/recommendedPacks";
import type { Category, QuestionPack } from "@/types";
function makeCategory(overrides: Partial<Category>): Category {
return {
id: "cat-1",
name: "Category",
description: null,
icon: null,
color: null,
sort_order: 0,
created_at: "",
is_public: true,
...overrides,
};
}
function makePack(overrides: Partial<QuestionPack>): QuestionPack {
return {
id: "pack-1",
name: "Pack",
description: null,
icon: null,
is_premium: false,
is_public: true,
is_explicit: false,
sort_order: 0,
category_id: null,
...overrides,
} as QuestionPack;
}
describe("pickRecommendedPacks", () => {
const categories: Category[] = [
makeCategory({ id: "quality-time", name: "Quality Time" }),
makeCategory({ id: "romance", name: "Romance & Sensuality" }),
makeCategory({ id: "kink-lab", name: "The Kink Lab" }),
makeCategory({ id: "adventure", name: "Adventure & Travel" }),
];
const packs: QuestionPack[] = [
makePack({ id: "p-quality-1", category_id: "quality-time" }),
makePack({ id: "p-quality-2", category_id: "quality-time" }),
makePack({ id: "p-romance-1", category_id: "romance" }),
makePack({ id: "p-kink-1", category_id: "kink-lab" }),
makePack({ id: "p-adventure-1", category_id: "adventure" }),
makePack({ id: "p-uncategorized", category_id: null }),
];
it("returns an empty list when there is no usage reason", () => {
expect(pickRecommendedPacks(packs, categories, null)).toEqual([]);
expect(pickRecommendedPacks(packs, categories, undefined)).toEqual([]);
});
it("prioritizes packs from the categories mapped to the usage reason, in order", () => {
const result = pickRecommendedPacks(packs, categories, "reconnect");
// "reconnect" maps to quality time, long distance, romance (in that order);
// only quality time and romance exist in this catalog.
expect(result.map((pack) => pack.id)).toEqual([
"p-quality-1",
"p-quality-2",
"p-romance-1",
]);
});
it("matches a different usage reason to a different category set", () => {
const result = pickRecommendedPacks(packs, categories, "spice_up_intimacy");
expect(result.map((pack) => pack.id)).toEqual(["p-kink-1"]);
});
it("returns an empty list when no catalog category matches the mapped keywords", () => {
const noMatchCategories: Category[] = [makeCategory({ id: "misc", name: "Miscellaneous" })];
const result = pickRecommendedPacks(packs, noMatchCategories, "have_fun");
expect(result).toEqual([]);
});
it("caps the recommended list at RECOMMENDED_PACKS_LIMIT", () => {
const manyPacks = Array.from({ length: RECOMMENDED_PACKS_LIMIT + 5 }, (_, i) =>
makePack({ id: `p-${i}`, category_id: "quality-time" })
);
const result = pickRecommendedPacks(manyPacks, categories, "strengthen_relationship");
expect(result).toHaveLength(RECOMMENDED_PACKS_LIMIT);
});
});
@@ -0,0 +1,34 @@
import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "@/lib/avatarPromptSeen";
jest.mock("expo-secure-store", () => {
const store = new Map<string, string>();
return {
__store: store,
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
setItemAsync: jest.fn(async (key: string, value: string) => { store.set(key, value); }),
};
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const secureStore = require("expo-secure-store") as { __store: Map<string, string> };
describe("avatarPromptSeen", () => {
beforeEach(() => {
secureStore.__store.clear();
});
it("reports unseen until marked seen for that user", async () => {
expect(await hasSeenAvatarPrompt("user-1")).toBe(false);
await markAvatarPromptSeen("user-1");
expect(await hasSeenAvatarPrompt("user-1")).toBe(true);
});
it("tracks the seen flag per user, so a shared device does not skip the prompt for a new account", async () => {
await markAvatarPromptSeen("user-1");
expect(await hasSeenAvatarPrompt("user-1")).toBe(true);
expect(await hasSeenAvatarPrompt("user-2")).toBe(false);
});
});
@@ -0,0 +1,125 @@
/**
* AvatarPromptCard - Lightweight, dismissible nudge shown once after a
* couple's first match, inviting a user with no profile photo to add one.
* Renders after the match celebration completes, never on top of it.
*/
import React from 'react';
import { StyleSheet, View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInUp, FadeOutDown } from 'react-native-reanimated';
import { GlassCard } from './ui/GlassCard';
import { colors, spacing, radius, typography } from '../theme';
interface Props {
visible: boolean;
onAddPhoto: () => void;
onDismiss: () => void;
}
export const AvatarPromptCard: React.FC<Props> = ({ visible, onAddPhoto, onDismiss }) => {
if (!visible) return null;
return (
<View style={styles.wrapper} pointerEvents="box-none">
<Animated.View
entering={FadeInUp.duration(400)}
exiting={FadeOutDown.duration(250)}
>
<GlassCard style={styles.card} variant="elevated">
<View style={styles.row}>
<View style={styles.iconCircle}>
<Ionicons name="camera" size={20} color={colors.primary} />
</View>
<View style={styles.textColumn}>
<Text style={styles.title}>Add a profile photo</Text>
<Text style={styles.subtitle}>
Let your partner see it's you
</Text>
</View>
</View>
<View style={styles.actions}>
<Pressable
style={styles.notNowButton}
onPress={onDismiss}
testID="avatar-prompt-not-now"
>
<Text style={styles.notNowText}>Not now</Text>
</Pressable>
<Pressable
style={styles.addPhotoButton}
onPress={onAddPhoto}
testID="avatar-prompt-add-photo"
>
<Text style={styles.addPhotoText}>Add photo</Text>
</Pressable>
</View>
</GlassCard>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.xxl + spacing.lg,
zIndex: 20,
},
card: {
marginBottom: 0,
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.md,
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
textColumn: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
},
subtitle: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
notNowButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
notNowText: {
...typography.subhead,
color: colors.textSecondary,
},
addPhotoButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.primary,
},
addPhotoText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
export default AvatarPromptCard;
@@ -0,0 +1,78 @@
import type { Category, QuestionPack, UsageReason } from '../../types';
/**
* Category-name keywords to prioritize for each onboarding usage reason,
* matched case-insensitively against the couple's actual catalog. Ordered
* from most to least relevant so the first matching category's packs lead
* the recommended row.
*
* Keep this in sync with the categories seeded in the content catalog
* (see apps/supabase/content-seed.sql) - it degrades gracefully to no
* recommendation if none of the keywords match anything in the catalog.
*/
export const USAGE_REASON_CATEGORY_KEYWORDS: Record<UsageReason, string[]> = {
improve_communication: ['getting started', 'quality time', 'social life'],
reconnect: ['quality time', 'long distance', 'romance'],
deeper_connection: ['romance', 'sensual discovery', 'getting started'],
have_fun: ['adventure', 'spicy challenges', 'roleplay', 'public thrills'],
strengthen_relationship: ['quality time', 'romance', 'getting started'],
spice_up_intimacy: ['bedroom adventures', 'fantasy exploration', 'the kink lab', 'toy time'],
};
export const RECOMMENDED_PACKS_LABEL = 'Picked for how you want to use Sauci';
/** Caps the recommended row so it stays a highlight, not a full re-listing of the catalog. */
export const RECOMMENDED_PACKS_LIMIT = 8;
/**
* Picks a personalized set of packs based on the usage reason collected during
* onboarding. Pure and side-effect free so it can be unit tested directly.
* Returns an empty array whenever there is nothing to recommend (no reason on
* file, or no catalog category matches its keywords) so callers can simply
* skip rendering the row.
*/
export function pickRecommendedPacks(
packs: QuestionPack[],
categories: Category[],
usageReason: UsageReason | null | undefined,
): QuestionPack[] {
if (!usageReason) return [];
const keywords = USAGE_REASON_CATEGORY_KEYWORDS[usageReason];
if (!keywords || keywords.length === 0) return [];
const orderedCategoryIds: string[] = [];
for (const keyword of keywords) {
for (const category of categories) {
if (
category.name.toLowerCase().includes(keyword) &&
!orderedCategoryIds.includes(category.id)
) {
orderedCategoryIds.push(category.id);
}
}
}
if (orderedCategoryIds.length === 0) return [];
const packsByCategoryId = new Map<string, QuestionPack[]>();
for (const pack of packs) {
if (!pack.category_id) continue;
const list = packsByCategoryId.get(pack.category_id) ?? [];
list.push(pack);
packsByCategoryId.set(pack.category_id, list);
}
const recommended: QuestionPack[] = [];
for (const categoryId of orderedCategoryIds) {
if (recommended.length >= RECOMMENDED_PACKS_LIMIT) break;
const categoryPacks = packsByCategoryId.get(categoryId) ?? [];
for (const pack of categoryPacks) {
if (recommended.length >= RECOMMENDED_PACKS_LIMIT) break;
if (!recommended.some((existing) => existing.id === pack.id)) {
recommended.push(pack);
}
}
}
return recommended;
}
@@ -4,6 +4,7 @@ import { useRouter } from "expo-router";
import { GradientBackground } from "../../components/ui"; import { GradientBackground } from "../../components/ui";
import { QuestionFeedbackModal } from "../../components/feedback"; import { QuestionFeedbackModal } from "../../components/feedback";
import { MatchConfetti } from "../../components/MatchConfetti"; import { MatchConfetti } from "../../components/MatchConfetti";
import { AvatarPromptCard } from "../../components/AvatarPromptCard";
import { Paywall } from "../../components/paywall"; import { Paywall } from "../../components/paywall";
import { useSwipeScreen } from "./hooks/useSwipeScreen"; import { useSwipeScreen } from "./hooks/useSwipeScreen";
import { SwipeCardStack } from "./components/SwipeCardStack"; import { SwipeCardStack } from "./components/SwipeCardStack";
@@ -32,6 +33,7 @@ const SwipeScreen = () => {
showPaywall, showPaywall,
packContext, packContext,
showConfetti, showConfetti,
showAvatarPrompt,
countdown, countdown,
user, user,
partner, partner,
@@ -41,6 +43,8 @@ const SwipeScreen = () => {
fetchQuestions, fetchQuestions,
handleAnswer, handleAnswer,
handleConfettiComplete, handleConfettiComplete,
handleAvatarPromptAddPhoto,
handleAvatarPromptDismiss,
getQuestionPackInfo, getQuestionPackInfo,
checkAnswerGap, checkAnswerGap,
setFeedbackQuestion, setFeedbackQuestion,
@@ -143,6 +147,15 @@ const SwipeScreen = () => {
onAnimationComplete={handleConfettiComplete} onAnimationComplete={handleConfettiComplete}
/> />
<AvatarPromptCard
visible={showAvatarPrompt}
onAddPhoto={() => {
handleAvatarPromptAddPhoto();
router.push('/(app)/settings/profile');
}}
onDismiss={handleAvatarPromptDismiss}
/>
<Paywall <Paywall
visible={showPaywall} visible={showPaywall}
onClose={() => setShowPaywall(false)} onClose={() => setShowPaywall(false)}
@@ -5,6 +5,7 @@ import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions"; import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient"; import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics"; import { Events } from "../../../lib/analytics";
import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "../../../lib/avatarPromptSeen";
import type { AnswerType } from "../../../types"; import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types"; import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
import { import {
@@ -45,7 +46,9 @@ export const useSwipeScreen = () => {
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null); const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [showAvatarPrompt, setShowAvatarPrompt] = useState(false);
const [countdown, setCountdown] = useState<string>(""); const [countdown, setCountdown] = useState<string>("");
const hasEvaluatedAvatarPrompt = useRef(false);
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore(); const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore(); const { user, partner, couple } = useAuthStore();
@@ -387,9 +390,40 @@ export const useSwipeScreen = () => {
} }
}, [checkAnswerGap, checkDailyLimit, currentIndex, dailyLimitInfo, filteredQuestions, mode, user?.id]); }, [checkAnswerGap, checkDailyLimit, currentIndex, dailyLimitInfo, filteredQuestions, mode, user?.id]);
const maybeShowAvatarPrompt = useCallback(async () => {
const currentUser = useAuthStore.getState().user;
if (!currentUser?.id || currentUser.avatar_url) return;
const alreadySeen = await hasSeenAvatarPrompt(currentUser.id);
if (alreadySeen) return;
// Marked seen as soon as it is shown, so the prompt appears at most once
// even if the user leaves the app before responding to it.
await markAvatarPromptSeen(currentUser.id);
setShowAvatarPrompt(true);
Events.avatarPromptShown('first_match');
}, []);
const handleConfettiComplete = useCallback(() => { const handleConfettiComplete = useCallback(() => {
setShowConfetti(false); setShowConfetti(false);
setCurrentIndex(prev => prev + 1); setCurrentIndex(prev => prev + 1);
// Deferred avatar ask: offered once, right after the couple's first match
// celebration finishes, never on top of it.
if (!hasEvaluatedAvatarPrompt.current) {
hasEvaluatedAvatarPrompt.current = true;
void maybeShowAvatarPrompt();
}
}, [maybeShowAvatarPrompt]);
const handleAvatarPromptAddPhoto = useCallback(() => {
setShowAvatarPrompt(false);
Events.avatarPromptAccepted('first_match');
}, []);
const handleAvatarPromptDismiss = useCallback(() => {
setShowAvatarPrompt(false);
Events.avatarPromptDismissed('first_match');
}, []); }, []);
const getQuestionPackInfo = useCallback( const getQuestionPackInfo = useCallback(
@@ -419,6 +453,7 @@ export const useSwipeScreen = () => {
showPaywall, showPaywall,
packContext, packContext,
showConfetti, showConfetti,
showAvatarPrompt,
countdown, countdown,
user, user,
partner, partner,
@@ -428,6 +463,8 @@ export const useSwipeScreen = () => {
fetchQuestions, fetchQuestions,
handleAnswer, handleAnswer,
handleConfettiComplete, handleConfettiComplete,
handleAvatarPromptAddPhoto,
handleAvatarPromptDismiss,
getQuestionPackInfo, getQuestionPackInfo,
checkAnswerGap, checkAnswerGap,
setFeedbackQuestion, setFeedbackQuestion,
+4
View File
@@ -257,6 +257,10 @@ export const Events = {
// Profile events // Profile events
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }), profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
avatarUploaded: () => logEvent("avatar_uploaded"), avatarUploaded: () => logEvent("avatar_uploaded"),
avatarSkipped: () => logEvent("avatar_skipped"),
avatarPromptShown: (source: string) => logEvent("avatar_prompt_shown", { source }),
avatarPromptAccepted: (source: string) => logEvent("avatar_prompt_accepted", { source }),
avatarPromptDismissed: (source: string) => logEvent("avatar_prompt_dismissed", { source }),
// Notification events // Notification events
notificationPermissionGranted: () => logEvent("notification_permission_granted"), notificationPermissionGranted: () => logEvent("notification_permission_granted"),
+54
View File
@@ -0,0 +1,54 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "avatar_prompt_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(key);
}
} else {
data = await SecureStore.getItemAsync(key);
}
return data === "true";
} catch (error) {
console.error("Error reading avatar prompt state:", error);
}
return false;
}
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data);
}
} else {
await SecureStore.setItemAsync(key, data);
}
} catch (error) {
console.error("Error saving avatar prompt state:", error);
}
}
/**
* Whether this user has already been shown the deferred avatar prompt (at the
* first-match moment), regardless of whether they added a photo or dismissed it.
* The prompt is shown at most once per user.
*/
export async function hasSeenAvatarPrompt(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markAvatarPromptSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+10
View File
@@ -1,6 +1,14 @@
// User profile (extends Supabase auth.users) // User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export type IntensityLevel = 1 | 2 | 3 | 4 | 5; export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
/** Why the user says they came to Sauci, collected during onboarding. */
export type UsageReason =
| 'improve_communication'
| 'reconnect'
| 'spice_up_intimacy'
| 'deeper_connection'
| 'have_fun'
| 'strengthen_relationship';
export interface Profile { export interface Profile {
id: string; id: string;
@@ -11,6 +19,8 @@ export interface Profile {
is_premium: boolean; is_premium: boolean;
couple_id: string | null; couple_id: string | null;
gender: Gender | null; gender: Gender | null;
/** Why the user came to Sauci, if they shared it during onboarding. */
usage_reason?: UsageReason | null;
show_explicit_content: boolean; show_explicit_content: boolean;
max_intensity: IntensityLevel; max_intensity: IntensityLevel;
hide_nsfw: boolean; hide_nsfw: boolean;
+11
View File
@@ -1,3 +1,12 @@
/** Why the user says they came to Sauci, collected during onboarding. */
export type UsageReason =
| 'improve_communication'
| 'reconnect'
| 'spice_up_intimacy'
| 'deeper_connection'
| 'have_fun'
| 'strengthen_relationship';
// User profile (extends Supabase auth.users) // User profile (extends Supabase auth.users)
export interface Profile { export interface Profile {
id: string; id: string;
@@ -8,6 +17,8 @@ export interface Profile {
is_premium: boolean; is_premium: boolean;
couple_id: string | null; couple_id: string | null;
gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null; gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
/** Why the user came to Sauci, if they shared it during onboarding. */
usage_reason?: UsageReason | null;
show_explicit_content: boolean; show_explicit_content: boolean;
max_intensity: 1 | 2 | 3 | 4 | 5; max_intensity: 1 | 2 | 3 | 4 | 5;
created_at: string; created_at: string;