Skip to content

Commit 8a133f9

Browse files
committed
fix(mobile): readable speed units + a real upgrade page
- formatBytes rounded the sub-1KB case, so fractional bytes/sec speeds printed a long decimal tail (e.g. "523.7481 B/s"); round to whole bytes. - UpgradeScreen was a one-line dead end. Rebuild it into a clear how-to: value + price (X FLUX / 30 days), numbered steps pointing to the web pay flow, and this device's prefilled, copyable reference to match the payment on the site. Still store-compliant (no pay-to-address/QR/Buy button in the app build). - The free-tier line now reads "Go Premium for full speed ›" (it opens the in-app upgrade page, not an external site).
1 parent 0fa4eed commit 8a133f9

2 files changed

Lines changed: 109 additions & 62 deletions

File tree

clients/mobile/src/screens/ConnectScreen.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from 'react-native';
1717
import type { RouteStyle } from '@cumulusvpn/core';
1818
import type { Country } from '../lib/gateways';
19-
import type { PaymentIdentity, VpnActions, VpnModel } from '../state/useVpn';
19+
import type { VpnActions, VpnModel } from '../state/useVpn';
2020
import { Orb } from '../components/Orb';
2121
import { TierPill } from '../components/TierPill';
2222
import { Toggle } from '../components/Toggle';
@@ -216,7 +216,7 @@ export function ConnectScreen({
216216
/>
217217

218218
{/* Free-tier upsell line — store-compliant, no purchase UI (docs/05). */}
219-
{vpn.tier === 'free' ? <UpgradeLine payment={vpn.payment} onPress={onOpenUpgrade} /> : null}
219+
{vpn.tier === 'free' ? <UpgradeLine onPress={onOpenUpgrade} /> : null}
220220

221221
<Pressable
222222
style={[
@@ -420,19 +420,12 @@ function KillSwitchRow({
420420
);
421421
}
422422

423-
function UpgradeLine({
424-
payment,
425-
onPress,
426-
}: {
427-
readonly payment: PaymentIdentity | null;
428-
readonly onPress: () => void;
429-
}): React.JSX.Element {
423+
function UpgradeLine({ onPress }: { readonly onPress: () => void }): React.JSX.Element {
430424
return (
431425
<Pressable onPress={onPress} accessibilityRole="button" style={styles.upsell}>
432426
<Text style={styles.upsellText}>
433-
Free · 100 KB/s — <Text style={styles.upsellAccent}>Upgrade at cumulusvpn.com</Text>
427+
Free · 100 KB/s — <Text style={styles.upsellAccent}>Go Premium for full speed ›</Text>
434428
</Text>
435-
{payment ? <Text style={styles.upsellMemo}>{payment.memo}</Text> : null}
436429
</Pressable>
437430
);
438431
}
@@ -448,7 +441,9 @@ function Stat({ k, v }: { readonly k: string; readonly v: string }): React.JSX.E
448441

449442
function formatBytes(n: number): string {
450443
if (n < 1024) {
451-
return `${n} B`;
444+
// Round: speeds are fractional bytes/sec, so `${n}` would print a long tail
445+
// of decimals (e.g. "523.7481 B/s").
446+
return `${Math.round(n)} B`;
452447
}
453448
const units = ['KB', 'MB', 'GB', 'TB'];
454449
let v = n / 1024;
@@ -650,7 +645,6 @@ const styles = StyleSheet.create({
650645
upsell: { alignItems: 'center', marginBottom: space.md },
651646
upsellText: { color: color.inkMuted, fontSize: 13 },
652647
upsellAccent: { color: color.amber, fontWeight: '600' },
653-
upsellMemo: { fontFamily: font.mono, fontSize: 10.5, color: color.inkFaint, marginTop: 2 },
654648
bigBtn: { borderRadius: radius.md, paddingVertical: 16, alignItems: 'center' },
655649
// Clearly-disabled look while connecting/disconnecting (was too subtle before).
656650
bigBtnBusy: { opacity: 0.45 },

clients/mobile/src/screens/UpgradeScreen.tsx

Lines changed: 102 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,15 @@
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';
2117
import type { Tier } from '@cumulusvpn/core';
2218
import type { PaymentIdentity } from '../state/useVpn';
2319
import { 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

3431
export 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 &amp; 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+
106126
const 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

Comments
 (0)