mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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:
1 parent
f22d4a3649
commit
fd8981fa28
14 files changed
+493
-3
No files matched your search
@@ -32,6 +32,7 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
||||
is_premium: row.isPremium,
|
||||
couple_id: row.coupleId,
|
||||
gender: row.gender,
|
||||
usage_reason: row.usageReason,
|
||||
show_explicit_content: row.showExplicitContent,
|
||||
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
|
||||
@@ -10,6 +10,9 @@ export const profiles = pgTable('profiles', {
|
||||
isPremium: boolean('is_premium').notNull().default(false),
|
||||
coupleId: uuid('couple_id'),
|
||||
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),
|
||||
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import { colors, spacing, typography } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
import { pickRecommendedPacks, RECOMMENDED_PACKS_LABEL } from '../../src/features/home/recommendedPacks';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 600;
|
||||
|
||||
@@ -112,6 +113,13 @@ export default function DiscoveryScreen() {
|
||||
[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)
|
||||
const hasNoPacks = packs.length === 0;
|
||||
|
||||
@@ -169,6 +177,17 @@ export default function DiscoveryScreen() {
|
||||
{/* Quiz */}
|
||||
<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 */}
|
||||
{packsByCategory.map((group, index) => (
|
||||
<ContentRow
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Events } from '../../src/lib/analytics';
|
||||
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
|
||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||
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 }[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
|
||||
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
||||
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
|
||||
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
|
||||
@@ -94,6 +92,13 @@ export default function OnboardingScreen() {
|
||||
Events.onboardingStageComplete('avatar');
|
||||
};
|
||||
|
||||
const handleAvatarSkip = () => {
|
||||
setError(null);
|
||||
setStage('name');
|
||||
Events.avatarSkipped();
|
||||
Events.onboardingStageComplete('avatar');
|
||||
};
|
||||
|
||||
const handleNameSubmit = () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter your name');
|
||||
@@ -280,6 +285,13 @@ export default function OnboardingScreen() {
|
||||
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
|
||||
Continue
|
||||
</GlassButton>
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
onPress={handleAvatarSkip}
|
||||
testID="avatar-skip-button"
|
||||
>
|
||||
<Text style={styles.skipText}>Skip for now</Text>
|
||||
</Pressable>
|
||||
</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 { QuestionFeedbackModal } from "../../components/feedback";
|
||||
import { MatchConfetti } from "../../components/MatchConfetti";
|
||||
import { AvatarPromptCard } from "../../components/AvatarPromptCard";
|
||||
import { Paywall } from "../../components/paywall";
|
||||
import { useSwipeScreen } from "./hooks/useSwipeScreen";
|
||||
import { SwipeCardStack } from "./components/SwipeCardStack";
|
||||
@@ -32,6 +33,7 @@ const SwipeScreen = () => {
|
||||
showPaywall,
|
||||
packContext,
|
||||
showConfetti,
|
||||
showAvatarPrompt,
|
||||
countdown,
|
||||
user,
|
||||
partner,
|
||||
@@ -41,6 +43,8 @@ const SwipeScreen = () => {
|
||||
fetchQuestions,
|
||||
handleAnswer,
|
||||
handleConfettiComplete,
|
||||
handleAvatarPromptAddPhoto,
|
||||
handleAvatarPromptDismiss,
|
||||
getQuestionPackInfo,
|
||||
checkAnswerGap,
|
||||
setFeedbackQuestion,
|
||||
@@ -143,6 +147,15 @@ const SwipeScreen = () => {
|
||||
onAnimationComplete={handleConfettiComplete}
|
||||
/>
|
||||
|
||||
<AvatarPromptCard
|
||||
visible={showAvatarPrompt}
|
||||
onAddPhoto={() => {
|
||||
handleAvatarPromptAddPhoto();
|
||||
router.push('/(app)/settings/profile');
|
||||
}}
|
||||
onDismiss={handleAvatarPromptDismiss}
|
||||
/>
|
||||
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "../../../lib/avatarPromptSeen";
|
||||
import type { AnswerType } from "../../../types";
|
||||
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
|
||||
import {
|
||||
@@ -45,7 +46,9 @@ export const useSwipeScreen = () => {
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [showAvatarPrompt, setShowAvatarPrompt] = useState(false);
|
||||
const [countdown, setCountdown] = useState<string>("");
|
||||
const hasEvaluatedAvatarPrompt = useRef(false);
|
||||
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
@@ -387,9 +390,40 @@ export const useSwipeScreen = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
setShowConfetti(false);
|
||||
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(
|
||||
@@ -419,6 +453,7 @@ export const useSwipeScreen = () => {
|
||||
showPaywall,
|
||||
packContext,
|
||||
showConfetti,
|
||||
showAvatarPrompt,
|
||||
countdown,
|
||||
user,
|
||||
partner,
|
||||
@@ -428,6 +463,8 @@ export const useSwipeScreen = () => {
|
||||
fetchQuestions,
|
||||
handleAnswer,
|
||||
handleConfettiComplete,
|
||||
handleAvatarPromptAddPhoto,
|
||||
handleAvatarPromptDismiss,
|
||||
getQuestionPackInfo,
|
||||
checkAnswerGap,
|
||||
setFeedbackQuestion,
|
||||
|
||||
@@ -257,6 +257,10 @@ export const Events = {
|
||||
// Profile events
|
||||
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
|
||||
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
|
||||
notificationPermissionGranted: () => logEvent("notification_permission_granted"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// User profile (extends Supabase auth.users)
|
||||
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
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 {
|
||||
id: string;
|
||||
@@ -11,6 +19,8 @@ export interface Profile {
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Gender | null;
|
||||
/** Why the user came to Sauci, if they shared it during onboarding. */
|
||||
usage_reason?: UsageReason | null;
|
||||
show_explicit_content: boolean;
|
||||
max_intensity: IntensityLevel;
|
||||
hide_nsfw: boolean;
|
||||
|
||||
@@ -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)
|
||||
export interface Profile {
|
||||
id: string;
|
||||
@@ -8,6 +17,8 @@ export interface Profile {
|
||||
is_premium: boolean;
|
||||
couple_id: string | 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;
|
||||
max_intensity: 1 | 2 | 3 | 4 | 5;
|
||||
created_at: string;
|
||||
|
||||
Reference in new issue
Block a user