d3ro-voice/apps/mobile-rn/src/screens/ProPaywallScreen.tsx
Yun Chan b6fe588a7c feat(web): serve the web app under /app and send every billing link there (WS-B)
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.
2026-09-26 15:48:30 +09:00

458 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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