mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18: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
+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,
|
||||
|
||||
Reference in new issue
Block a user