feat: notification, purchase, and analytics services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-21 00:04:47 +01:00
parent d6bc7f5a4c
commit 540bb015c7
10 changed files with 759 additions and 0 deletions

View File

@@ -6,3 +6,5 @@ export { useTimer } from './useTimer'
export type { TimerPhase } from './useTimer'
export { useHaptics } from './useHaptics'
export { useAudio } from './useAudio'
export { useNotifications, requestNotificationPermissions } from './useNotifications'
export { usePurchases } from './usePurchases'

View File

@@ -0,0 +1,56 @@
/**
* TabataFit Notification Hook
* Manages daily reminder scheduling via expo-notifications
*/
import { useEffect } from 'react'
import * as Notifications from 'expo-notifications'
import { useUserStore } from '@/src/shared/stores'
import i18n from '@/src/shared/i18n'
const REMINDER_ID = 'daily-reminder'
export async function requestNotificationPermissions(): Promise<boolean> {
const { status: existing } = await Notifications.getPermissionsAsync()
if (existing === 'granted') return true
const { status } = await Notifications.requestPermissionsAsync()
return status === 'granted'
}
async function scheduleDaily(time: string) {
await Notifications.cancelAllScheduledNotificationsAsync()
const [hour, minute] = time.split(':').map(Number)
await Notifications.scheduleNotificationAsync({
identifier: REMINDER_ID,
content: {
title: i18n.t('notifications:dailyReminder.title'),
body: i18n.t('notifications:dailyReminder.body'),
sound: true,
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
},
})
}
async function cancelAll() {
await Notifications.cancelAllScheduledNotificationsAsync()
}
export function useNotifications() {
const reminders = useUserStore((s) => s.settings.reminders)
const reminderTime = useUserStore((s) => s.settings.reminderTime)
useEffect(() => {
if (reminders) {
scheduleDaily(reminderTime)
} else {
cancelAll()
}
}, [reminders, reminderTime])
}

View File

@@ -0,0 +1,257 @@
/**
* TabataFit Purchases Hook
* Wraps RevenueCat API for subscription management
*
* DEV mode: If StoreKit purchase fails (no sandbox account / product mismatch),
* shows an Alert offering to simulate the purchase for testing.
*/
import { useState, useEffect, useCallback } from 'react'
import { Alert } from 'react-native'
import Purchases, {
CustomerInfo,
PurchasesOfferings,
PurchasesPackage,
} from 'react-native-purchases'
import { useUserStore } from '../stores'
import { ENTITLEMENT_ID } from '../services/purchases'
import type { SubscriptionPlan } from '../types'
interface PurchaseResult {
success: boolean
cancelled: boolean
error?: string
}
interface UsePurchasesReturn {
isPremium: boolean
isLoading: boolean
monthlyPackage: PurchasesPackage | null
annualPackage: PurchasesPackage | null
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>
restorePurchases: () => Promise<boolean>
}
/**
* Helper to check if user has premium entitlement
*/
function hasPremiumEntitlement(info: CustomerInfo | null): boolean {
if (!info) return false
return ENTITLEMENT_ID in info.entitlements.active
}
/**
* Hook to manage RevenueCat subscriptions
* Syncs subscription state with userStore
*/
export function usePurchases(): UsePurchasesReturn {
const [isLoading, setIsLoading] = useState(true)
const [offerings, setOfferings] = useState<PurchasesOfferings | null>(null)
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null)
const subscription = useUserStore((s) => s.profile.subscription)
const setSubscription = useUserStore((s) => s.setSubscription)
// Derive premium status from RevenueCat entitlement or local state
const isPremium = customerInfo
? hasPremiumEntitlement(customerInfo)
: subscription !== 'free'
// Get packages from offerings
const monthlyPackage = offerings?.current?.monthly ?? null
const annualPackage = offerings?.current?.annual ?? null
// Sync RevenueCat state to userStore
const syncSubscriptionToStore = useCallback(
(info: CustomerInfo | null) => {
if (!info) return
const hasPremium = hasPremiumEntitlement(info)
const activeSubscriptions = info.activeSubscriptions
let newPlan: SubscriptionPlan = 'free'
if (hasPremium && activeSubscriptions.length > 0) {
// Determine plan type from subscription identifier
const subId = activeSubscriptions[0].toLowerCase()
if (subId.includes('yearly') || subId.includes('annual')) {
newPlan = 'premium-yearly'
} else if (subId.includes('monthly')) {
newPlan = 'premium-monthly'
} else {
// Default to yearly for any premium entitlement
newPlan = 'premium-yearly'
}
}
// Only update if different
if (subscription !== newPlan) {
console.log('[Purchases] Syncing subscription to store:', newPlan)
setSubscription(newPlan)
}
},
[subscription, setSubscription]
)
// Fetch offerings and customer info on mount
useEffect(() => {
const fetchData = async () => {
try {
const [offeringsResult, customerInfoResult] = await Promise.all([
Purchases.getOfferings(),
Purchases.getCustomerInfo(),
])
setOfferings(offeringsResult)
setCustomerInfo(customerInfoResult)
syncSubscriptionToStore(customerInfoResult)
console.log('[Purchases] Offerings loaded:', {
hasMonthly: !!offeringsResult.current?.monthly,
hasAnnual: !!offeringsResult.current?.annual,
monthlyProductId: offeringsResult.current?.monthly?.product.identifier,
annualProductId: offeringsResult.current?.annual?.product.identifier,
monthlyPrice: offeringsResult.current?.monthly?.product.priceString,
annualPrice: offeringsResult.current?.annual?.product.priceString,
})
} catch (error) {
console.error('[Purchases] Failed to fetch offerings/customerInfo:', error)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [syncSubscriptionToStore])
// Listen for customer info changes (renewals, expirations, etc.)
useEffect(() => {
const listener = (info: CustomerInfo) => {
console.log('[Purchases] Customer info updated')
setCustomerInfo(info)
syncSubscriptionToStore(info)
}
Purchases.addCustomerInfoUpdateListener(listener)
return () => {
Purchases.removeCustomerInfoUpdateListener(listener)
}
}, [syncSubscriptionToStore])
// Purchase a package
const purchasePackage = useCallback(
async (pkg: PurchasesPackage): Promise<PurchaseResult> => {
try {
console.log('[Purchases] Starting purchase for:', pkg.identifier, pkg.product.identifier)
const { customerInfo: newInfo } = await Purchases.purchasePackage(pkg)
setCustomerInfo(newInfo)
syncSubscriptionToStore(newInfo)
const success = hasPremiumEntitlement(newInfo)
console.log('[Purchases] Purchase result:', { success })
return { success, cancelled: false }
} catch (error: any) {
// Handle user cancellation
if (error.userCancelled) {
console.log('[Purchases] Purchase cancelled by user')
return { success: false, cancelled: true }
}
console.error('[Purchases] Purchase error:', error)
// DEV mode: offer to simulate the purchase when StoreKit fails
if (__DEV__) {
return new Promise((resolve) => {
Alert.alert(
'Purchase failed (DEV)',
`StoreKit error: ${error.message || 'Unknown error'}\n\nProduct: ${pkg.product.identifier}\n\nSimulate a successful purchase for testing?`,
[
{
text: 'Cancel',
style: 'cancel',
onPress: () =>
resolve({ success: false, cancelled: true }),
},
{
text: 'Simulate Purchase',
onPress: () => {
// Determine plan from package identifier
const id = pkg.product.identifier.toLowerCase()
const plan: SubscriptionPlan =
id.includes('annual') || id.includes('year')
? 'premium-yearly'
: 'premium-monthly'
setSubscription(plan)
console.log('[Purchases] DEV: Simulated purchase →', plan)
resolve({ success: true, cancelled: false })
},
},
]
)
})
}
return {
success: false,
cancelled: false,
error: error.message || 'Purchase failed',
}
}
},
[syncSubscriptionToStore, setSubscription]
)
// Restore purchases
const restorePurchases = useCallback(async (): Promise<boolean> => {
try {
console.log('[Purchases] Restoring purchases...')
const restoredInfo = await Purchases.restorePurchases()
setCustomerInfo(restoredInfo)
syncSubscriptionToStore(restoredInfo)
const hasPremium = hasPremiumEntitlement(restoredInfo)
console.log('[Purchases] Restore result:', { hasPremium })
return hasPremium
} catch (error) {
console.error('[Purchases] Restore failed:', error)
// DEV mode: offer to simulate restore
if (__DEV__) {
return new Promise((resolve) => {
Alert.alert(
'Restore failed (DEV)',
'Simulate a restored premium subscription?',
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{
text: 'Simulate Restore',
onPress: () => {
setSubscription('premium-yearly')
console.log('[Purchases] DEV: Simulated restore → premium-yearly')
resolve(true)
},
},
]
)
})
}
return false
}
}, [syncSubscriptionToStore, setSubscription])
return {
isPremium,
isLoading,
monthlyPackage,
annualPackage,
purchasePackage,
restorePurchases,
}
}

View File

@@ -0,0 +1,16 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5392 | 10:22 PM | 🔵 | User requests sandbox purchase testing capability for RevenueCat | ~514 |
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5386 | 10:16 PM | ✅ | Updated RevenueCat API key in production configuration | ~115 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #5353 | 7:47 PM | 🟣 | RevenueCat Service Initialization Module Created | ~244 |
| #5275 | 2:42 PM | 🟣 | Implemented daily reminder feature for TabataGo app | ~204 |
</claude-mem-context>

View File

@@ -0,0 +1,84 @@
/**
* TabataFit PostHog Analytics Service
* Initialize and configure PostHog for user analytics
*
* Follows the same pattern as purchases.ts:
* - initializeAnalytics() called once at app startup
* - track() helper for type-safe event tracking
* - identifyUser() for user identification
*/
import PostHog from 'posthog-react-native'
type EventProperties = Record<string, string | number | boolean | string[]>
// PostHog configuration
// Replace with your actual PostHog project API key
const POSTHOG_API_KEY = '__YOUR_POSTHOG_API_KEY__'
const POSTHOG_HOST = 'https://us.i.posthog.com'
// Singleton client instance
let posthogClient: PostHog | null = null
/**
* Initialize PostHog SDK
* Call this once at app startup (after store hydration)
*/
export async function initializeAnalytics(): Promise<PostHog | null> {
if (posthogClient) {
console.log('[Analytics] Already initialized')
return posthogClient
}
// Skip initialization if no real API key is configured
if (POSTHOG_API_KEY.startsWith('__')) {
if (__DEV__) {
console.log('[Analytics] No API key configured — events will be logged to console only')
}
return null
}
try {
posthogClient = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
enableSessionReplay: false,
})
console.log('[Analytics] PostHog initialized successfully')
return posthogClient
} catch (error) {
console.error('[Analytics] Failed to initialize PostHog:', error)
return null
}
}
/**
* Get the PostHog client instance (for PostHogProvider)
*/
export function getPostHogClient(): PostHog | null {
return posthogClient
}
/**
* Track an analytics event
* Safe to call even if PostHog is not initialized — logs to console in dev
*/
export function track(event: string, properties?: EventProperties): void {
if (__DEV__) {
console.log(`[Analytics] ${event}`, properties ?? '')
}
posthogClient?.capture(event, properties)
}
/**
* Identify a user with traits
*/
export function identifyUser(
userId: string,
traits?: EventProperties,
): void {
if (__DEV__) {
console.log('[Analytics] identify', userId, traits ?? '')
}
posthogClient?.identify(userId, traits)
}

View File

@@ -0,0 +1,56 @@
/**
* TabataFit RevenueCat Service
* Initialize and configure RevenueCat for Apple subscriptions
*
* Sandbox testing:
* - The test_ API key enables RevenueCat sandbox mode
* - StoreKit Configuration (TabataFit.storekit) enables simulator purchases
* - Transactions are free sandbox completions — no real charges
*/
import Purchases, { LOG_LEVEL } from 'react-native-purchases'
// RevenueCat configuration
// test_ prefix = sandbox mode (free transactions on simulator + device sandbox accounts)
export const REVENUECAT_API_KEY = 'test_oIJbIHWISJaUZdgxRMHlwizBHvM'
// Entitlement ID configured in RevenueCat dashboard
export const ENTITLEMENT_ID = 'premium'
// Track initialization state
let isInitialized = false
/**
* Initialize RevenueCat SDK
* Call this once at app startup (after store hydration)
*/
export async function initializePurchases(): Promise<void> {
if (isInitialized) {
console.log('[Purchases] Already initialized')
return
}
try {
// setLogLevel MUST be called before configure()
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE)
}
// Configure RevenueCat with API key
await Purchases.configure({ apiKey: REVENUECAT_API_KEY })
isInitialized = true
console.log('[Purchases] RevenueCat initialized successfully')
console.log('[Purchases] Sandbox mode:', __DEV__ ? 'enabled' : 'disabled')
} catch (error) {
console.error('[Purchases] Failed to initialize RevenueCat:', error)
throw error
}
}
/**
* Check if RevenueCat has been initialized
*/
export function isPurchasesInitialized(): boolean {
return isInitialized
}