Files
tabatago/app/paywall.tsx
Millian Lamiaux 2ad7ae3a34 feat: Apple Watch app + Paywall + Privacy Policy + rebranding
## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
2026-03-11 09:43:53 +01:00

410 lines
11 KiB
TypeScript

/**
* TabataFit Paywall Screen
* Premium subscription purchase flow
*/
import React from 'react'
import {
View,
StyleSheet,
ScrollView,
Pressable,
Text,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { BRAND, darkColors } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
// ═══════════════════════════════════════════════════════════════════════════
// FEATURES LIST
// ═══════════════════════════════════════════════════════════════════════════
const PREMIUM_FEATURES = [
{ icon: 'musical-notes', key: 'music' },
{ icon: 'infinity', key: 'workouts' },
{ icon: 'stats-chart', key: 'stats' },
{ icon: 'flame', key: 'calories' },
{ icon: 'notifications', key: 'reminders' },
{ icon: 'close-circle', key: 'ads' },
]
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function PlanCard({
title,
price,
period,
savings,
isSelected,
onPress,
}: {
title: string
price: string
period: string
savings?: string
isSelected: boolean
onPress: () => void
}) {
const haptics = useHaptics()
const handlePress = () => {
haptics.selection()
onPress()
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.planCard,
isSelected && styles.planCardSelected,
pressed && styles.planCardPressed,
]}
>
{savings && (
<View style={styles.savingsBadge}>
<Text style={styles.savingsText}>{savings}</Text>
</View>
)}
<View style={styles.planInfo}>
<Text style={styles.planTitle}>{title}</Text>
<Text style={styles.planPeriod}>{period}</Text>
</View>
<Text style={styles.planPrice}>{price}</Text>
{isSelected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
</View>
)}
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function PaywallScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const {
monthlyPackage,
annualPackage,
purchasePackage,
restorePurchases,
isLoading,
} = usePurchases()
const [selectedPlan, setSelectedPlan] = React.useState<'monthly' | 'annual'>('annual')
// Get prices from RevenueCat packages
const monthlyPrice = monthlyPackage?.product.priceString ?? '$4.99'
const annualPrice = annualPackage?.product.priceString ?? '$29.99'
const annualMonthlyEquivalent = annualPackage
? (annualPackage.product.price / 12).toFixed(2)
: '2.49'
const handlePurchase = async () => {
haptics.buttonTap()
const pkg = selectedPlan === 'monthly' ? monthlyPackage : annualPackage
if (!pkg) {
console.log('[Paywall] No package available for purchase')
return
}
const result = await purchasePackage(pkg)
if (result.success) {
haptics.workoutComplete()
router.back()
} else if (!result.cancelled) {
console.log('[Paywall] Purchase error:', result.error)
}
}
const handleRestore = async () => {
haptics.selection()
const restored = await restorePurchases()
if (restored) {
haptics.workoutComplete()
router.back()
}
}
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Background Gradient */}
<LinearGradient
colors={['#1a1a2e', '#16213e', '#0f0f1a']}
style={styles.gradient}
/>
{/* Close Button */}
<Pressable style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={28} color={darkColors.text.secondary} />
</Pressable>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>TabataFit+</Text>
<Text style={styles.subtitle}>{t('paywall.subtitle')}</Text>
</View>
{/* Features Grid */}
<View style={styles.featuresGrid}>
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
</View>
<Text style={styles.featureText}>
{t(`paywall.features.${feature.key}`)}
</Text>
</View>
))}
</View>
{/* Plan Selection */}
<View style={styles.plansContainer}>
<PlanCard
title={t('paywall.yearly')}
price={annualPrice}
period={t('paywall.perYear')}
savings={t('paywall.save50')}
isSelected={selectedPlan === 'annual'}
onPress={() => setSelectedPlan('annual')}
/>
<PlanCard
title={t('paywall.monthly')}
price={monthlyPrice}
period={t('paywall.perMonth')}
isSelected={selectedPlan === 'monthly'}
onPress={() => setSelectedPlan('monthly')}
/>
</View>
{/* Price Note */}
{selectedPlan === 'annual' && (
<Text style={styles.priceNote}>
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
</Text>
)}
{/* CTA Button */}
<Pressable
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
onPress={handlePurchase}
disabled={isLoading}
>
<LinearGradient
colors={[BRAND.PRIMARY, '#FF8A5B']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
<Text style={styles.ctaText}>
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
</Text>
</LinearGradient>
</Pressable>
{/* Restore & Terms */}
<View style={styles.footer}>
<Pressable onPress={handleRestore}>
<Text style={styles.restoreText}>{t('paywall.restore')}</Text>
</Pressable>
<Text style={styles.termsText}>{t('paywall.terms')}</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
...StyleSheet.absoluteFillObject,
},
closeButton: {
position: 'absolute',
top: SPACING[4],
right: SPACING[4],
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[8],
},
header: {
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: '700',
color: '#FFF',
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: darkColors.text.secondary,
textAlign: 'center',
marginTop: SPACING[2],
},
featuresGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: SPACING[6],
marginHorizontal: -SPACING[2],
},
featureItem: {
width: '33%',
alignItems: 'center',
paddingVertical: SPACING[3],
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[2],
},
featureText: {
fontSize: 13,
color: darkColors.text.secondary,
textAlign: 'center',
},
plansContainer: {
marginTop: SPACING[6],
gap: SPACING[3],
},
planCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: RADIUS.LG,
padding: SPACING[4],
borderWidth: 2,
borderColor: 'transparent',
},
planCardSelected: {
borderColor: BRAND.PRIMARY,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
},
planCardPressed: {
opacity: 0.8,
},
savingsBadge: {
position: 'absolute',
top: -8,
right: SPACING[3],
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
savingsText: {
fontSize: 10,
fontWeight: '700',
color: '#FFF',
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 16,
fontWeight: '600',
color: darkColors.text.primary,
},
planPeriod: {
fontSize: 13,
color: darkColors.text.tertiary,
marginTop: 2,
},
planPrice: {
fontSize: 20,
fontWeight: '700',
color: BRAND.PRIMARY,
},
checkmark: {
marginLeft: SPACING[2],
},
priceNote: {
fontSize: 13,
color: darkColors.text.tertiary,
textAlign: 'center',
marginTop: SPACING[3],
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginTop: SPACING[6],
},
ctaButtonDisabled: {
opacity: 0.6,
},
ctaGradient: {
paddingVertical: SPACING[4],
alignItems: 'center',
},
ctaText: {
fontSize: 17,
fontWeight: '600',
color: '#FFF',
},
footer: {
marginTop: SPACING[5],
alignItems: 'center',
gap: SPACING[4],
},
restoreText: {
fontSize: 14,
color: darkColors.text.tertiary,
},
termsText: {
fontSize: 11,
color: darkColors.text.tertiary,
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: SPACING[4],
},
})