From 540bb015c7663e4eed762b14583bcf3b28a2a180 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sat, 21 Feb 2026 00:04:47 +0100 Subject: [PATCH] feat: notification, purchase, and analytics services Co-Authored-By: Claude Opus 4.6 --- plugins/CLAUDE.md | 7 + plugins/withStoreKitConfig.js | 148 +++++++++++++++ src/shared/hooks/index.ts | 2 + src/shared/hooks/useNotifications.ts | 56 ++++++ src/shared/hooks/usePurchases.ts | 257 +++++++++++++++++++++++++++ src/shared/services/CLAUDE.md | 16 ++ src/shared/services/analytics.ts | 84 +++++++++ src/shared/services/purchases.ts | 56 ++++++ storekit/CLAUDE.md | 7 + storekit/TabataFit.storekit | 126 +++++++++++++ 10 files changed, 759 insertions(+) create mode 100644 plugins/CLAUDE.md create mode 100644 plugins/withStoreKitConfig.js create mode 100644 src/shared/hooks/useNotifications.ts create mode 100644 src/shared/hooks/usePurchases.ts create mode 100644 src/shared/services/CLAUDE.md create mode 100644 src/shared/services/analytics.ts create mode 100644 src/shared/services/purchases.ts create mode 100644 storekit/CLAUDE.md create mode 100644 storekit/TabataFit.storekit diff --git a/plugins/CLAUDE.md b/plugins/CLAUDE.md new file mode 100644 index 0000000..adfdcb1 --- /dev/null +++ b/plugins/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/plugins/withStoreKitConfig.js b/plugins/withStoreKitConfig.js new file mode 100644 index 0000000..11fb6a9 --- /dev/null +++ b/plugins/withStoreKitConfig.js @@ -0,0 +1,148 @@ +/** + * Expo Config Plugin: StoreKit Configuration for Sandbox Testing + * + * Adds TabataFit.storekit to the Xcode project and configures the scheme + * to use it for StoreKit testing. This enables purchase testing in the + * iOS simulator without real Apple Pay charges. + */ + +const { + withXcodeProject, + withDangerousMod, +} = require('@expo/config-plugins') +const fs = require('fs') +const path = require('path') + +const STOREKIT_FILENAME = 'TabataFit.storekit' + +/** + * Step 1: Copy the .storekit file and add it to the Xcode project (project.pbxproj) + */ +function withStoreKitXcodeProject(config) { + return withXcodeProject(config, (config) => { + const project = config.modResults + const projectName = config.modRequest.projectName + const platformRoot = config.modRequest.platformProjectRoot + + // Copy .storekit file into the iOS app directory + const sourceFile = path.resolve(__dirname, '..', 'storekit', STOREKIT_FILENAME) + const destDir = path.join(platformRoot, projectName) + const destFile = path.join(destDir, STOREKIT_FILENAME) + fs.copyFileSync(sourceFile, destFile) + + // Add file to the Xcode project manually via pbxproj APIs + // Find the app's PBXGroup + const mainGroupKey = project.getFirstProject().firstProject.mainGroup + const mainGroup = project.getPBXGroupByKey(mainGroupKey) + + // Find the app target group (e.g., "tabatago") + let appGroupKey = null + if (mainGroup && mainGroup.children) { + for (const child of mainGroup.children) { + if (child.comment === projectName) { + appGroupKey = child.value + break + } + } + } + + if (!appGroupKey) { + console.warn('[withStoreKitConfig] Could not find app group in Xcode project') + return config + } + + const appGroup = project.getPBXGroupByKey(appGroupKey) + + // Check if already added + const alreadyExists = appGroup.children?.some( + (child) => child.comment === STOREKIT_FILENAME + ) + + if (!alreadyExists) { + // Generate a unique UUID for the file reference + const fileRefUuid = project.generateUuid() + + // Add PBXFileReference — NOT added to any build phase + // .storekit files are testing configs, not app resources + const pbxFileRef = project.hash.project.objects['PBXFileReference'] + pbxFileRef[fileRefUuid] = { + isa: 'PBXFileReference', + lastKnownFileType: 'text.json', + path: STOREKIT_FILENAME, + sourceTree: '""', + } + pbxFileRef[`${fileRefUuid}_comment`] = STOREKIT_FILENAME + + // Add to the app group's children (visible in Xcode navigator) + appGroup.children.push({ + value: fileRefUuid, + comment: STOREKIT_FILENAME, + }) + + console.log('[withStoreKitConfig] Added', STOREKIT_FILENAME, 'to Xcode project') + } + + return config + }) +} + +/** + * Step 2: Configure the Xcode scheme to use the StoreKit configuration + */ +function withStoreKitScheme(config) { + return withDangerousMod(config, [ + 'ios', + (config) => { + const projectName = config.modRequest.projectName + const schemePath = path.join( + config.modRequest.platformProjectRoot, + `${projectName}.xcodeproj`, + 'xcshareddata', + 'xcschemes', + `${projectName}.xcscheme` + ) + + if (!fs.existsSync(schemePath)) { + console.warn('[withStoreKitConfig] Scheme not found:', schemePath) + return config + } + + let scheme = fs.readFileSync(schemePath, 'utf8') + + // Skip if already configured + if (scheme.includes('storeKitConfigurationFileReference')) { + console.log('[withStoreKitConfig] StoreKit config already in scheme') + return config + } + + // Insert StoreKitConfigurationFileReference as a child element of LaunchAction + // The identifier path is relative to the workspace root (ios/ directory) + const storeKitRef = ` + + ` + + // Insert before the closing tag + scheme = scheme.replace( + '', + `${storeKitRef}\n ` + ) + + fs.writeFileSync(schemePath, scheme, 'utf8') + console.log('[withStoreKitConfig] Added StoreKit config to scheme') + + return config + }, + ]) +} + +/** + * Main plugin + */ +function withStoreKitConfig(config) { + config = withStoreKitXcodeProject(config) + config = withStoreKitScheme(config) + return config +} + +module.exports = withStoreKitConfig diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index a7e7e08..2070ad6 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -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' diff --git a/src/shared/hooks/useNotifications.ts b/src/shared/hooks/useNotifications.ts new file mode 100644 index 0000000..4b0d03d --- /dev/null +++ b/src/shared/hooks/useNotifications.ts @@ -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 { + 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]) +} diff --git a/src/shared/hooks/usePurchases.ts b/src/shared/hooks/usePurchases.ts new file mode 100644 index 0000000..379ffc1 --- /dev/null +++ b/src/shared/hooks/usePurchases.ts @@ -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 + restorePurchases: () => Promise +} + +/** + * 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(null) + const [customerInfo, setCustomerInfo] = useState(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 => { + 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 => { + 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, + } +} diff --git a/src/shared/services/CLAUDE.md b/src/shared/services/CLAUDE.md new file mode 100644 index 0000000..081c948 --- /dev/null +++ b/src/shared/services/CLAUDE.md @@ -0,0 +1,16 @@ + +# Recent Activity + + + +### 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 | + \ No newline at end of file diff --git a/src/shared/services/analytics.ts b/src/shared/services/analytics.ts new file mode 100644 index 0000000..55909a4 --- /dev/null +++ b/src/shared/services/analytics.ts @@ -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 + +// 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 { + 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) +} diff --git a/src/shared/services/purchases.ts b/src/shared/services/purchases.ts new file mode 100644 index 0000000..e3ca0a5 --- /dev/null +++ b/src/shared/services/purchases.ts @@ -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 { + 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 +} diff --git a/storekit/CLAUDE.md b/storekit/CLAUDE.md new file mode 100644 index 0000000..adfdcb1 --- /dev/null +++ b/storekit/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/storekit/TabataFit.storekit b/storekit/TabataFit.storekit new file mode 100644 index 0000000..f6e8e9b --- /dev/null +++ b/storekit/TabataFit.storekit @@ -0,0 +1,126 @@ +{ + "identifier" : "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", + "type" : "Default", + "version" : { + "major" : 4, + "minor" : 0 + }, + "settings" : { + "_applicationInternalID" : "", + "_developerTeamID" : "", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : null, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "F1A2B3C4-D5E6-7890-ABCD-111111111111", + "localizations" : [], + "name" : "TabataFit Premium", + "subscriptions" : [ + { + "adHocOffers" : [], + "codeOffers" : [], + "displayPrice" : "6.99", + "familyShareable" : true, + "groupNumber" : 1, + "internalID" : "A1111111-1111-1111-1111-111111111111", + "introductoryOffer" : { + "displayPrice" : "0", + "internalID" : "B1111111-1111-1111-1111-111111111111", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P1W" + }, + "localizations" : [ + { + "description" : "Monthly access to all TabataFit workouts and features", + "displayName" : "TabataFit+ Monthly", + "locale" : "en_US" + } + ], + "productID" : "tabatafit_premium_monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Monthly Premium", + "subscriptionGroupID" : "F1A2B3C4-D5E6-7890-ABCD-111111111111", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [], + "codeOffers" : [], + "displayPrice" : "49.99", + "familyShareable" : true, + "groupNumber" : 1, + "internalID" : "A2222222-2222-2222-2222-222222222222", + "introductoryOffer" : { + "displayPrice" : "0", + "internalID" : "B2222222-2222-2222-2222-222222222222", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P1W" + }, + "localizations" : [ + { + "description" : "Annual access to all TabataFit workouts — save 40%", + "displayName" : "TabataFit+ Annual", + "locale" : "en_US" + } + ], + "productID" : "tabatafit_premium_annual", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Annual Premium", + "subscriptionGroupID" : "F1A2B3C4-D5E6-7890-ABCD-111111111111", + "type" : "RecurringSubscription" + } + ] + } + ] +}