feat: notification, purchase, and analytics services
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
plugins/CLAUDE.md
Normal file
7
plugins/CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
148
plugins/withStoreKitConfig.js
Normal file
148
plugins/withStoreKitConfig.js
Normal file
@@ -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: '"<group>"',
|
||||
}
|
||||
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 = `
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "${projectName}/${STOREKIT_FILENAME}">
|
||||
</StoreKitConfigurationFileReference>`
|
||||
|
||||
// Insert before the closing </LaunchAction> tag
|
||||
scheme = scheme.replace(
|
||||
'</LaunchAction>',
|
||||
`${storeKitRef}\n </LaunchAction>`
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
7
storekit/CLAUDE.md
Normal file
7
storekit/CLAUDE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
126
storekit/TabataFit.storekit
Normal file
126
storekit/TabataFit.storekit
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user