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,
|
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(),
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
// 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user