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
+567 -77

No files matched your search

@@ -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,