diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index 670dcf4..468b9a0 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -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(), diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 054fc47..2de88f3 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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 | null>(), diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index fef1341..c44927c 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -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 */} + {/* Personalized pick based on the onboarding usage reason */} + {recommendedPacks.length > 0 && ( + setShowPaywall(true)} + /> + )} + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( { + 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() { Continue + + Skip for now + ); @@ -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() { - + diff --git a/apps/mobile/src/__tests__/features/home/recommendedPacks.test.ts b/apps/mobile/src/__tests__/features/home/recommendedPacks.test.ts new file mode 100644 index 0000000..4514d6d --- /dev/null +++ b/apps/mobile/src/__tests__/features/home/recommendedPacks.test.ts @@ -0,0 +1,89 @@ +import { pickRecommendedPacks, RECOMMENDED_PACKS_LIMIT } from "@/features/home/recommendedPacks"; +import type { Category, QuestionPack } from "@/types"; + +function makeCategory(overrides: Partial): 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 { + 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); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/avatarPromptSeen.test.ts b/apps/mobile/src/__tests__/lib/avatarPromptSeen.test.ts new file mode 100644 index 0000000..a7086f4 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/avatarPromptSeen.test.ts @@ -0,0 +1,34 @@ +import { hasSeenAvatarPrompt, markAvatarPromptSeen } from "@/lib/avatarPromptSeen"; + +jest.mock("expo-secure-store", () => { + const store = new Map(); + 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 }; + +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); + }); +}); diff --git a/apps/mobile/src/components/AvatarPromptCard.tsx b/apps/mobile/src/components/AvatarPromptCard.tsx new file mode 100644 index 0000000..47e260f --- /dev/null +++ b/apps/mobile/src/components/AvatarPromptCard.tsx @@ -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 = ({ visible, onAddPhoto, onDismiss }) => { + if (!visible) return null; + + return ( + + + + + + + + + Add a profile photo + + Let your partner see it's you + + + + + + Not now + + + Add photo + + + + + + ); +}; + +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; diff --git a/apps/mobile/src/features/home/recommendedPacks.ts b/apps/mobile/src/features/home/recommendedPacks.ts new file mode 100644 index 0000000..0380455 --- /dev/null +++ b/apps/mobile/src/features/home/recommendedPacks.ts @@ -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 = { + 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(); + 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; +} diff --git a/apps/mobile/src/features/swipe/SwipeScreen.tsx b/apps/mobile/src/features/swipe/SwipeScreen.tsx index ea620ac..661283e 100644 --- a/apps/mobile/src/features/swipe/SwipeScreen.tsx +++ b/apps/mobile/src/features/swipe/SwipeScreen.tsx @@ -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} /> + { + handleAvatarPromptAddPhoto(); + router.push('/(app)/settings/profile'); + }} + onDismiss={handleAvatarPromptDismiss} + /> + setShowPaywall(false)} diff --git a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts index 2561b34..d2b4c52 100644 --- a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts +++ b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts @@ -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(""); + 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, diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index bec04ab..3e41b6e 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -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"), diff --git a/apps/mobile/src/lib/avatarPromptSeen.ts b/apps/mobile/src/lib/avatarPromptSeen.ts new file mode 100644 index 0000000..b31d00e --- /dev/null +++ b/apps/mobile/src/lib/avatarPromptSeen.ts @@ -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 { + 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 { + 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 { + return getStorage(buildKey(userId)); +} + +export async function markAvatarPromptSeen(userId: string): Promise { + await setStorage(buildKey(userId), true); +} diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 08664e5..5b2947a 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -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 { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9668b34..49c5780 100755 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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;