feat: notification, purchase, and analytics services
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
56
src/shared/hooks/useNotifications.ts
Normal file
56
src/shared/hooks/useNotifications.ts
Normal 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])
|
||||
}
|
||||
257
src/shared/hooks/usePurchases.ts
Normal file
257
src/shared/hooks/usePurchases.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
16
src/shared/services/CLAUDE.md
Normal file
16
src/shared/services/CLAUDE.md
Normal 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>
|
||||
84
src/shared/services/analytics.ts
Normal file
84
src/shared/services/analytics.ts
Normal 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)
|
||||
}
|
||||
56
src/shared/services/purchases.ts
Normal file
56
src/shared/services/purchases.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user