mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Update
This commit is contained in:
1 parent
6ff5bc62f2
commit
adab288a3a
204 files changed
+11334
-14295
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { Platform, Dimensions } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import type { DeviceInfo } from '../types';
|
||||
|
||||
export function getDeviceInfo(): DeviceInfo {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
return {
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
osVersion: Platform.Version?.toString() ?? 'unknown',
|
||||
appVersion: Constants.expoConfig?.version ?? '1.0.0',
|
||||
buildNumber: Platform.select({
|
||||
ios: Constants.expoConfig?.ios?.buildNumber,
|
||||
android: Constants.expoConfig?.android?.versionCode?.toString(),
|
||||
default: undefined,
|
||||
}),
|
||||
deviceModel: Platform.select({
|
||||
ios: Constants.platform?.ios?.model,
|
||||
android: Constants.platform?.android?.model,
|
||||
default: undefined,
|
||||
}),
|
||||
screenWidth: width,
|
||||
screenHeight: height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as Notifications from "expo-notifications";
|
||||
import Constants from "expo-constants";
|
||||
import { Platform } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { supabase } from "./supabase";
|
||||
import { captureError } from "./sentry";
|
||||
|
||||
// Configure notification behavior for foreground
|
||||
// We don't show system alerts since the app handles notifications with in-app toasts
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: false,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Types
|
||||
export interface NotificationData {
|
||||
type: "match" | "message";
|
||||
match_id?: string;
|
||||
message_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions and get Expo push token.
|
||||
* Returns null if permissions denied, on web, or if registration fails.
|
||||
*/
|
||||
export async function registerForPushNotificationsAsync(): Promise<string | null> {
|
||||
// Push notifications don't work on web
|
||||
if (Platform.OS === "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check current permission status
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
// Request permission if not already granted
|
||||
if (existingStatus !== "granted") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
console.log("Push notification permission denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get Expo push token
|
||||
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
|
||||
if (!projectId) {
|
||||
console.warn("EAS project ID not configured - push notifications disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||
return tokenData.data;
|
||||
} catch (error) {
|
||||
// This typically fails in simulator or development
|
||||
console.error("Failed to get push token:", error);
|
||||
captureError(error as Error, { context: "push_token_registration" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save push token to user profile in Supabase.
|
||||
*/
|
||||
export async function savePushToken(userId: string, token: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ push_token: token })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to save push token:", error);
|
||||
captureError(new Error("Failed to save push token"), { userId, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear push token from user profile on sign out.
|
||||
*/
|
||||
export async function clearPushToken(userId: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ push_token: null })
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to clear push token:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification tap - navigate to appropriate screen.
|
||||
*/
|
||||
export function handleNotificationResponse(
|
||||
response: Notifications.NotificationResponse
|
||||
): void {
|
||||
const data = response.notification.request.content.data as NotificationData;
|
||||
|
||||
if (!data?.match_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Both match and message notifications navigate to chat
|
||||
router.push(`/(app)/chat/${data.match_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listener for notification taps (when app is running).
|
||||
* Returns subscription that should be cleaned up on unmount.
|
||||
*/
|
||||
export function setupNotificationResponseListener(): Notifications.Subscription {
|
||||
return Notifications.addNotificationResponseReceivedListener(handleNotificationResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if app was opened from a notification tap (cold start).
|
||||
*/
|
||||
export async function getInitialNotification(): Promise<Notifications.NotificationResponse | null> {
|
||||
return Notifications.getLastNotificationResponseAsync();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const REVENUECAT_IOS_API_KEY =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || "";
|
||||
const ENTITLEMENT_ID =
|
||||
process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID || "pro"; // Your RevenueCat entitlement identifier
|
||||
|
||||
export interface SubscriptionState {
|
||||
isProUser: boolean;
|
||||
activeSubscription: string | null;
|
||||
expirationDate: Date | null;
|
||||
willRenew: boolean;
|
||||
}
|
||||
|
||||
// Type definitions for RevenueCat (minimal subset we use)
|
||||
interface CustomerInfo {
|
||||
entitlements: {
|
||||
active: {
|
||||
[key: string]: {
|
||||
productIdentifier: string;
|
||||
expirationDate: string | null;
|
||||
willRenew: boolean;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PurchasesPackage {
|
||||
identifier: string;
|
||||
packageType: string;
|
||||
product: {
|
||||
price: number;
|
||||
priceString: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PurchasesOffering {
|
||||
identifier: string;
|
||||
availablePackages: PurchasesPackage[];
|
||||
}
|
||||
|
||||
// Re-export for consumers
|
||||
export type { CustomerInfo, PurchasesPackage, PurchasesOffering };
|
||||
|
||||
class RevenueCatService {
|
||||
private initialized = false;
|
||||
private Purchases: any = null;
|
||||
|
||||
isAvailable(): boolean {
|
||||
return Platform.OS === "ios";
|
||||
}
|
||||
|
||||
private async loadPurchases(): Promise<boolean> {
|
||||
if (this.Purchases) return true;
|
||||
|
||||
try {
|
||||
const module = require("react-native-purchases");
|
||||
this.Purchases = module.default || module;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log("RevenueCat: Native module not available (running in Expo Go?)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Only initialize on iOS
|
||||
if (Platform.OS !== "ios") {
|
||||
console.log("RevenueCat: Skipping initialization on non-iOS platform");
|
||||
return;
|
||||
}
|
||||
|
||||
const available = await this.loadPurchases();
|
||||
if (!available) return;
|
||||
|
||||
if (!REVENUECAT_IOS_API_KEY) {
|
||||
console.warn("RevenueCat: Missing iOS API key");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (__DEV__) {
|
||||
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
|
||||
}
|
||||
|
||||
await this.Purchases.configure({
|
||||
apiKey: REVENUECAT_IOS_API_KEY,
|
||||
appUserID: userId,
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
console.log("RevenueCat: Initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Failed to initialize:", error);
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async login(userId: string): Promise<CustomerInfo | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { customerInfo } = await this.Purchases.logIn(userId);
|
||||
console.log("RevenueCat: Logged in user", userId);
|
||||
return customerInfo;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Login error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (!this.initialized || !this.Purchases) return;
|
||||
|
||||
try {
|
||||
await this.Purchases.logOut();
|
||||
console.log("RevenueCat: Logged out");
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Logout error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getOfferings(): Promise<PurchasesOffering | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const offerings = await this.Purchases.getOfferings();
|
||||
return offerings.current;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error fetching offerings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async purchasePackage(pkg: PurchasesPackage): Promise<CustomerInfo> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
throw new Error("RevenueCat not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const { customerInfo } = await this.Purchases.purchasePackage(pkg);
|
||||
return customerInfo;
|
||||
} catch (error: any) {
|
||||
if (error.userCancelled) {
|
||||
throw new Error("Purchase cancelled");
|
||||
}
|
||||
console.error("RevenueCat: Purchase error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async restorePurchases(): Promise<CustomerInfo> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
throw new Error("RevenueCat not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const customerInfo = await this.Purchases.restorePurchases();
|
||||
return customerInfo;
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Restore error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomerInfo(): Promise<CustomerInfo | null> {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.Purchases.getCustomerInfo();
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error getting customer info:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
parseCustomerInfo(customerInfo: CustomerInfo | null): SubscriptionState {
|
||||
if (!customerInfo) {
|
||||
console.log("RevenueCat: No customer info");
|
||||
return {
|
||||
isProUser: false,
|
||||
activeSubscription: null,
|
||||
expirationDate: null,
|
||||
willRenew: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Debug: Log all active entitlements
|
||||
const activeEntitlements = Object.keys(customerInfo.entitlements.active);
|
||||
console.log("RevenueCat: Active entitlements:", activeEntitlements);
|
||||
console.log("RevenueCat: Looking for entitlement ID:", ENTITLEMENT_ID);
|
||||
|
||||
const entitlement = customerInfo.entitlements.active[ENTITLEMENT_ID];
|
||||
console.log("RevenueCat: Found entitlement:", entitlement ? "yes" : "no");
|
||||
|
||||
return {
|
||||
isProUser: !!entitlement,
|
||||
activeSubscription: entitlement?.productIdentifier || null,
|
||||
expirationDate: entitlement?.expirationDate
|
||||
? new Date(entitlement.expirationDate)
|
||||
: null,
|
||||
willRenew: entitlement?.willRenew || false,
|
||||
};
|
||||
}
|
||||
|
||||
addCustomerInfoUpdateListener(
|
||||
callback: (customerInfo: CustomerInfo) => void
|
||||
): (() => void) | null {
|
||||
if (!this.initialized || !this.Purchases) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = this.Purchases.addCustomerInfoUpdateListener(callback);
|
||||
return () => {
|
||||
if (subscription && typeof subscription.remove === "function") {
|
||||
subscription.remove();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("RevenueCat: Error adding listener:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const revenueCatService = new RevenueCatService();
|
||||
export default revenueCatService;
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import Constants from "expo-constants";
|
||||
|
||||
const SENTRY_DSN = process.env.EXPO_PUBLIC_SENTRY_DSN;
|
||||
|
||||
export function initSentry() {
|
||||
if (!SENTRY_DSN) {
|
||||
console.warn("Sentry DSN not configured - error tracking disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
debug: __DEV__,
|
||||
enabled: !__DEV__, // Only enable in production
|
||||
environment: __DEV__ ? "development" : "production",
|
||||
release: Constants.expoConfig?.version ?? "1.0.0",
|
||||
dist: Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode?.toString(),
|
||||
|
||||
// Performance monitoring
|
||||
tracesSampleRate: 0.2, // 20% of transactions for performance
|
||||
|
||||
// Session tracking
|
||||
enableAutoSessionTracking: true,
|
||||
sessionTrackingIntervalMillis: 30000,
|
||||
|
||||
// Capture unhandled promise rejections
|
||||
enableNative: true,
|
||||
|
||||
// Filter out noisy errors
|
||||
beforeSend(event) {
|
||||
// Filter out network errors that are expected (e.g., user offline)
|
||||
if (event.exception?.values?.[0]?.value?.includes("Network request failed")) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to capture errors with context
|
||||
export function captureError(error: Error, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
Sentry.setContext("additional", context);
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
// Set user context when they log in
|
||||
export function setUserContext(userId: string, email?: string) {
|
||||
Sentry.setUser({
|
||||
id: userId,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear user context on logout
|
||||
export function clearUserContext() {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
|
||||
// Add breadcrumb for debugging
|
||||
export function addBreadcrumb(message: string, category: string, data?: Record<string, unknown>) {
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
category,
|
||||
data,
|
||||
level: "info",
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
Reference in new issue
Block a user