- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView - Create reusable Icon wrapper component (src/shared/components/Icon.tsx) - Remove @expo/vector-icons and lucide-react dependencies - Refactor explore tab with filters, search, and category browsing - Add collections and programs data with Supabase integration - Add explore filter store and filter sheet - Update i18n strings (en, de, es, fr) for new explore features - Update test mocks and remove stale snapshots - Add user fitness level to user store and types
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
/**
|
|
* TabataFit Paywall Screen
|
|
* Premium subscription purchase flow
|
|
*/
|
|
|
|
import React, { useMemo } from 'react'
|
|
import {
|
|
View,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Pressable,
|
|
} from 'react-native'
|
|
import { useRouter } from 'expo-router'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
|
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useHaptics, usePurchases } from '@/src/shared/hooks'
|
|
import { StyledText } from '@/src/shared/components/StyledText'
|
|
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
|
import type { ThemeColors } from '@/src/shared/theme/types'
|
|
import { SPACING } from '@/src/shared/constants/spacing'
|
|
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// FEATURES LIST
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const PREMIUM_FEATURES: { icon: IconName; key: string }[] = [
|
|
{ icon: 'music.note.list', key: 'music' },
|
|
{ icon: 'infinity', key: 'workouts' },
|
|
{ icon: 'chart.bar.fill', key: 'stats' },
|
|
{ icon: 'flame.fill', key: 'calories' },
|
|
{ icon: 'bell.fill', key: 'reminders' },
|
|
{ icon: 'xmark.circle.fill', key: 'ads' },
|
|
]
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// COMPONENTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
interface PlanCardStyles {
|
|
planCard: object
|
|
planCardPressed: object
|
|
savingsBadge: object
|
|
savingsText: object
|
|
planInfo: object
|
|
planTitle: object
|
|
planPeriod: object
|
|
planPrice: object
|
|
checkmark: object
|
|
}
|
|
|
|
function PlanCard({
|
|
title,
|
|
price,
|
|
period,
|
|
savings,
|
|
isSelected,
|
|
onPress,
|
|
colors,
|
|
styles,
|
|
}: {
|
|
title: string
|
|
price: string
|
|
period: string
|
|
savings?: string
|
|
isSelected: boolean
|
|
onPress: () => void
|
|
colors: ThemeColors
|
|
styles: PlanCardStyles
|
|
}) {
|
|
const haptics = useHaptics()
|
|
|
|
const handlePress = () => {
|
|
haptics.selection()
|
|
onPress()
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={handlePress}
|
|
style={({ pressed }) => [
|
|
styles.planCard,
|
|
isSelected && { borderColor: BRAND.PRIMARY },
|
|
pressed && styles.planCardPressed,
|
|
{
|
|
backgroundColor: colors.bg.surface,
|
|
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
|
|
},
|
|
]}
|
|
>
|
|
{savings && (
|
|
<View style={styles.savingsBadge}>
|
|
<StyledText size={10} weight="bold" color={colors.text.primary}>{savings}</StyledText>
|
|
</View>
|
|
)}
|
|
<View style={styles.planInfo}>
|
|
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
|
{title}
|
|
</StyledText>
|
|
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: 2 }}>
|
|
{period}
|
|
</StyledText>
|
|
</View>
|
|
<StyledText size={20} weight="bold" color={BRAND.PRIMARY}>
|
|
{price}
|
|
</StyledText>
|
|
{isSelected && (
|
|
<View style={styles.checkmark}>
|
|
<Icon name="checkmark.circle.fill" 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 colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
|
|
// Extract plan card styles for the child component
|
|
const planCardStyles = useMemo(
|
|
() => ({
|
|
planCard: styles.planCard,
|
|
planCardPressed: styles.planCardPressed,
|
|
savingsBadge: styles.savingsBadge,
|
|
savingsText: styles.savingsText,
|
|
planInfo: styles.planInfo,
|
|
planTitle: styles.planTitle,
|
|
planPeriod: styles.planPeriod,
|
|
planPrice: styles.planPrice,
|
|
checkmark: styles.checkmark,
|
|
}),
|
|
[styles],
|
|
)
|
|
|
|
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 }]}>
|
|
{/* Close Button */}
|
|
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
|
|
<Icon name="xmark" size={28} color={colors.text.secondary} />
|
|
</Pressable>
|
|
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={[
|
|
styles.scrollContent,
|
|
{ paddingBottom: insets.bottom + 100 },
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<StyledText size={32} weight="bold" color={colors.text.primary} style={{ textAlign: 'center' }}>
|
|
TabataFit+
|
|
</StyledText>
|
|
<StyledText size={16} color={colors.text.secondary} style={{ textAlign: 'center', marginTop: SPACING[2] }}>
|
|
{t('paywall.subtitle')}
|
|
</StyledText>
|
|
</View>
|
|
|
|
{/* Features Grid */}
|
|
<View style={styles.featuresGrid}>
|
|
{PREMIUM_FEATURES.map((feature) => (
|
|
<View key={feature.key} style={styles.featureItem}>
|
|
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
|
|
<Icon name={feature.icon} size={22} color={BRAND.PRIMARY} />
|
|
</View>
|
|
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
|
|
{t(`paywall.features.${feature.key}`)}
|
|
</StyledText>
|
|
</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')}
|
|
colors={colors}
|
|
styles={planCardStyles}
|
|
/>
|
|
<PlanCard
|
|
title={t('paywall.monthly')}
|
|
price={monthlyPrice}
|
|
period={t('paywall.perMonth')}
|
|
isSelected={selectedPlan === 'monthly'}
|
|
onPress={() => setSelectedPlan('monthly')}
|
|
colors={colors}
|
|
styles={planCardStyles}
|
|
/>
|
|
</View>
|
|
|
|
{/* Price Note */}
|
|
{selectedPlan === 'annual' && (
|
|
<StyledText size={13} color={colors.text.tertiary} style={{ textAlign: 'center', marginTop: SPACING[3] }}>
|
|
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
|
|
</StyledText>
|
|
)}
|
|
|
|
{/* CTA Button */}
|
|
<Pressable
|
|
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
|
|
onPress={handlePurchase}
|
|
disabled={isLoading}
|
|
>
|
|
<LinearGradient
|
|
colors={GRADIENTS.CTA}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.ctaGradient}
|
|
>
|
|
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
|
{isLoading ? t('paywall.processing') : t('paywall.trialCta')}
|
|
</StyledText>
|
|
</LinearGradient>
|
|
</Pressable>
|
|
|
|
{/* Restore & Terms */}
|
|
<View style={styles.footer}>
|
|
<Pressable onPress={handleRestore}>
|
|
<StyledText size={14} color={colors.text.tertiary}>
|
|
{t('paywall.restore')}
|
|
</StyledText>
|
|
</Pressable>
|
|
|
|
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
|
|
{t('paywall.terms')}
|
|
</StyledText>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// STYLES
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function createStyles(colors: ThemeColors) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bg.base,
|
|
},
|
|
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: colors.text.primary,
|
|
textAlign: 'center',
|
|
},
|
|
subtitle: {
|
|
fontSize: 16,
|
|
color: colors.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,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: SPACING[2],
|
|
},
|
|
featureText: {
|
|
fontSize: 13,
|
|
textAlign: 'center',
|
|
},
|
|
plansContainer: {
|
|
marginTop: SPACING[6],
|
|
gap: SPACING[3],
|
|
},
|
|
planCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: RADIUS.LG,
|
|
padding: SPACING[4],
|
|
borderWidth: 2,
|
|
},
|
|
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: colors.text.primary,
|
|
},
|
|
planInfo: {
|
|
flex: 1,
|
|
},
|
|
planTitle: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
planPeriod: {
|
|
fontSize: 13,
|
|
marginTop: 2,
|
|
},
|
|
planPrice: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
},
|
|
checkmark: {
|
|
marginLeft: SPACING[2],
|
|
},
|
|
priceNote: {
|
|
fontSize: 13,
|
|
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',
|
|
},
|
|
footer: {
|
|
marginTop: SPACING[5],
|
|
alignItems: 'center',
|
|
gap: SPACING[4],
|
|
},
|
|
restoreText: {
|
|
fontSize: 14,
|
|
},
|
|
termsText: {
|
|
fontSize: 11,
|
|
textAlign: 'center',
|
|
lineHeight: 18,
|
|
paddingHorizontal: SPACING[4],
|
|
},
|
|
})
|
|
}
|