feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,435 +1,457 @@
|
|||
// apps/mobile-rn/src/screens/ProPaywallScreen.tsx
|
||||
// Monetization & Subscription Paywall: In-App Purchases (StoreKit 2 / Google Play) + Rewarded Video Token Refill
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
View,
|
||||
Linking,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Linking
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import {
|
||||
MetalCard,
|
||||
PhosphorText,
|
||||
PhysicalButton,
|
||||
Led,
|
||||
Header,
|
||||
d3roNativePalette,
|
||||
d3roNativeFonts
|
||||
} from '@d3ro/ui-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'
|
||||
|
||||
type Currency = 'USD' | 'KRW'
|
||||
type TierKey = 'pro' | 'pro_plus' | 'team'
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList, 'ProPaywall'>
|
||||
type TranslationKey = Parameters<ReturnType<typeof useI18n>['t']>[0]
|
||||
|
||||
interface PricingPlan {
|
||||
key: TierKey
|
||||
title: string
|
||||
badge?: string
|
||||
priceUSD: string
|
||||
priceKRW: string
|
||||
period: string
|
||||
features: string[]
|
||||
recommended?: boolean
|
||||
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',
|
||||
],
|
||||
}
|
||||
|
||||
const PLANS: PricingPlan[] = [
|
||||
{
|
||||
key: 'pro',
|
||||
title: 'PRO',
|
||||
priceUSD: '$9.90',
|
||||
priceKRW: '13,500원',
|
||||
period: '/월',
|
||||
features: [
|
||||
'무제한 로컬 Whisper 전사',
|
||||
'월 2,000 Cloud AI 고속 토큰',
|
||||
'광고 100% 제거 (Ad-Free)',
|
||||
'모바일-데스크톱 실시간 동기화',
|
||||
'다국어 음성 번역 및 회의 요약'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'pro_plus',
|
||||
title: 'PRO+',
|
||||
badge: 'POPULAR',
|
||||
priceUSD: '$19.90',
|
||||
priceKRW: '27,000원',
|
||||
period: '/월',
|
||||
recommended: true,
|
||||
features: [
|
||||
'PRO의 모든 기능 포함',
|
||||
'월 6,000 Cloud AI 고속 토큰',
|
||||
'Deepgram Nova-3 실시간 스트리밍',
|
||||
'우선 순위 LLM 음성 질의응답',
|
||||
'화자 분리(Diarization) 지원'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
title: 'TEAM',
|
||||
priceUSD: '$25.00',
|
||||
priceKRW: '34,000원',
|
||||
period: '/인/월',
|
||||
features: [
|
||||
'팀 전용 공유 워크스페이스',
|
||||
'무제한 Cloud AI 음성 토큰',
|
||||
'중앙 결제 및 멤버 관리',
|
||||
'엔터프라이즈 전용 SLA 지원',
|
||||
'커스텀 음성 AI 프롬프트'
|
||||
]
|
||||
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 { t, locale } = useI18n()
|
||||
const navigation = useNavigation()
|
||||
const [currency, setCurrency] = useState<Currency>(locale === 'ko' ? 'KRW' : 'USD')
|
||||
const [selectedPlan, setSelectedPlan] = useState<TierKey>('pro_plus')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [rewardLoading, setRewardLoading] = useState(false)
|
||||
const [tokenBalance, setTokenBalance] = useState(50)
|
||||
|
||||
async function handleWatchRewardedVideo(): Promise<void> {
|
||||
setRewardLoading(true)
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500))
|
||||
setTokenBalance((prev) => prev + 50)
|
||||
Alert.alert(
|
||||
'🎉 토큰 충전 완료!',
|
||||
'15초 스폰서 비디오 시청이 완료되어 +50 Cloud AI 토큰이 충전되었습니다. (현재 잔여: ' + (tokenBalance + 50) + ' 토큰)',
|
||||
[{ text: '확인' }]
|
||||
)
|
||||
} finally {
|
||||
setRewardLoading(false)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurchase(plan: PricingPlan): Promise<void> {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800))
|
||||
Alert.alert(
|
||||
'결제 완료',
|
||||
`D3RO Voice ${plan.title} 플랜 구독이 활성화되었습니다. 모든 기기에서 즉시 적용됩니다.`,
|
||||
[{ text: '시작하기', onPress: () => navigation.goBack() }]
|
||||
)
|
||||
} catch (err: any) {
|
||||
Alert.alert('결제 오류', err.message || '결제를 처리할 수 없습니다.')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestorePurchases(): Promise<void> {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
Alert.alert('구매 내역 복원', '이전 구독 내역을 확인했습니다. 최신 혜택이 적용되었습니다.')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
}, [billing.operation])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Header
|
||||
title="D3RO PRO & REWARDS"
|
||||
paddingTop={insets.top}
|
||||
showBorder
|
||||
rightContent={
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.closeBtn}>
|
||||
<PhosphorText variant="body" color="muted">✕</PhosphorText>
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<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 + 40 }]}
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{ paddingBottom: insets.bottom + 36 },
|
||||
]}
|
||||
testID="paywall-screen"
|
||||
>
|
||||
{/* Token Balance & Free Refill Banner */}
|
||||
<MetalCard style={styles.rewardCard}>
|
||||
<View style={styles.rewardHeader}>
|
||||
<View>
|
||||
<PhosphorText variant="label" color="muted">CURRENT AI TOKENS</PhosphorText>
|
||||
<View style={styles.tokenRow}>
|
||||
<PhosphorText variant="hero" color="amber">{tokenBalance}</PhosphorText>
|
||||
<PhosphorText variant="body" color="amber" style={styles.tokenUnit}>TOKENS</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.refillBadge}>
|
||||
<Led color="green" size={6} />
|
||||
<PhosphorText variant="label" color="primary">FREE REWARD READY</PhosphorText>
|
||||
</View>
|
||||
<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>
|
||||
|
||||
<PhosphorText variant="small" color="muted" style={styles.rewardDesc}>
|
||||
무료 티어 사용자는 15초 스폰서 비디오 시청 시 횟수 제한 없이 +50 Cloud AI 토큰을 즉시 리필받을 수 있습니다.
|
||||
</PhosphorText>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
<PhysicalButton
|
||||
label={rewardLoading ? '광고 로딩 중...' : '▶ 15초 광고 보고 +50 토큰 무료 충전'}
|
||||
onPress={handleWatchRewardedVideo}
|
||||
disabled={rewardLoading}
|
||||
variant="primary"
|
||||
style={styles.rewardButton}
|
||||
<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 },
|
||||
]}
|
||||
/>
|
||||
</MetalCard>
|
||||
|
||||
{/* Currency Switcher */}
|
||||
<View style={styles.currencyRow}>
|
||||
<PhosphorText variant="title" color="primary">구독 요금제 (Ad-Free & 무제한)</PhosphorText>
|
||||
<View style={styles.currencyToggle}>
|
||||
<Pressable
|
||||
onPress={() => setCurrency('KRW')}
|
||||
style={[styles.curBtn, currency === 'KRW' && styles.curBtnActive]}
|
||||
>
|
||||
<PhosphorText variant="label" color={currency === 'KRW' ? 'amber' : 'muted'}>
|
||||
KRW (₩)
|
||||
</PhosphorText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setCurrency('USD')}
|
||||
style={[styles.curBtn, currency === 'USD' && styles.curBtnActive]}
|
||||
>
|
||||
<PhosphorText variant="label" color={currency === 'USD' ? 'amber' : 'muted'}>
|
||||
USD ($)
|
||||
</PhosphorText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Subscription Plan Cards */}
|
||||
{PLANS.map((plan) => {
|
||||
const isSelected = selectedPlan === plan.key
|
||||
const price = currency === 'KRW' ? plan.priceKRW : plan.priceUSD
|
||||
{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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
return (
|
||||
<Pressable key={plan.key} onPress={() => setSelectedPlan(plan.key)} style={styles.planPressable}>
|
||||
<MetalCard
|
||||
style={[
|
||||
styles.planCard,
|
||||
isSelected && styles.planCardSelected,
|
||||
plan.recommended && styles.planCardRecommended
|
||||
]}
|
||||
>
|
||||
<View style={styles.planHeader}>
|
||||
<View style={styles.planTitleGroup}>
|
||||
<PhosphorText variant="title" color={isSelected ? 'amber' : 'primary'}>
|
||||
{plan.title}
|
||||
</PhosphorText>
|
||||
{plan.badge && (
|
||||
<View style={styles.planBadge}>
|
||||
<PhosphorText variant="label" color="amber">{plan.badge}</PhosphorText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<View style={styles.priceGroup}>
|
||||
<PhosphorText variant="title" color="amber">{price}</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{plan.period}</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
{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}
|
||||
/>
|
||||
|
||||
{/* Features List */}
|
||||
<View style={styles.featureList}>
|
||||
{plan.features.map((feat, idx) => (
|
||||
<View key={idx} style={styles.featureItem}>
|
||||
<PhosphorText variant="small" color="amber">✓</PhosphorText>
|
||||
<PhosphorText variant="small" color="primary" style={styles.featureText}>
|
||||
{feat}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<PhysicalButton
|
||||
label={isProcessing ? '결제 처리 중...' : `${plan.title} 시작하기 (${price}${plan.period})`}
|
||||
onPress={() => handlePurchase(plan)}
|
||||
disabled={isProcessing}
|
||||
variant="primary"
|
||||
style={styles.subscribeBtn}
|
||||
/>
|
||||
)}
|
||||
</MetalCard>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Legal & Restore Purchases (App Store Guideline 3.1.1) */}
|
||||
<View style={styles.legalSection}>
|
||||
<Pressable onPress={handleRestorePurchases} disabled={isProcessing}>
|
||||
<PhosphorText variant="small" color="muted" style={styles.restoreText}>
|
||||
이전 구매 내역 복원 (Restore Purchases)
|
||||
</PhosphorText>
|
||||
<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('https://d3ro.chanpaca.net/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('https://d3ro.chanpaca.net/privacy/')}
|
||||
style={styles.legalLink}
|
||||
>
|
||||
<ThemeText variant="small" color="accent">{t('mobile.paywall.privacy')}</ThemeText>
|
||||
</Pressable>
|
||||
|
||||
<PhosphorText variant="label" color="muted" style={styles.legalNotice}>
|
||||
구독 결제는 {Platform.OS === 'ios' ? 'Apple App Store' : 'Google Play'} 계정을 통해 안전하게 처리되며, 현재 구독 기간 종료 최소 24시간 전에 취소하지 않으면 자동으로 갱신됩니다.
|
||||
</PhosphorText>
|
||||
|
||||
<View style={styles.legalLinks}>
|
||||
<Pressable onPress={() => Linking.openURL('https://d3ro.chanpaca.net')}>
|
||||
<PhosphorText variant="label" color="amber">이용약관</PhosphorText>
|
||||
</Pressable>
|
||||
<PhosphorText variant="label" color="muted"> • </PhosphorText>
|
||||
<Pressable onPress={() => Linking.openURL('https://d3ro.chanpaca.net')}>
|
||||
<PhosphorText variant="label" color="amber">개인정보 처리방침</PhosphorText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: d3roNativePalette.bg.app
|
||||
},
|
||||
closeBtn: {
|
||||
padding: 8
|
||||
},
|
||||
scroll: {
|
||||
flex: 1
|
||||
},
|
||||
content: {
|
||||
padding: 16
|
||||
},
|
||||
rewardCard: {
|
||||
backgroundColor: 'rgba(242, 91, 41, 0.06)',
|
||||
borderColor: 'rgba(242, 91, 41, 0.3)',
|
||||
marginBottom: 20
|
||||
},
|
||||
rewardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 10
|
||||
},
|
||||
tokenRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 6
|
||||
},
|
||||
tokenUnit: {
|
||||
fontFamily: d3roNativeFonts.mono,
|
||||
fontSize: 12
|
||||
},
|
||||
refillBadge: {
|
||||
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',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(74, 222, 128, 0.3)'
|
||||
},
|
||||
rewardDesc: {
|
||||
marginBottom: 14,
|
||||
lineHeight: 18
|
||||
},
|
||||
rewardButton: {
|
||||
width: '100%'
|
||||
},
|
||||
currencyRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14
|
||||
gap: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
currencyToggle: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: d3roNativePalette.bg.card,
|
||||
borderRadius: 6,
|
||||
headerText: { flex: 1, gap: 4 },
|
||||
closeButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
overflow: 'hidden'
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
curBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4
|
||||
},
|
||||
curBtnActive: {
|
||||
backgroundColor: 'rgba(242, 91, 41, 0.15)'
|
||||
},
|
||||
planPressable: {
|
||||
marginBottom: 14
|
||||
},
|
||||
planCard: {
|
||||
borderColor: d3roNativePalette.border.default
|
||||
},
|
||||
planCardSelected: {
|
||||
borderColor: d3roNativePalette.accent.main,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)'
|
||||
},
|
||||
planCardRecommended: {
|
||||
borderWidth: 1.5
|
||||
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: 'center',
|
||||
marginBottom: 12
|
||||
},
|
||||
planTitleGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
},
|
||||
planBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(242, 91, 41, 0.2)'
|
||||
},
|
||||
priceGroup: {
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
featureList: {
|
||||
gap: 6,
|
||||
marginBottom: 12
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
},
|
||||
featureText: {
|
||||
flex: 1
|
||||
},
|
||||
subscribeBtn: {
|
||||
marginTop: 8
|
||||
},
|
||||
legalSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 12
|
||||
},
|
||||
restoreText: {
|
||||
textDecorationLine: 'underline',
|
||||
marginBottom: 12
|
||||
},
|
||||
legalNotice: {
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
marginBottom: 12
|
||||
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',
|
||||
alignItems: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
legalLink: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 4 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue