mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
update
This commit is contained in:
1 parent
c978a3bd28
commit
0f229211ea
19 files changed
+1625
-624
No files matched your search
@@ -41,6 +41,16 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-firebase/app",
|
||||
"@react-native-firebase/analytics",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"useFrameworks": "static"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-router",
|
||||
"expo-apple-authentication",
|
||||
[
|
||||
|
||||
@@ -14,6 +14,7 @@ import { decode } from 'base64-arraybuffer';
|
||||
import { Database } from "../../../src/types/supabase";
|
||||
import { GradientBackground } from "../../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography } from "../../../src/theme";
|
||||
import { Events } from "../../../src/lib/analytics";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -205,6 +206,8 @@ export default function ChatScreen() {
|
||||
if (error) {
|
||||
Alert.alert("Error", "Failed to send message");
|
||||
setInputText(content);
|
||||
} else {
|
||||
Events.messageSent();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -283,6 +286,7 @@ export default function ChatScreen() {
|
||||
user_id: user.id,
|
||||
media_path: fileName,
|
||||
});
|
||||
Events.mediaUploaded();
|
||||
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to upload image");
|
||||
|
||||
@@ -28,7 +28,9 @@ import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||
import { useAuthStore } from '../../src/store';
|
||||
import { supabase } from '../../src/lib/supabase';
|
||||
import { getProfileError } from '../../src/lib/errors';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import type { Gender } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
@@ -59,6 +61,7 @@ export default function OnboardingScreen() {
|
||||
Keyboard.dismiss();
|
||||
setError(null);
|
||||
setStage('gender');
|
||||
Events.onboardingStageComplete('name');
|
||||
};
|
||||
|
||||
const handleGenderSelect = (selectedGender: Gender) => {
|
||||
@@ -72,10 +75,12 @@ export default function OnboardingScreen() {
|
||||
return;
|
||||
}
|
||||
setStage('explicit');
|
||||
Events.onboardingStageComplete('gender');
|
||||
};
|
||||
|
||||
const handleExplicitContinue = () => {
|
||||
setStage('notifications');
|
||||
Events.onboardingStageComplete('explicit');
|
||||
};
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
@@ -124,10 +129,11 @@ export default function OnboardingScreen() {
|
||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||
await fetchUser();
|
||||
console.log('[Onboarding] User fetched, navigating to home...');
|
||||
Events.onboardingComplete();
|
||||
router.replace('/');
|
||||
} catch (err: any) {
|
||||
console.error('Onboarding error:', err);
|
||||
setError(err.message || 'Something went wrong');
|
||||
setError(getProfileError(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { getPairingError } from "../../src/lib/errors";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { router } from "expo-router";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
@@ -81,8 +83,9 @@ export default function PairingScreen() {
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
Events.coupleCreated();
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", error.message);
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export default function PairingScreen() {
|
||||
// Validate invite code format (8 alphanumeric characters)
|
||||
const sanitizedCode = inviteCode.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
|
||||
Alert.alert("Error", "Please enter a valid 8-character invite code");
|
||||
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,12 +121,13 @@ export default function PairingScreen() {
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
Events.coupleJoined();
|
||||
|
||||
Alert.alert("Success", "You are now paired!", [
|
||||
{ text: "Let's Go", onPress: () => router.replace("/(app)/") }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", error.message);
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -142,6 +146,7 @@ export default function PairingScreen() {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -169,8 +174,9 @@ export default function PairingScreen() {
|
||||
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
Events.pairingCancelled();
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", error.message);
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function ProfileScreen() {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? false);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
||||
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
|
||||
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
||||
const [biometricEnabled, setBiometricEnabledState] = useState(false);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipe
|
||||
import SwipeCard from "../../src/components/SwipeCard";
|
||||
import SwipeTutorial from "../../src/components/SwipeTutorial";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
export default function SwipeScreen() {
|
||||
@@ -102,6 +103,7 @@ export default function SwipeScreen() {
|
||||
// Handle skip - don't submit a response, just store it for later
|
||||
if (direction === "down") {
|
||||
await skipQuestion(question.id);
|
||||
Events.questionSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +129,8 @@ export default function SwipeScreen() {
|
||||
|
||||
if (error) {
|
||||
console.error("Submit response error:", error);
|
||||
} else {
|
||||
Events.questionAnswered(answer, question.pack_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit response", error);
|
||||
|
||||
@@ -17,6 +17,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { getAuthError } from "../../src/lib/errors";
|
||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
|
||||
@@ -71,7 +72,7 @@ export default function ForgotPasswordScreen() {
|
||||
setIsLoading(false);
|
||||
|
||||
if (resetError) {
|
||||
showError(resetError.message);
|
||||
showError(getAuthError(resetError));
|
||||
} else {
|
||||
setIsEmailSent(true);
|
||||
setResendCooldown(60);
|
||||
@@ -92,7 +93,7 @@ export default function ForgotPasswordScreen() {
|
||||
|
||||
if (resendError) {
|
||||
if (Platform.OS !== 'web') {
|
||||
Alert.alert("Error", resendError.message);
|
||||
Alert.alert("Error", getAuthError(resendError));
|
||||
}
|
||||
} else {
|
||||
setResendCooldown(60);
|
||||
|
||||
@@ -29,6 +29,8 @@ if (Platform.OS === "ios") {
|
||||
}
|
||||
}
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { getAuthError } from "../../src/lib/errors";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
@@ -96,7 +98,7 @@ export default function LoginScreen() {
|
||||
|
||||
if (resendError) {
|
||||
if (Platform.OS !== 'web') {
|
||||
Alert.alert("Error", resendError.message);
|
||||
Alert.alert("Error", getAuthError(resendError));
|
||||
}
|
||||
} else {
|
||||
// Start 60 second cooldown
|
||||
@@ -125,7 +127,7 @@ export default function LoginScreen() {
|
||||
Alert.alert("Not yet verified", "Please check your email and click the verification link.");
|
||||
}
|
||||
} else {
|
||||
Alert.alert("Error", signInError.message);
|
||||
Alert.alert("Error", getAuthError(signInError));
|
||||
}
|
||||
}
|
||||
// If successful, the auth state change will handle the redirect
|
||||
@@ -175,9 +177,10 @@ export default function LoginScreen() {
|
||||
setIsLoading(false);
|
||||
|
||||
if (authError) {
|
||||
showError(authError.message);
|
||||
showError(getAuthError(authError));
|
||||
} else {
|
||||
setIsMagicLinkSent(true);
|
||||
Events.signIn("magic_link");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,7 +210,7 @@ export default function LoginScreen() {
|
||||
setIsLoading(false);
|
||||
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
showError(getAuthError(signUpError));
|
||||
} else if (data.user && data.user.identities && data.user.identities.length === 0) {
|
||||
// User already exists - Supabase returns success but empty identities
|
||||
// for security (prevents email enumeration)
|
||||
@@ -218,6 +221,7 @@ export default function LoginScreen() {
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
Events.signUp("password");
|
||||
}
|
||||
} else {
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
@@ -227,7 +231,9 @@ export default function LoginScreen() {
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (signInError) {
|
||||
if (!signInError) {
|
||||
Events.signIn("password");
|
||||
} else if (signInError) {
|
||||
// Check if the error is due to unverified email
|
||||
if (signInError.message.includes("Email not confirmed")) {
|
||||
// Show the verification pending screen
|
||||
@@ -236,7 +242,7 @@ export default function LoginScreen() {
|
||||
password,
|
||||
});
|
||||
} else {
|
||||
showError(signInError.message);
|
||||
showError(getAuthError(signInError));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +270,9 @@ export default function LoginScreen() {
|
||||
|
||||
if (error) {
|
||||
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||
Alert.alert("Error", error.message);
|
||||
Alert.alert("Error", getAuthError(error));
|
||||
} else {
|
||||
Events.signIn("apple");
|
||||
}
|
||||
} else {
|
||||
Alert.alert("Error", "No identity token received from Apple");
|
||||
@@ -274,7 +282,7 @@ export default function LoginScreen() {
|
||||
if (e.code === "ERR_REQUEST_CANCELED") {
|
||||
// User canceled - do nothing
|
||||
} else {
|
||||
Alert.alert("Apple Sign In Error", `${e.code || 'Unknown'}: ${e.message || "Apple sign in failed"}`);
|
||||
Alert.alert("Error", "Apple sign in failed. Please try again.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -288,7 +296,7 @@ export default function LoginScreen() {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", error.message);
|
||||
Alert.alert("Error", getAuthError(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { getAuthError } from "../../src/lib/errors";
|
||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
|
||||
@@ -59,14 +60,7 @@ export default function ResetPasswordScreen() {
|
||||
setIsLoading(false);
|
||||
|
||||
if (updateError) {
|
||||
// Handle common errors
|
||||
if (updateError.message.includes("same as")) {
|
||||
showError("New password must be different from your current password");
|
||||
} else if (updateError.message.includes("session")) {
|
||||
showError("Your reset link has expired. Please request a new one.");
|
||||
} else {
|
||||
showError(updateError.message);
|
||||
}
|
||||
showError(getAuthError(updateError));
|
||||
} else {
|
||||
setIsSuccess(true);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import "../src/polyfills/web";
|
||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||
initSentry();
|
||||
|
||||
// Initialize Firebase Analytics
|
||||
import { initAnalytics, setUserId, clearUserId } from "../src/lib/analytics";
|
||||
initAnalytics();
|
||||
|
||||
// Import push notification utilities
|
||||
import {
|
||||
clearPushToken,
|
||||
@@ -153,6 +157,8 @@ export default function RootLayout() {
|
||||
// Set Sentry user context for error tracking
|
||||
if (session?.user) {
|
||||
setUserContext(session.user.id, session.user.email);
|
||||
// Set Analytics user ID
|
||||
setUserId(session.user.id);
|
||||
}
|
||||
} else if (event === "SIGNED_OUT") {
|
||||
// Clear push token before clearing user state
|
||||
@@ -162,6 +168,7 @@ export default function RootLayout() {
|
||||
}
|
||||
setUser(null);
|
||||
clearUserContext();
|
||||
clearUserId();
|
||||
// Clear React Query cache
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@expo/vector-icons": "~14.0.4",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-firebase/analytics": "^23.7.0",
|
||||
"@react-native-firebase/app": "^23.7.0",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@react-navigation/native-stack": "^7.0.0",
|
||||
@@ -25,6 +27,7 @@
|
||||
"expo": "~52.0.0",
|
||||
"expo-apple-authentication": "~7.1.3",
|
||||
"expo-blur": "~14.0.3",
|
||||
"expo-build-properties": "^0.13.3",
|
||||
"expo-clipboard": "~7.0.1",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-dev-client": "~5.0.20",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useSubscriptionStore } from "../store";
|
||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||
import type { PurchasesPackage } from "../lib/revenuecat";
|
||||
import { Events } from "../lib/analytics";
|
||||
|
||||
interface PaywallProps {
|
||||
visible: boolean;
|
||||
@@ -58,6 +59,9 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
if (visible && !offerings) {
|
||||
fetchOfferings();
|
||||
}
|
||||
if (visible) {
|
||||
Events.paywallShown("paywall");
|
||||
}
|
||||
}, [visible, offerings, fetchOfferings]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,8 +77,10 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedPackage) return;
|
||||
|
||||
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
|
||||
const success = await purchasePackage(selectedPackage);
|
||||
if (success) {
|
||||
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}
|
||||
@@ -83,6 +89,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
const handleRestore = async () => {
|
||||
const restored = await restorePurchases();
|
||||
if (restored) {
|
||||
Events.purchaseRestored();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import analytics from "@react-native-firebase/analytics";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export function initAnalytics() {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Disabled in development mode");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
console.log("[Analytics] Firebase Analytics initialized");
|
||||
}
|
||||
|
||||
// Log a custom event
|
||||
export async function logEvent(
|
||||
name: string,
|
||||
params?: Record<string, string | number | boolean>
|
||||
) {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Event:", name, params);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await analytics().logEvent(name, params);
|
||||
} catch (error) {
|
||||
console.warn("[Analytics] Failed to log event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set user ID for tracking
|
||||
export async function setUserId(userId: string) {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Set user ID:", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await analytics().setUserId(userId);
|
||||
} catch (error) {
|
||||
console.warn("[Analytics] Failed to set user ID:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear user ID on logout
|
||||
export async function clearUserId() {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Cleared user ID");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await analytics().setUserId(null);
|
||||
} catch (error) {
|
||||
console.warn("[Analytics] Failed to clear user ID:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set user properties
|
||||
export async function setUserProperties(
|
||||
properties: Record<string, string | null>
|
||||
) {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Set user properties:", properties);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
await analytics().setUserProperty(key, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Analytics] Failed to set user properties:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Log screen view
|
||||
export async function logScreenView(screenName: string, screenClass?: string) {
|
||||
if (__DEV__) {
|
||||
console.log("[Analytics] Screen view:", screenName);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await analytics().logScreenView({
|
||||
screen_name: screenName,
|
||||
screen_class: screenClass ?? screenName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[Analytics] Failed to log screen view:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-defined event helpers for common actions
|
||||
export const Events = {
|
||||
// Auth events
|
||||
signUp: (method: string) => logEvent("sign_up", { method }),
|
||||
signIn: (method: string) => logEvent("login", { method }),
|
||||
signOut: () => logEvent("sign_out"),
|
||||
|
||||
// Onboarding events
|
||||
onboardingStart: () => logEvent("onboarding_start"),
|
||||
onboardingStageComplete: (stage: string) =>
|
||||
logEvent("onboarding_stage_complete", { stage }),
|
||||
onboardingComplete: () => logEvent("onboarding_complete"),
|
||||
|
||||
// Pairing events
|
||||
coupleCreated: () => logEvent("couple_created"),
|
||||
coupleJoined: () => logEvent("couple_joined"),
|
||||
codeShared: () => logEvent("invite_code_shared"),
|
||||
pairingCancelled: () => logEvent("pairing_cancelled"),
|
||||
|
||||
// Swipe/question events
|
||||
questionAnswered: (answer: string, packId?: string) =>
|
||||
logEvent("question_answered", { answer, pack_id: packId ?? "unknown" }),
|
||||
questionSkipped: () => logEvent("question_skipped"),
|
||||
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
|
||||
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
|
||||
|
||||
// Chat events
|
||||
matchViewed: () => logEvent("match_viewed"),
|
||||
messageSent: () => logEvent("message_sent"),
|
||||
mediaUploaded: () => logEvent("chat_media_uploaded"),
|
||||
|
||||
// Subscription events
|
||||
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
|
||||
purchaseInitiated: (packageType: string) =>
|
||||
logEvent("purchase_initiated", { package_type: packageType }),
|
||||
purchaseCompleted: (packageType: string) =>
|
||||
logEvent("purchase", { package_type: packageType }),
|
||||
purchaseRestored: () => logEvent("purchase_restored"),
|
||||
|
||||
// Pack events
|
||||
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
|
||||
packDisabled: (packId: string) => logEvent("pack_disabled", { pack_id: packId }),
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Centralized error handling utility
|
||||
* Maps technical Supabase/API errors to user-friendly messages
|
||||
*/
|
||||
|
||||
type ErrorCategory = 'auth' | 'pairing' | 'profile' | 'network' | 'generic';
|
||||
|
||||
interface ErrorMapping {
|
||||
pattern: RegExp | string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||
{ pattern: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
|
||||
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
|
||||
{ pattern: /user already registered/i, message: "An account with this email already exists." },
|
||||
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
|
||||
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
|
||||
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
|
||||
{ pattern: /rate limit/i, message: "Too many attempts. Please wait a moment and try again." },
|
||||
{ pattern: /email rate limit/i, message: "Too many emails sent. Please wait before requesting another." },
|
||||
{ pattern: /same as/i, message: "New password must be different from your current password." },
|
||||
{ pattern: /session.*expired/i, message: "Your session has expired. Please log in again." },
|
||||
{ pattern: /invalid.*token/i, message: "This link has expired. Please request a new one." },
|
||||
{ pattern: /otp.*expired/i, message: "This code has expired. Please request a new one." },
|
||||
{ pattern: /signups.*disabled/i, message: "Sign ups are currently disabled. Please try again later." },
|
||||
{ pattern: /user not found/i, message: "No account found with this email." },
|
||||
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
|
||||
];
|
||||
|
||||
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||
{ pattern: /invalid.*invite.*code/i, message: "This invite code is invalid. Please check and try again." },
|
||||
{ pattern: /invite.*code.*not.*found/i, message: "This invite code doesn't exist. Please check with your partner." },
|
||||
{ pattern: /already.*paired/i, message: "You're already paired with a partner." },
|
||||
{ pattern: /couple.*full/i, message: "This couple already has two partners." },
|
||||
{ pattern: /cannot.*join.*own/i, message: "You can't join your own couple." },
|
||||
];
|
||||
|
||||
const PROFILE_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||
{ pattern: /profile.*not.*found/i, message: "Profile not found. Please try logging in again." },
|
||||
{ pattern: /duplicate.*name/i, message: "This name is already taken." },
|
||||
];
|
||||
|
||||
const NETWORK_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||
{ pattern: /network.*error/i, message: "Connection problem. Please check your internet and try again." },
|
||||
{ pattern: /fetch.*failed/i, message: "Couldn't connect to server. Please try again." },
|
||||
{ pattern: /timeout/i, message: "Request timed out. Please try again." },
|
||||
{ pattern: /abort/i, message: "Request was cancelled. Please try again." },
|
||||
];
|
||||
|
||||
const GENERIC_FALLBACK = "Something went wrong. Please try again.";
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message from a Supabase/API error
|
||||
*/
|
||||
export function getUserFriendlyError(
|
||||
error: Error | { message: string } | string | null | undefined,
|
||||
category: ErrorCategory = 'generic'
|
||||
): string {
|
||||
if (!error) return GENERIC_FALLBACK;
|
||||
|
||||
const errorMessage = typeof error === 'string' ? error : error.message;
|
||||
if (!errorMessage) return GENERIC_FALLBACK;
|
||||
|
||||
// Check category-specific mappings first
|
||||
const categoryMappings = getCategoryMappings(category);
|
||||
for (const mapping of categoryMappings) {
|
||||
if (matchesPattern(errorMessage, mapping.pattern)) {
|
||||
return mapping.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Check network errors (applies to all categories)
|
||||
for (const mapping of NETWORK_ERROR_MAPPINGS) {
|
||||
if (matchesPattern(errorMessage, mapping.pattern)) {
|
||||
return mapping.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Check auth errors if in auth category
|
||||
if (category === 'auth') {
|
||||
for (const mapping of AUTH_ERROR_MAPPINGS) {
|
||||
if (matchesPattern(errorMessage, mapping.pattern)) {
|
||||
return mapping.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return generic fallback instead of raw error
|
||||
return GENERIC_FALLBACK;
|
||||
}
|
||||
|
||||
function getCategoryMappings(category: ErrorCategory): ErrorMapping[] {
|
||||
switch (category) {
|
||||
case 'auth':
|
||||
return AUTH_ERROR_MAPPINGS;
|
||||
case 'pairing':
|
||||
return PAIRING_ERROR_MAPPINGS;
|
||||
case 'profile':
|
||||
return PROFILE_ERROR_MAPPINGS;
|
||||
case 'network':
|
||||
return NETWORK_ERROR_MAPPINGS;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function matchesPattern(message: string, pattern: RegExp | string): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return message.toLowerCase().includes(pattern.toLowerCase());
|
||||
}
|
||||
return pattern.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper specifically for auth errors
|
||||
*/
|
||||
export function getAuthError(error: Error | { message: string } | string | null | undefined): string {
|
||||
return getUserFriendlyError(error, 'auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper specifically for pairing/couple errors
|
||||
*/
|
||||
export function getPairingError(error: Error | { message: string } | string | null | undefined): string {
|
||||
return getUserFriendlyError(error, 'pairing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper specifically for profile errors
|
||||
*/
|
||||
export function getProfileError(error: Error | { message: string } | string | null | undefined): string {
|
||||
return getUserFriendlyError(error, 'profile');
|
||||
}
|
||||
@@ -257,7 +257,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
set({ isLoading: true });
|
||||
|
||||
// Get user's explicit content preference
|
||||
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? false;
|
||||
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true;
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = await supabase
|
||||
|
||||
@@ -2,6 +2,7 @@ import Navbar from '@/components/Navbar'
|
||||
import Hero from '@/components/Hero'
|
||||
import Features from '@/components/Features'
|
||||
import HowItWorks from '@/components/HowItWorks'
|
||||
import Pricing from '@/components/Pricing'
|
||||
import CTA from '@/components/CTA'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
@@ -12,6 +13,7 @@ export default function Home() {
|
||||
<Hero />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
<Pricing />
|
||||
<CTA />
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
@@ -46,6 +46,9 @@ export default function Navbar() {
|
||||
<a href="#how-it-works" className="text-white/70 hover:text-white transition-colors">
|
||||
How It Works
|
||||
</a>
|
||||
<a href="#pricing" className="text-white/70 hover:text-white transition-colors">
|
||||
Pro
|
||||
</a>
|
||||
<a
|
||||
href="https://apps.apple.com/app/sauci"
|
||||
target="_blank"
|
||||
@@ -87,6 +90,13 @@ export default function Navbar() {
|
||||
>
|
||||
How It Works
|
||||
</a>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Pro
|
||||
</a>
|
||||
<a
|
||||
href="https://apps.apple.com/app/sauci"
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Crown, Heart, Sparkles, Infinity } from 'lucide-react'
|
||||
|
||||
const features = [
|
||||
'Unlock all question packs',
|
||||
'Access exclusive premium content',
|
||||
'New packs added regularly',
|
||||
'Both partners get full access',
|
||||
'One-time setup, shared benefits',
|
||||
]
|
||||
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<section id="pricing" className="relative py-24 overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background-light to-background" />
|
||||
|
||||
{/* Accent Glows */}
|
||||
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-primary/15 rounded-full blur-[150px]" />
|
||||
<div className="absolute bottom-1/4 left-0 w-[300px] h-[300px] bg-secondary/15 rounded-full blur-[150px]" />
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4">
|
||||
Upgrade to <span className="gradient-text">Sauci Pro</span>
|
||||
</h2>
|
||||
<p className="text-lg text-white/60 max-w-2xl mx-auto">
|
||||
Unlock the full experience and explore every dimension of your relationship together.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="max-w-xl mx-auto"
|
||||
>
|
||||
{/* Main Pricing Card */}
|
||||
<div className="glass border-primary/30 p-8 relative overflow-hidden">
|
||||
{/* Crown Badge */}
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="bg-gradient-to-r from-primary to-secondary px-4 py-1 rounded-bl-xl rounded-tr-xl">
|
||||
<span className="text-sm font-semibold flex items-center gap-1">
|
||||
<Crown className="w-4 h-4" /> PRO
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Couple Highlight */}
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<Infinity className="w-6 h-6 text-white/40" />
|
||||
<div className="w-12 h-12 rounded-full bg-secondary/20 flex items-center justify-center">
|
||||
<Heart className="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-center mb-2">One Subscription, Both Partners</h3>
|
||||
<p className="text-white/60 text-center mb-6">
|
||||
When you upgrade to Pro, your partner automatically gets full access too.
|
||||
No separate purchases needed - you're in this together.
|
||||
</p>
|
||||
|
||||
{/* Feature List */}
|
||||
<ul className="space-y-3 mb-8">
|
||||
{features.map((feature, index) => (
|
||||
<motion.li
|
||||
key={feature}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 + index * 0.1 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
<span className="text-white/80">{feature}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Value Proposition */}
|
||||
<div className="bg-white/5 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-secondary flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-white/80">
|
||||
<span className="font-semibold text-white">Pay once per couple.</span>{' '}
|
||||
Whether monthly or yearly, one subscription unlocks Pro for both you and your partner.
|
||||
That's two accounts for the price of one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="https://apps.apple.com/app/sauci"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Crown className="w-5 h-5" />
|
||||
Get Sauci Pro
|
||||
</a>
|
||||
<p className="text-sm text-white/40 mt-3">
|
||||
Upgrade anytime from within the app
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom Note */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-center text-white/40 text-sm mt-8 max-w-lg mx-auto"
|
||||
>
|
||||
Start free with access to selected packs. Upgrade to Pro when you're ready to unlock everything.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Generated
+1123
-598
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user