Files
tabatago/app/paywall.tsx
Millian Lamiaux b833198e9d feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer
- 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
2026-03-25 23:28:51 +01:00

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],
},
})
}