mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { Platform } from "react-native";
|
|
import Constants from "expo-constants";
|
|
// Import default export to ensure namespace registration runs
|
|
import analytics, {
|
|
getAnalytics,
|
|
logEvent as firebaseLogEvent,
|
|
setUserId as firebaseSetUserId,
|
|
setUserProperty,
|
|
setAnalyticsCollectionEnabled,
|
|
resetAnalyticsData,
|
|
FirebaseAnalyticsTypes,
|
|
} from "@react-native-firebase/analytics";
|
|
|
|
// Force the analytics module to be retained (prevents tree-shaking)
|
|
const _analyticsModule = analytics;
|
|
|
|
let isInitialized = false;
|
|
let analyticsInstance: FirebaseAnalyticsTypes.Module | null = null;
|
|
let analyticsUnavailable = false;
|
|
let analyticsUnavailableLogged = false;
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (!error) return "";
|
|
if (typeof error === "string") return error;
|
|
if (error instanceof Error) return error.message;
|
|
if (typeof (error as { message?: unknown }).message === "string") {
|
|
return (error as { message?: string }).message || "";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function isAnalyticsSupported(): boolean {
|
|
if (Platform.OS === "web") return false;
|
|
if (Constants.appOwnership === "expo") return false;
|
|
return true;
|
|
}
|
|
|
|
function logAnalyticsUnavailableOnce(reason: string) {
|
|
if (analyticsUnavailableLogged) return;
|
|
analyticsUnavailableLogged = true;
|
|
if (__DEV__) console.log("[Analytics] " + reason);
|
|
}
|
|
|
|
function getAnalyticsInstance(): FirebaseAnalyticsTypes.Module | null {
|
|
if (analyticsUnavailable) {
|
|
return null;
|
|
}
|
|
|
|
if (!isAnalyticsSupported()) {
|
|
analyticsUnavailable = true;
|
|
logAnalyticsUnavailableOnce("Skipping Firebase Analytics on Expo Go or web.");
|
|
return null;
|
|
}
|
|
|
|
if (analyticsInstance) {
|
|
return analyticsInstance;
|
|
}
|
|
|
|
try {
|
|
analyticsInstance = getAnalytics();
|
|
return analyticsInstance;
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
if (message.includes("module could not be found")) {
|
|
analyticsUnavailable = true;
|
|
logAnalyticsUnavailableOnce(
|
|
"Firebase Analytics native module missing. Rebuild with a dev client or native app."
|
|
);
|
|
return null;
|
|
}
|
|
console.warn("[Analytics] Failed to get analytics instance:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function initAnalytics() {
|
|
if (isInitialized) {
|
|
return;
|
|
}
|
|
|
|
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
|
|
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
await setAnalyticsCollectionEnabled(analytics, true);
|
|
isInitialized = true;
|
|
console.log("[Analytics] Firebase Analytics initialized");
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to initialize Firebase Analytics:", error);
|
|
}
|
|
}
|
|
|
|
// Log a custom event
|
|
export function logEvent(
|
|
name: string,
|
|
params?: Record<string, string | number | boolean>
|
|
) {
|
|
try {
|
|
if (__DEV__) console.log("[Analytics] Event:", name, params);
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
firebaseLogEvent(analytics, name, params);
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to log event:", error);
|
|
}
|
|
}
|
|
|
|
// Set user ID for tracking
|
|
export function setUserId(userId: string) {
|
|
try {
|
|
if (__DEV__) console.log("[Analytics] Set user ID:", userId);
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
firebaseSetUserId(analytics, userId);
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to set user ID:", error);
|
|
}
|
|
}
|
|
|
|
// Clear user ID on logout
|
|
export function clearUserId() {
|
|
try {
|
|
if (__DEV__) console.log("[Analytics] Cleared user ID");
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
firebaseSetUserId(analytics, null);
|
|
resetAnalyticsData(analytics);
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to clear user ID:", error);
|
|
}
|
|
}
|
|
|
|
// Set user properties
|
|
export function setUserProperties(
|
|
properties: Record<string, string | null>
|
|
) {
|
|
try {
|
|
if (__DEV__) console.log("[Analytics] Set user properties:", properties);
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
// Firebase Analytics requires setting user properties one at a time
|
|
for (const [key, value] of Object.entries(properties)) {
|
|
setUserProperty(analytics, key, value);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to set user properties:", error);
|
|
}
|
|
}
|
|
|
|
// Log screen view
|
|
export function logScreenView(screenName: string, screenClass?: string) {
|
|
try {
|
|
if (__DEV__) console.log("[Analytics] Screen view:", screenName);
|
|
const analytics = getAnalyticsInstance();
|
|
if (analytics) {
|
|
firebaseLogEvent(analytics, "screen_view", {
|
|
screen_name: screenName,
|
|
screen_class: screenClass ?? screenName,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn("[Analytics] Failed to log screen view:", error);
|
|
}
|
|
}
|
|
|
|
// Flush events (useful before app backgrounding)
|
|
export function flush() {
|
|
// Firebase Analytics batches and sends events automatically
|
|
// No manual flush needed, but we keep the function for API compatibility
|
|
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
|
|
}
|
|
|
|
function truncateParam(value: string, maxLength = 100) {
|
|
if (value.length <= maxLength) return value;
|
|
return value.slice(0, maxLength - 1) + "…";
|
|
}
|
|
|
|
function safeParam(value: unknown, maxLength = 100): string {
|
|
if (typeof value === "string") return truncateParam(value, maxLength);
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (value instanceof Error) return truncateParam(value.message, maxLength);
|
|
return value == null ? "" : truncateParam(String(value), maxLength);
|
|
}
|
|
|
|
// 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"),
|
|
questionShared: (destination: "instagram" | "messages" | "more") =>
|
|
logEvent("question_shared", { destination }),
|
|
matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }),
|
|
allQuestionsExhausted: () => logEvent("all_questions_exhausted"),
|
|
pendingQuestionsPromptViewed: (count: number) =>
|
|
logEvent("pending_questions_prompt_viewed", { question_count: count }),
|
|
pendingQuestionsPromptTapped: (count: number) =>
|
|
logEvent("pending_questions_prompt_tapped", { question_count: count }),
|
|
|
|
// Chat events
|
|
matchViewed: () => logEvent("match_viewed"),
|
|
messageSent: () => logEvent("message_sent"),
|
|
mediaUploaded: () => logEvent("chat_media_uploaded"),
|
|
|
|
// Subscription events
|
|
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
|
|
paywallClosed: (source: string, reason: string) =>
|
|
logEvent("paywall_closed", { source, reason }),
|
|
purchaseInitiated: (packageType: string) =>
|
|
logEvent("purchase_initiated", { package_type: packageType }),
|
|
purchaseCancelled: (packageType: string, errorCode?: unknown) =>
|
|
logEvent("purchase_cancelled", {
|
|
package_type: packageType,
|
|
error_code: safeParam(errorCode) || "unknown",
|
|
}),
|
|
purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) =>
|
|
logEvent("purchase_failed", {
|
|
package_type: packageType,
|
|
error_code: safeParam(errorCode) || "unknown",
|
|
error_message: safeParam(errorMessage),
|
|
}),
|
|
purchaseCompleted: (packageType: string) =>
|
|
logEvent("purchase_completed", { 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 }),
|
|
|
|
// App lifecycle events
|
|
appOpened: (type: "cold" | "warm") => logEvent("app_opened", { type }),
|
|
|
|
// 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"),
|
|
notificationPermissionDenied: () => logEvent("notification_permission_denied"),
|
|
|
|
// Relationship events
|
|
relationshipEnded: () => logEvent("relationship_ended"),
|
|
accountDeleted: () => logEvent("account_deleted"),
|
|
|
|
// Milestone events
|
|
firstMatch: () => logEvent("first_match"),
|
|
milestoneMatch: (count: number) => logEvent("milestone_match", { match_count: count }),
|
|
|
|
// Invite events
|
|
inviteCodeCopied: () => logEvent("invite_code_copied"),
|
|
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
|
|
logEvent("invite_link_opened", { source }),
|
|
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
|
|
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
|
|
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
|
|
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
|
|
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
|
|
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
|
|
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
|
|
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
|
|
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
|
|
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
|
|
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
|
|
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
|
|
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
|
|
|
|
// Quiz events
|
|
quizStarted: () => logEvent("quiz_started"),
|
|
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
|
|
quizShared: () => logEvent("quiz_shared"),
|
|
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
|
|
|
|
// Tutorial events
|
|
tutorialStarted: (tutorial: "swipe" | "matches") =>
|
|
logEvent("tutorial_started", { tutorial }),
|
|
tutorialStepViewed: (tutorial: "swipe" | "matches", step: number, stepName: string) =>
|
|
logEvent("tutorial_step_viewed", { tutorial, step, step_name: stepName }),
|
|
tutorialCompleted: (tutorial: "swipe" | "matches") =>
|
|
logEvent("tutorial_completed", { tutorial }),
|
|
tutorialSkipped: (tutorial: "swipe" | "matches", atStep: number) =>
|
|
logEvent("tutorial_skipped", { tutorial, at_step: atStep }),
|
|
};
|