Merge skippable onboarding avatar with first-match prompt and usage-reason pack picks into staging

This commit is contained in:
jjbrunton committed 2026-08-30 19:49:23 +01:00
commit 3c08963a70
14 files changed
+567 -77

No files matched your search

+19
View File
@@ -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
+42 -30
View File
@@ -27,14 +27,14 @@ import { GlassButton } from '../../src/components/ui/GlassButton';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
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,17 +43,15 @@ 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' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' },
{ value: 'have_fun', label: 'Have fun together', icon: 'happy' },
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
export default function OnboardingScreen() {
const router = useRouter();
@@ -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');
@@ -129,9 +134,9 @@ export default function OnboardingScreen() {
setError('Please select what brings you to Sauci');
return;
}
setStage('notifications');
Events.onboardingStageComplete('purpose');
};
setStage('notifications');
Events.onboardingStageComplete('purpose');
};
const handleEnableNotifications = async () => {
setIsLoading(true);
@@ -169,26 +174,26 @@ export default function OnboardingScreen() {
}
}
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true,
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
};
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
};
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
@@ -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>
);
@@ -569,7 +581,7 @@ export default function OnboardingScreen() {
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('purpose');
if (stage === 'notifications') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
@@ -585,7 +597,7 @@ export default function OnboardingScreen() {
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</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,
+4
View File
@@ -259,6 +259,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"),
+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);
}
+57 -47
View File
@@ -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;
@@ -63,55 +73,55 @@ export interface QuestionPack {
}
// Individual question
export interface Question {
id: string;
pack_id: string;
text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
/** Type of question interaction (default: 'swipe') */
question_type?: QuestionType;
/** Configuration options for specific question types */
config?: QuestionConfig | null;
created_at: string;
}
// User's answer type
export type AnswerType = 'yes' | 'no' | 'maybe';
// Question type (default is 'swipe' for backwards compatibility)
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
// Question configuration options
export interface QuestionConfig {
max_duration_seconds?: number; // For audio questions (default: 60)
}
// Response data for non-swipe question types
export type ResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string };
// Match type when both partners agree
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
export interface Question {
id: string;
pack_id: string;
text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
/** Type of question interaction (default: 'swipe') */
question_type?: QuestionType;
/** Configuration options for specific question types */
config?: QuestionConfig | null;
created_at: string;
}
// User's answer type
export type AnswerType = 'yes' | 'no' | 'maybe';
// Question type (default is 'swipe' for backwards compatibility)
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
// Question configuration options
export interface QuestionConfig {
max_duration_seconds?: number; // For audio questions (default: 60)
}
// Response data for non-swipe question types
export type ResponseData =
| { type: 'text_answer'; text: string }
| { type: 'audio'; media_path: string; duration_seconds: number }
| { type: 'photo'; media_path: string }
| { type: 'who_likely'; chosen_user_id: string };
// Match type when both partners agree
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
// User response to a question
export interface Response {
id: string;
user_id: string;
question_id: string;
couple_id: string;
answer: AnswerType;
/** Additional response data for non-swipe question types */
response_data?: ResponseData | null;
created_at: string;
}
export interface Response {
id: string;
user_id: string;
question_id: string;
couple_id: string;
answer: AnswerType;
/** Additional response data for non-swipe question types */
response_data?: ResponseData | null;
created_at: string;
}
// Match between partners
export interface Match {