import { useMemo } from 'react' import { Linking, Pressable, ScrollView, StyleSheet, View, } from 'react-native' import { useNavigation } from '@react-navigation/native' import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useI18n } from '@d3ro/i18n' import type { RootStackParamList } from '../../App' import { type GooglePlaySubscriptionId, type StoreSubscription, useBilling, } from '../lib/billing-context' import { useEntitlement } from '../lib/entitlement-context' import { type RewardedCreditStatus, useMobileAds, useRewardedCredits, } from '../lib/mobile-ads-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { SITE_URLS } from '@d3ro/core/web-urls' type Navigation = NativeStackNavigationProp type TranslationKey = Parameters['t']>[0] const PLAN_FEATURE_KEYS: Readonly< Record > = { d3ro_voice_pro_monthly: [ 'mobile.paywall.proFeature1', 'mobile.paywall.proFeature2', 'mobile.paywall.proFeature3', 'mobile.paywall.proFeature4', ], d3ro_voice_pro_plus_monthly: [ 'mobile.paywall.proPlusFeature1', 'mobile.paywall.proPlusFeature2', 'mobile.paywall.proPlusFeature3', 'mobile.paywall.proPlusFeature4', ], } function planTitle( productId: GooglePlaySubscriptionId, t: ReturnType['t'], ): string { return productId === 'd3ro_voice_pro_plus_monthly' ? t('mobile.paywall.proPlus') : t('mobile.paywall.pro') } function rewardMessageKey(status: RewardedCreditStatus): TranslationKey { switch (status) { case 'loading': return 'mobile.paywall.rewardLoading' case 'showing': return 'mobile.paywall.rewardShowing' case 'server-verifying': return 'mobile.paywall.rewardVerifying' case 'credited': return 'mobile.paywall.rewardCredited' case 'verification-pending': return 'mobile.paywall.rewardPending' case 'closed-without-reward': return 'mobile.paywall.rewardClosed' default: return 'mobile.paywall.rewardUnavailable' } } export default function ProPaywallScreen(): React.ReactElement { const insets = useSafeAreaInsets() const navigation = useNavigation() const { t } = useI18n() const { palette } = useMobilePreferences() const styles = usePaywallStyles() const billing = useBilling() const entitlement = useEntitlement() const ads = useMobileAds() const reward = useRewardedCredits() const isPaid = entitlement.snapshot.tier !== 'free' const busy = [ 'loading-products', 'purchasing', 'verifying', 'restoring', ].includes(billing.operation) const currentPlanLabel = entitlement.snapshot.tier === 'pro_plus' ? t('mobile.paywall.proPlus') : entitlement.snapshot.tier === 'pro' ? t('mobile.paywall.pro') : t('mobile.paywall.free') const billingStatus = useMemo<{ key: TranslationKey danger: boolean } | null>(() => { switch (billing.operation) { case 'purchasing': case 'verifying': case 'restoring': case 'loading-products': return { key: 'mobile.paywall.processing', danger: false } case 'pending': return { key: 'mobile.paywall.pending', danger: false } case 'succeeded': return { key: 'mobile.paywall.success', danger: false } case 'cancelled': return { key: 'mobile.paywall.cancelled', danger: false } case 'failed': return { key: 'mobile.paywall.error', danger: true } default: return null } }, [billing.operation]) return ( {t('mobile.paywall.title')} {t('mobile.paywall.currentPlan')}: {currentPlanLabel} navigation.goBack()} style={({ pressed }) => [ styles.closeButton, { borderColor: palette.border.default, backgroundColor: pressed ? palette.bg.cardHover : palette.bg.inset, }, ]} > × {t('mobile.paywall.currentPlan')} {currentPlanLabel} {t('mobile.paywall.currentCredits')} {entitlement.snapshot.overageCredits} {t('mobile.paywall.creditsUnit')} {!isPaid && ( {t('mobile.paywall.rewardTitle')} {t('mobile.paywall.rewardDescription')} {reward.status === 'ready' ? ( ) : reward.status === 'error' || reward.status === 'closed-without-reward' || reward.status === 'verification-pending' ? ( ) : null} {reward.status !== 'ready' && ( {reward.status === 'error' ? t('mobile.paywall.error', { code: reward.errorCode ?? ads.errorCode ?? 'rewarded_ad_failed', }) : t(rewardMessageKey(reward.status))} )} )} {t('mobile.paywall.plans')} {billing.products.length === 0 ? ( {t(billing.storeAvailable ? 'mobile.paywall.storeUnavailable' : 'mobile.paywall.storeInstallRequired')} {billing.storeAvailable && ( void billing.refreshProducts()} testID="retry-store-products" /> )} ) : billing.products.map((product) => ( void billing.purchase(product.id)} /> ))} {billingStatus && ( {billingStatus.key === 'mobile.paywall.error' ? t(billingStatus.key, { code: billing.errorCode ?? 'billing_failed' }) : t(billingStatus.key)} )} {isPaid && ( void billing.manage()} testID="manage-subscription" style={styles.actionButton} /> )} void billing.restore()} testID="restore-purchases" style={styles.actionButton} /> {t('mobile.paywall.billingNotice')} void Linking.openURL(SITE_URLS.terms)} style={styles.legalLink} > {t('mobile.paywall.terms')} · void Linking.openURL(SITE_URLS.privacy)} style={styles.legalLink} > {t('mobile.paywall.privacy')} ) } function PlanCard({ product, current, hasExistingSubscription, busy, onPurchase, }: { product: StoreSubscription current: boolean hasExistingSubscription: boolean busy: boolean onPurchase: () => void }): React.ReactElement { const { t } = useI18n() const { palette } = useMobilePreferences() const styles = usePaywallStyles() const title = planTitle(product.id, t) const features = PLAN_FEATURE_KEYS[product.id] return ( {title} {product.basePlanId && ( {product.basePlanId} )} {product.displayPrice} {features.map((key) => ( ✓ {t(key)} ))} ) } type Palette = ReturnType['palette'] function usePaywallStyles(): ReturnType { const { palette } = useMobilePreferences() return useMemo(() => createStyles(palette), [palette]) } function createStyles(_palette: Palette) { return StyleSheet.create({ screen: { flex: 1 }, header: { minHeight: 82, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16, paddingHorizontal: 20, paddingBottom: 12, borderBottomWidth: 1, }, headerText: { flex: 1, gap: 4 }, closeButton: { width: 48, height: 48, borderRadius: 24, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, scroll: { flex: 1 }, content: { padding: 18, gap: 16 }, summaryCard: { flexDirection: 'row', padding: 14 }, summaryItem: { flex: 1, gap: 6 }, summaryDivider: { width: 1, marginHorizontal: 12 }, rewardCard: { gap: 12 }, description: { lineHeight: 19 }, statusText: { marginTop: 2, lineHeight: 18 }, sectionHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingTop: 8, }, connectionDot: { width: 9, height: 9, borderRadius: 5 }, unavailableCard: { gap: 14 }, planCard: { gap: 16 }, planHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, }, planTitle: { flex: 1, gap: 4 }, featureList: { gap: 8 }, featureRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, featureText: { flex: 1, lineHeight: 18 }, statusCard: { padding: 12 }, actionButton: { marginTop: -4 }, legalNotice: { lineHeight: 17, textAlign: 'center', marginTop: 4 }, legalLinks: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 12, }, legalLink: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 4 }, }) }