55 * - The FLUX payment unlocks *app functionality* (premium speed), so it falls
66 * under Apple 3.1.1(a) / Google Play Payments. A live "send FLUX to unlock"
77 * screen inside the store build would be rejected.
8- * - Therefore this screen is CONNECT-ONLY informational: it shows the current
9- * tier (a neutral fact from `/v1/status`) and, when free, a single plain
10- * line — "Upgrade at cumulusvpn.com".
11- * - On iOS there is NO tappable external link and NO purchase/"Buy" UI. The
12- * URL is shown as inert, selectable text. The user pays on the web/desktop;
13- * because entitlement is chain-based and keyed to the WG pubkey, the phone
14- * unlocks automatically within ~1 minute.
15- * - The payment CODE/MEMO is shown as a neutral reference so the user can find
16- * their key on the website. It is NOT a call to action here.
17- * - Never render this as a payment form in an iOS/Android store build. The
18- * beautiful pay-to-address screen lives on web + desktop only.
8+ * - So this screen is INFORMATIONAL: it explains exactly how to upgrade and
9+ * shows this device's reference so the user can match it on the web, but it
10+ * renders NO pay-to-address, NO QR, NO "Buy" button, and does NOT open an
11+ * external purchase link (the URL is inert, selectable text). The actual
12+ * prefilled pay-to-address flow lives on the web app (vpn.cumulusvpn.com).
13+ * - Entitlement is chain-based and keyed to the WG pubkey, so once paid on the
14+ * web the phone unlocks automatically within ~1 minute — nothing to enter.
1915 */
20- import { Platform , Pressable , StyleSheet , Text , View } from 'react-native' ;
16+ import { Pressable , StyleSheet , Text , View } from 'react-native' ;
2117import type { Tier } from '@cumulusvpn/core' ;
2218import type { PaymentIdentity } from '../state/useVpn' ;
2319import { TierPill } from '../components/TierPill' ;
@@ -29,14 +25,15 @@ interface Props {
2925 readonly onClose : ( ) => void ;
3026}
3127
32- const MANAGE_URL = 'cumulusvpn.com' ;
28+ /** Where the prefilled pay-to-address upgrade flow lives (the web app). */
29+ const UPGRADE_URL = 'vpn.cumulusvpn.com' ;
3330
3431export function UpgradeScreen ( { tier, payment, onClose } : Props ) : React . JSX . Element {
3532 const premium = tier === 'premium' ;
3633 return (
3734 < View style = { styles . root } >
3835 < View style = { styles . header } >
39- < Text style = { styles . title } > Your plan</ Text >
36+ < Text style = { styles . title } > { premium ? ' Your plan' : 'Upgrade to Premium' } </ Text >
4037 < Pressable onPress = { onClose } accessibilityRole = "button" hitSlop = { 12 } >
4138 < Text style = { styles . done } > Done</ Text >
4239 </ Pressable >
@@ -55,54 +52,77 @@ export function UpgradeScreen({ tier, payment, onClose }: Props): React.JSX.Elem
5552 ) : (
5653 < >
5754 < Text style = { styles . body } >
58- You’re on the free tier ( 100 KB/s). To unlock full speed, upgrade on the web with FLUX
59- — no account needed. Your device unlocks automatically within about a minute .
55+ Free is capped at 100 KB/s. Premium unlocks full speed on every gateway — no account,
56+ paid once with FLUX for 30 days .
6057 </ Text >
6158
62- { /*
63- Inert, selectable URL — NOT a tappable link (iOS store compliance).
64- The user types it into a browser; we never call Linking.openURL on
65- iOS for a purchase destination.
66- */ }
67- < View style = { styles . field } >
68- < Text style = { styles . fieldLabel } > Upgrade at</ Text >
69- < Text style = { styles . fieldValue } selectable >
70- { MANAGE_URL }
71- </ Text >
72- </ View >
73-
7459 { payment ? (
75- < View style = { styles . field } >
76- < Text style = { styles . fieldLabel } > Your key reference (find it on the site) </ Text >
77- < Text style = { [ styles . fieldValue , styles . mono ] } selectable >
78- { payment . memo }
60+ < View style = { styles . priceRow } >
61+ < Text style = { styles . priceLabel } > Premium </ Text >
62+ < Text style = { styles . price } >
63+ { payment . priceFlux } FLUX < Text style = { styles . priceUnit } > / 30 days </ Text >
7964 </ Text >
8065 </ View >
8166 ) : null }
82-
83- < Text style = { styles . note } >
84- Payment is verified on the Flux blockchain and tied to your device key — we never see
85- who you are.
86- </ Text >
8767 </ >
8868 ) }
8969 </ View >
9070
91- { /*
92- POC / fast-follow: Apple IAP + Google Play Billing (docs/05 option 2) as
93- an in-app fiat rail. Would gate behind Platform checks and StoreKit /
94- BillingClient bridges — deliberately absent at launch.
95- */ }
96- { Platform . OS === 'android' ? (
97- < Text style = { styles . androidHint } >
98- { /* Android allows an external-offer link under the External Offers
99- program, but commission still applies — kept inert for launch. */ }
100- </ Text >
71+ { ! premium ? (
72+ < >
73+ < Text style = { styles . section } > How to upgrade</ Text >
74+ < View style = { styles . steps } >
75+ < Step
76+ n = { 1 }
77+ text = { `Open ${ UPGRADE_URL } in any browser — on this phone or a computer.` }
78+ />
79+ < Step
80+ n = { 2 }
81+ text = "Open Upgrade. The gateway, exact FLUX amount and pay-to address are prefilled there (with a QR)."
82+ />
83+ < Step n = { 3 } text = "Pay with FLUX from any wallet — scan the QR or copy the address." />
84+ < Step
85+ n = { 4 }
86+ text = "This device unlocks automatically within ~1 minute. Nothing to enter here."
87+ />
88+ </ View >
89+
90+ { payment ? (
91+ < View style = { styles . field } >
92+ < Text style = { styles . fieldLabel } >
93+ This device’s reference · press & hold to copy
94+ </ Text >
95+ < Text style = { [ styles . fieldValue , styles . mono ] } selectable >
96+ { payment . memo }
97+ </ Text >
98+ < Text style = { styles . fieldHint } >
99+ Matches this phone to your payment on the site — no personal info.
100+ </ Text >
101+ </ View >
102+ ) : null }
103+
104+ < Text style = { styles . note } >
105+ Payment is verified on the Flux blockchain and tied to this device’s key — we never see
106+ who you are, and there’s no account to create.
107+ </ Text >
108+ </ >
101109 ) : null }
102110 </ View >
103111 ) ;
104112}
105113
114+ /** One numbered step in the how-to list. */
115+ function Step ( { n, text } : { readonly n : number ; readonly text : string } ) : React . JSX . Element {
116+ return (
117+ < View style = { styles . step } >
118+ < View style = { styles . stepNum } >
119+ < Text style = { styles . stepNumText } > { n } </ Text >
120+ </ View >
121+ < Text style = { styles . stepText } > { text } </ Text >
122+ </ View >
123+ ) ;
124+ }
125+
106126const styles = StyleSheet . create ( {
107127 root : { flex : 1 , paddingHorizontal : space . xl , paddingTop : space . sm } ,
108128 header : {
@@ -125,11 +145,44 @@ const styles = StyleSheet.create({
125145 tierRow : { flexDirection : 'row' , alignItems : 'center' , justifyContent : 'space-between' } ,
126146 tierLabel : { color : color . inkDim , fontSize : 13 } ,
127147 body : { color : color . inkMuted , fontSize : 14 , lineHeight : 20 } ,
148+ priceRow : {
149+ flexDirection : 'row' ,
150+ alignItems : 'center' ,
151+ justifyContent : 'space-between' ,
152+ borderTopColor : color . hairline ,
153+ borderTopWidth : 1 ,
154+ paddingTop : space . md ,
155+ } ,
156+ priceLabel : { color : color . inkDim , fontSize : 13 } ,
157+ price : { color : color . amber , fontSize : 18 , fontWeight : '700' } ,
158+ priceUnit : { color : color . inkFaint , fontSize : 12 , fontWeight : '500' } ,
159+ section : {
160+ color : color . inkFaint ,
161+ fontSize : 11 ,
162+ textTransform : 'uppercase' ,
163+ letterSpacing : 0.7 ,
164+ marginTop : space . xl ,
165+ marginBottom : space . sm ,
166+ } ,
167+ steps : { gap : space . md } ,
168+ step : { flexDirection : 'row' , alignItems : 'flex-start' , gap : space . md } ,
169+ stepNum : {
170+ width : 22 ,
171+ height : 22 ,
172+ borderRadius : 11 ,
173+ backgroundColor : color . glassStrong ,
174+ alignItems : 'center' ,
175+ justifyContent : 'center' ,
176+ marginTop : 1 ,
177+ } ,
178+ stepNumText : { color : color . cyan , fontSize : 12 , fontWeight : '700' } ,
179+ stepText : { flex : 1 , color : color . inkMuted , fontSize : 14 , lineHeight : 20 } ,
128180 field : {
129181 backgroundColor : color . orbCoreOn ,
130182 borderRadius : radius . sm ,
131183 padding : space . md ,
132184 gap : 4 ,
185+ marginTop : space . lg ,
133186 } ,
134187 fieldLabel : {
135188 fontSize : 10.5 ,
@@ -138,7 +191,7 @@ const styles = StyleSheet.create({
138191 letterSpacing : 0.5 ,
139192 } ,
140193 fieldValue : { color : color . amber , fontSize : 15 , fontWeight : '600' } ,
194+ fieldHint : { color : color . inkFaint , fontSize : 11.5 , lineHeight : 16 } ,
141195 mono : { fontFamily : font . mono , fontSize : 13 } ,
142- note : { color : color . inkFaint , fontSize : 12 , lineHeight : 17 } ,
143- androidHint : { height : 0 } ,
196+ note : { color : color . inkFaint , fontSize : 12 , lineHeight : 17 , marginTop : space . lg } ,
144197} ) ;
0 commit comments