apps/web was never deployed, so /billing on the public domain returned the landing page and d3ro.dev (desktop "upgrade") did not resolve. - apps/web runs with basePath /app and output standalone; /download and /releases redirect to the site's #download. A Dockerfile and a d3ro-web compose service (port 3002) deploy it to the NAS with the other images. - The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host) and rewrites upstream redirects; everything else still goes to Pages. With no origin configured /app answers 503 instead of the landing page. - Desktop upgrade, desktop Stripe return, mobile subscription management, the web checkout/portal returns and the site all use billingUrl(); the return query is success=1 / canceled=1, which the billing page reads. The billing page highlights ?tier=pro|pro_plus, and signing in from a billing link returns to the same plan. - auth/callback pins the redirect origin in production and rejects protocol-relative next= values (open redirect). - Mobile legal links use SITE_URLS (fixes the missing slash on /terms). - Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal mounts; deploy scripts add the web image and the SUPABASE_* values the NAS compose already required; .dockerignore keeps app .env files out of images. - Supabase auth redirects allow /app/** (remote dashboard must match). Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
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<RootStackParamList, 'ProPaywall'>
|
||
type TranslationKey = Parameters<ReturnType<typeof useI18n>['t']>[0]
|
||
|
||
const PLAN_FEATURE_KEYS: Readonly<
|
||
Record<GooglePlaySubscriptionId, readonly TranslationKey[]>
|
||
> = {
|
||
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<typeof useI18n>['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<Navigation>()
|
||
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 (
|
||
<View style={[styles.screen, { backgroundColor: palette.bg.app }]}>
|
||
<View
|
||
style={[
|
||
styles.header,
|
||
{
|
||
paddingTop: insets.top + 8,
|
||
borderBottomColor: palette.border.subtle,
|
||
backgroundColor: palette.bg.sidebar,
|
||
},
|
||
]}
|
||
>
|
||
<View style={styles.headerText}>
|
||
<ThemeText variant="title" color="primary" accessibilityRole="header">
|
||
{t('mobile.paywall.title')}
|
||
</ThemeText>
|
||
<ThemeText variant="label" color="muted">
|
||
{t('mobile.paywall.currentPlan')}: {currentPlanLabel}
|
||
</ThemeText>
|
||
</View>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('mobile.paywall.close')}
|
||
testID="paywall-close"
|
||
onPress={() => navigation.goBack()}
|
||
style={({ pressed }) => [
|
||
styles.closeButton,
|
||
{
|
||
borderColor: palette.border.default,
|
||
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.inset,
|
||
},
|
||
]}
|
||
>
|
||
<ThemeText variant="body" color="muted">×</ThemeText>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<ScrollView
|
||
style={styles.scroll}
|
||
contentContainerStyle={[
|
||
styles.content,
|
||
{ paddingBottom: insets.bottom + 36 },
|
||
]}
|
||
testID="paywall-screen"
|
||
>
|
||
<ThemeCard style={styles.summaryCard}>
|
||
<View style={styles.summaryItem}>
|
||
<ThemeText variant="label" color="muted">
|
||
{t('mobile.paywall.currentPlan')}
|
||
</ThemeText>
|
||
<ThemeText variant="heading" color="accent">{currentPlanLabel}</ThemeText>
|
||
</View>
|
||
<View style={[styles.summaryDivider, { backgroundColor: palette.border.subtle }]} />
|
||
<View style={styles.summaryItem}>
|
||
<ThemeText variant="label" color="muted">
|
||
{t('mobile.paywall.currentCredits')}
|
||
</ThemeText>
|
||
<ThemeText variant="heading" color="accent">
|
||
{entitlement.snapshot.overageCredits} {t('mobile.paywall.creditsUnit')}
|
||
</ThemeText>
|
||
</View>
|
||
</ThemeCard>
|
||
|
||
{!isPaid && (
|
||
<ThemeCard style={[styles.rewardCard, { borderColor: palette.accent.dim }] }>
|
||
<ThemeText variant="title" color="primary">
|
||
{t('mobile.paywall.rewardTitle')}
|
||
</ThemeText>
|
||
<ThemeText variant="small" color="muted" style={styles.description}>
|
||
{t('mobile.paywall.rewardDescription')}
|
||
</ThemeText>
|
||
{reward.status === 'ready' ? (
|
||
<ThemeButton
|
||
label={t('mobile.paywall.watchReward')}
|
||
onPress={reward.show}
|
||
testID="watch-rewarded-ad"
|
||
/>
|
||
) : reward.status === 'error'
|
||
|| reward.status === 'closed-without-reward'
|
||
|| reward.status === 'verification-pending' ? (
|
||
<ThemeButton
|
||
label={t('mobile.paywall.rewardRetry')}
|
||
variant="secondary"
|
||
onPress={reward.reload}
|
||
testID="reload-rewarded-ad"
|
||
/>
|
||
) : null}
|
||
{reward.status !== 'ready' && (
|
||
<ThemeText
|
||
variant="small"
|
||
color={reward.status === 'error' ? 'danger' : 'muted'}
|
||
accessibilityRole={reward.status === 'error' ? 'alert' : undefined}
|
||
accessibilityLiveRegion={reward.status === 'error' ? 'assertive' : 'polite'}
|
||
testID="rewarded-ad-status"
|
||
style={styles.statusText}
|
||
>
|
||
{reward.status === 'error'
|
||
? t('mobile.paywall.error', {
|
||
code: reward.errorCode ?? ads.errorCode ?? 'rewarded_ad_failed',
|
||
})
|
||
: t(rewardMessageKey(reward.status))}
|
||
</ThemeText>
|
||
)}
|
||
</ThemeCard>
|
||
)}
|
||
|
||
<View style={styles.sectionHeader}>
|
||
<ThemeText variant="title" color="primary">
|
||
{t('mobile.paywall.plans')}
|
||
</ThemeText>
|
||
<View
|
||
style={[
|
||
styles.connectionDot,
|
||
{ backgroundColor: billing.connected ? palette.tag.green : palette.tag.red },
|
||
]}
|
||
/>
|
||
</View>
|
||
|
||
{billing.products.length === 0 ? (
|
||
<ThemeCard
|
||
accessibilityRole="alert"
|
||
accessibilityLiveRegion="polite"
|
||
style={styles.unavailableCard}
|
||
testID="store-products-unavailable"
|
||
>
|
||
<ThemeText variant="small" color="muted" style={styles.description}>
|
||
{t(billing.storeAvailable
|
||
? 'mobile.paywall.storeUnavailable'
|
||
: 'mobile.paywall.storeInstallRequired')}
|
||
</ThemeText>
|
||
{billing.storeAvailable && (
|
||
<ThemeButton
|
||
label={t('mobile.paywall.storeRetry')}
|
||
variant="secondary"
|
||
disabled={busy}
|
||
onPress={() => void billing.refreshProducts()}
|
||
testID="retry-store-products"
|
||
/>
|
||
)}
|
||
</ThemeCard>
|
||
) : billing.products.map((product) => (
|
||
<PlanCard
|
||
key={product.id}
|
||
product={product}
|
||
current={entitlement.snapshot.storeProductId === product.id}
|
||
hasExistingSubscription={isPaid}
|
||
busy={busy}
|
||
onPurchase={() => void billing.purchase(product.id)}
|
||
/>
|
||
))}
|
||
|
||
{billingStatus && (
|
||
<ThemeCard
|
||
style={[
|
||
styles.statusCard,
|
||
{ borderColor: billingStatus.danger ? palette.tag.red : palette.border.default },
|
||
]}
|
||
accessibilityRole={billingStatus.danger ? 'alert' : undefined}
|
||
accessibilityLiveRegion={billingStatus.danger ? 'assertive' : 'polite'}
|
||
testID="billing-operation-status"
|
||
>
|
||
<ThemeText variant="small" color={billingStatus.danger ? 'danger' : 'muted'}>
|
||
{billingStatus.key === 'mobile.paywall.error'
|
||
? t(billingStatus.key, { code: billing.errorCode ?? 'billing_failed' })
|
||
: t(billingStatus.key)}
|
||
</ThemeText>
|
||
</ThemeCard>
|
||
)}
|
||
|
||
{isPaid && (
|
||
<ThemeButton
|
||
label={t('mobile.paywall.manage')}
|
||
variant="secondary"
|
||
onPress={() => void billing.manage()}
|
||
testID="manage-subscription"
|
||
style={styles.actionButton}
|
||
/>
|
||
)}
|
||
<ThemeButton
|
||
label={t('mobile.paywall.restore')}
|
||
variant="quiet"
|
||
disabled={busy || !billing.storeAvailable}
|
||
onPress={() => void billing.restore()}
|
||
testID="restore-purchases"
|
||
style={styles.actionButton}
|
||
/>
|
||
|
||
<ThemeText variant="label" color="muted" style={styles.legalNotice}>
|
||
{t('mobile.paywall.billingNotice')}
|
||
</ThemeText>
|
||
<View style={styles.legalLinks}>
|
||
<Pressable
|
||
accessibilityRole="link"
|
||
accessibilityLabel={t('mobile.paywall.terms')}
|
||
onPress={() => void Linking.openURL(SITE_URLS.terms)}
|
||
style={styles.legalLink}
|
||
>
|
||
<ThemeText variant="small" color="accent">{t('mobile.paywall.terms')}</ThemeText>
|
||
</Pressable>
|
||
<ThemeText variant="small" color="muted">·</ThemeText>
|
||
<Pressable
|
||
accessibilityRole="link"
|
||
accessibilityLabel={t('mobile.paywall.privacy')}
|
||
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
|
||
style={styles.legalLink}
|
||
>
|
||
<ThemeText variant="small" color="accent">{t('mobile.paywall.privacy')}</ThemeText>
|
||
</Pressable>
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<ThemeCard
|
||
style={[
|
||
styles.planCard,
|
||
current && { borderColor: palette.accent.main, borderWidth: 2 },
|
||
]}
|
||
testID={`store-plan-${product.id}`}
|
||
>
|
||
<View style={styles.planHeader}>
|
||
<View style={styles.planTitle}>
|
||
<ThemeText variant="heading" color={current ? 'accent' : 'primary'}>
|
||
{title}
|
||
</ThemeText>
|
||
{product.basePlanId && (
|
||
<ThemeText variant="label" color="muted">{product.basePlanId}</ThemeText>
|
||
)}
|
||
</View>
|
||
<ThemeText variant="title" color="accent">{product.displayPrice}</ThemeText>
|
||
</View>
|
||
<View style={styles.featureList}>
|
||
{features.map((key) => (
|
||
<View key={key} style={styles.featureRow}>
|
||
<ThemeText variant="small" color="success">✓</ThemeText>
|
||
<ThemeText variant="small" color="secondary" style={styles.featureText}>
|
||
{t(key)}
|
||
</ThemeText>
|
||
</View>
|
||
))}
|
||
</View>
|
||
<ThemeButton
|
||
label={current
|
||
? t('mobile.paywall.current')
|
||
: hasExistingSubscription
|
||
? t('mobile.paywall.changePlan', { plan: title, price: product.displayPrice })
|
||
: t('mobile.paywall.subscribe', { plan: title, price: product.displayPrice })}
|
||
disabled={busy || current}
|
||
onPress={onPurchase}
|
||
testID={`purchase-${product.id}`}
|
||
/>
|
||
</ThemeCard>
|
||
)
|
||
}
|
||
|
||
type Palette = ReturnType<typeof useMobilePreferences>['palette']
|
||
|
||
function usePaywallStyles(): ReturnType<typeof createStyles> {
|
||
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 },
|
||
})
|
||
}
|