feat(mobile): overhaul mobile app with Pro paywall, rewarded video refill, iOS permissions, and build APK
Some checks failed
deploy-site-windows / deploy-win (push) Successful in 48s
deploy-site / deploy (push) Failing after 24s

This commit is contained in:
Yun Chan 2026-08-20 21:49:05 +09:00
parent e87567fa90
commit 7e264dca37
8 changed files with 535 additions and 27 deletions

View file

@ -3,11 +3,13 @@
// Converted from Expo → RN CLI
import React from 'react'
import { View, ScrollView, StyleSheet } from 'react-native'
import { View, ScrollView, StyleSheet, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'
import {
MetalCard,
PhosphorText,
PhysicalButton,
Led,
ScreenPanel,
AppStatusBar,
@ -19,6 +21,7 @@ import { useI18n } from '@d3ro/i18n'
export default function DashScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const navigation = useNavigation<any>()
return (
<ScrollView
@ -95,6 +98,24 @@ export default function DashScreen(): React.ReactElement {
</View>
</MetalCard>
{/* Monetization / Pro & Reward Refill Card */}
<Pressable onPress={() => navigation.navigate('ProPaywall')}>
<MetalCard style={styles.upgradeCard}>
<View style={styles.upgradeHeader}>
<PhosphorText variant="title" color="amber">💎 PRO & REWARDS</PhosphorText>
<View style={styles.upgradeBadge}>
<PhosphorText variant="label" color="amber">REFLL +50</PhosphorText>
</View>
</View>
<PhosphorText variant="small" color="primary" style={styles.upgradeText}>
15 PRO (Ad-Free)
</PhosphorText>
<View style={styles.upgradeAction}>
<PhosphorText variant="label" color="amber"> </PhosphorText>
</View>
</MetalCard>
</Pressable>
{/* Usage */}
<PhosphorText variant="label" color="muted" style={styles.usageTitle}>
{t('mobile.dash.usage')}
@ -104,7 +125,7 @@ export default function DashScreen(): React.ReactElement {
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.llmProcess')} value={t('mobile.dash.unlimited')} />
<View style={styles.usageDivider} />
<UsageRow label={t('mobile.dash.premiumQuota')} value="250 / 500" progress={0.5} />
<UsageRow label={t('mobile.dash.premiumQuota')} value="50 / 500 TOKENS" progress={0.1} />
</MetalCard>
<AppStatusBar />
@ -173,6 +194,32 @@ const styles = StyleSheet.create({
},
infoRow: { flexDirection: 'row', alignItems: 'center' },
infoValue: { marginLeft: 8 },
upgradeCard: {
backgroundColor: 'rgba(242, 91, 41, 0.08)',
borderColor: 'rgba(242, 91, 41, 0.35)',
marginBottom: 16,
padding: 16,
borderWidth: 1.5
},
upgradeHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
},
upgradeBadge: {
backgroundColor: 'rgba(242, 91, 41, 0.25)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4
},
upgradeText: {
marginBottom: 10,
lineHeight: 18
},
upgradeAction: {
alignItems: 'flex-end'
},
usageTitle: { letterSpacing: 3, marginBottom: 8, marginLeft: 4 },
usageCard: { marginBottom: 12, padding: 0, overflow: 'hidden' },
usageRow: {

View file

@ -0,0 +1,435 @@
// 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 {
View,
ScrollView,
StyleSheet,
Pressable,
Alert,
ActivityIndicator,
Platform,
Linking
} 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 { useI18n } from '@d3ro/i18n'
type Currency = 'USD' | 'KRW'
type TierKey = 'pro' | 'pro_plus' | 'team'
interface PricingPlan {
key: TierKey
title: string
badge?: string
priceUSD: string
priceKRW: string
period: string
features: string[]
recommended?: boolean
}
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 프롬프트'
]
}
]
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)
}
}
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)
}
}
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>
}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 40 }]}
>
{/* 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>
</View>
<PhosphorText variant="small" color="muted" style={styles.rewardDesc}>
15 +50 Cloud AI .
</PhosphorText>
<PhysicalButton
label={rewardLoading ? '광고 로딩 중...' : '▶ 15초 광고 보고 +50 토큰 무료 충전'}
onPress={handleWatchRewardedVideo}
disabled={rewardLoading}
variant="primary"
style={styles.rewardButton}
/>
</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
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>
<View style={styles.priceGroup}>
<PhosphorText variant="title" color="amber">{price}</PhosphorText>
<PhosphorText variant="label" color="muted">{plan.period}</PhosphorText>
</View>
</View>
{/* 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>
</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: {
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
},
currencyToggle: {
flexDirection: 'row',
backgroundColor: d3roNativePalette.bg.card,
borderRadius: 6,
borderWidth: 1,
borderColor: d3roNativePalette.border.default,
overflow: 'hidden'
},
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
},
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
},
legalLinks: {
flexDirection: 'row',
alignItems: 'center'
}
})

View file

@ -3,8 +3,9 @@
// Converted from Expo → RN CLI
import React from 'react'
import { View, ScrollView, StyleSheet, Alert, Switch } from 'react-native'
import { View, ScrollView, StyleSheet, Alert, Switch, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'
import {
MetalCard,
PhosphorText,
@ -21,6 +22,7 @@ export default function SettingsScreen(): React.ReactElement {
const insets = useSafeAreaInsets()
const { t } = useI18n()
const { user } = useAuth()
const navigation = useNavigation<any>()
async function handleLogout(): Promise<void> {
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
@ -76,6 +78,10 @@ export default function SettingsScreen(): React.ReactElement {
</PhosphorText>
</View>
</View>
<Pressable onPress={() => navigation.navigate('ProPaywall')} style={styles.upgradeSettingRow}>
<PhosphorText variant="body" color="amber">💎 PRO & </PhosphorText>
<PhosphorText variant="label" color="amber"></PhosphorText>
</Pressable>
</MetalCard>
{/* Backend Section */}
@ -206,5 +212,14 @@ const styles = StyleSheet.create({
},
rowRight: { flexDirection: 'row', alignItems: 'center' },
rowValue: { marginLeft: 8 },
upgradeSettingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderTopWidth: 1,
borderTopColor: 'rgba(242, 91, 41, 0.25)',
backgroundColor: 'rgba(242, 91, 41, 0.08)'
},
logoutWrap: { margin: 20 }
})