Skip to content

Commit c02d772

Browse files
committed
feat(mobile): show subscription expiry + let premium users extend
The gateway already returns paid_until in /v1/status but the app discarded it. Now: - useVpn captures paid_until (from both the connected and disconnected polls) and exposes it as `paidUntil`. - Upgrade screen shows "Active until <date> · N days left" for premium, and no longer hides the pay flow from premium users — it becomes an "Add more time" flow (pay again → stacks 30 days on top of the current expiry). - Settings gains a "Plan" row (tier pill + expiry) that taps through to the plan screen. - The tier pill on Connect is now tappable (premium or free) and opens the plan screen, so "tap the Premium button" shows plan details + extend. Entitlement is chain-derived and identical on every gateway, so the expiry is read straight from status — no new endpoint.
1 parent bfdffe6 commit c02d772

5 files changed

Lines changed: 141 additions & 22 deletions

File tree

clients/mobile/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,17 @@ function App(): React.JSX.Element {
9595
) : route === 'upgrade' ? (
9696
<UpgradeScreen
9797
tier={vpn.tier}
98+
paidUntil={vpn.paidUntil}
9899
payment={vpn.payment}
99100
inAppUpgrade={flags.inAppUpgrade}
100101
onClose={() => setRoute('connect')}
101102
/>
102103
) : route === 'settings' ? (
103-
<SettingsScreen vpn={vpn} onClose={() => setRoute('connect')} />
104+
<SettingsScreen
105+
vpn={vpn}
106+
onClose={() => setRoute('connect')}
107+
onOpenUpgrade={() => setRoute('upgrade')}
108+
/>
104109
) : (
105110
<ConnectScreen
106111
vpn={vpn}

clients/mobile/src/screens/ConnectScreen.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ export function ConnectScreen({
8787
<View style={styles.top}>
8888
<Text style={styles.brand}>CumulusVPN</Text>
8989
<View style={styles.topRight}>
90-
<TierPill tier={vpn.tier} />
90+
<Pressable
91+
onPress={onOpenUpgrade}
92+
accessibilityRole="button"
93+
accessibilityLabel={vpn.tier === 'premium' ? 'View your plan' : 'Upgrade to Premium'}
94+
hitSlop={8}
95+
>
96+
<TierPill tier={vpn.tier} />
97+
</Pressable>
9198
<Pressable onPress={onOpenSettings} accessibilityRole="button" hitSlop={10}>
9299
<Text style={styles.gear}></Text>
93100
</Pressable>

clients/mobile/src/screens/SettingsScreen.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-na
66
import type { VpnActions, VpnModel } from '../state/useVpn';
77
import { CVPN_DIRECTORY_PUBKEY } from '../lib/directory';
88
import { PoweredByFlux } from '../components/PoweredByFlux';
9+
import { TierPill } from '../components/TierPill';
910
import { Toggle } from '../components/Toggle';
11+
import { formatExpiry } from './UpgradeScreen';
1012
import { color, font, radius, space } from '../theme/tokens';
1113

1214
/** App version — matches the release tag; single source is package.json. */
@@ -16,9 +18,12 @@ const SITE_URL = 'https://cumulusvpn.com';
1618
interface Props {
1719
readonly vpn: VpnModel & VpnActions;
1820
readonly onClose: () => void;
21+
readonly onOpenUpgrade: () => void;
1922
}
2023

21-
export function SettingsScreen({ vpn, onClose }: Props): React.JSX.Element {
24+
export function SettingsScreen({ vpn, onClose, onOpenUpgrade }: Props): React.JSX.Element {
25+
const premium = vpn.tier === 'premium';
26+
const expiry = formatExpiry(vpn.paidUntil);
2227
return (
2328
<View style={styles.root}>
2429
<View style={styles.header}>
@@ -29,6 +34,30 @@ export function SettingsScreen({ vpn, onClose }: Props): React.JSX.Element {
2934
</View>
3035

3136
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
37+
<Text style={styles.section}>Plan</Text>
38+
<Pressable
39+
style={styles.planRow}
40+
onPress={onOpenUpgrade}
41+
accessibilityRole="button"
42+
accessibilityLabel={premium ? 'Manage your Premium plan' : 'Upgrade to Premium'}
43+
>
44+
<View style={styles.rowMeta}>
45+
<View style={styles.planTop}>
46+
<TierPill tier={vpn.tier} />
47+
</View>
48+
<Text style={styles.rowSub}>
49+
{premium
50+
? expiry
51+
? `Active until ${expiry.date} · ${expiry.daysLeft} ${
52+
expiry.daysLeft === 1 ? 'day' : 'days'
53+
} left`
54+
: 'Full speed on every gateway'
55+
: 'Limited to 100 KB/s — tap to upgrade'}
56+
</Text>
57+
</View>
58+
<Text style={styles.chev}></Text>
59+
</Pressable>
60+
3261
<Text style={styles.section}>Connection</Text>
3362
<ToggleRow
3463
title="Auto-connect on launch"
@@ -143,6 +172,20 @@ const styles = StyleSheet.create({
143172
marginBottom: space.sm,
144173
gap: space.md,
145174
},
175+
planRow: {
176+
flexDirection: 'row',
177+
alignItems: 'center',
178+
justifyContent: 'space-between',
179+
backgroundColor: color.glass,
180+
borderColor: color.hairline,
181+
borderWidth: 1,
182+
borderRadius: radius.sm,
183+
paddingHorizontal: space.md,
184+
paddingVertical: 12,
185+
marginBottom: space.sm,
186+
gap: space.md,
187+
},
188+
planTop: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
146189
rowMeta: { flex: 1 },
147190
rowTitle: { color: color.ink, fontSize: 15, fontWeight: '600' },
148191
rowSub: { color: color.inkDim, fontSize: 12, marginTop: 2 },

clients/mobile/src/screens/UpgradeScreen.tsx

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { color, font, radius, space } from '../theme/tokens';
2525

2626
interface Props {
2727
readonly tier: Tier;
28+
/** RFC3339 timestamp premium is paid through, or null when free/unknown. */
29+
readonly paidUntil: string | null;
2830
readonly payment: PaymentIdentity | null;
2931
/** Remote flag: when true, show the in-app pay flow; else "manage on web". */
3032
readonly inAppUpgrade: boolean;
@@ -34,8 +36,34 @@ interface Props {
3436
/** Where the prefilled pay-to-address upgrade flow lives on the web. */
3537
const UPGRADE_URL = 'vpn.cumulusvpn.com';
3638

37-
export function UpgradeScreen({ tier, payment, inAppUpgrade, onClose }: Props): React.JSX.Element {
39+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
40+
41+
/**
42+
* Format an RFC3339 expiry into a short date + whole days remaining. Hand-rolled
43+
* (no `Intl`/`toLocaleDateString`) so it's identical across Hermes/JSC.
44+
*/
45+
export function formatExpiry(iso: string | null): { date: string; daysLeft: number } | null {
46+
if (!iso) {
47+
return null;
48+
}
49+
const t = Date.parse(iso);
50+
if (Number.isNaN(t)) {
51+
return null;
52+
}
53+
const d = new Date(t);
54+
const daysLeft = Math.max(0, Math.ceil((t - Date.now()) / 86_400_000));
55+
return { date: `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`, daysLeft };
56+
}
57+
58+
export function UpgradeScreen({
59+
tier,
60+
paidUntil,
61+
payment,
62+
inAppUpgrade,
63+
onClose,
64+
}: Props): React.JSX.Element {
3865
const premium = tier === 'premium';
66+
const expiry = formatExpiry(paidUntil);
3967
return (
4068
<ScrollView
4169
style={styles.root}
@@ -55,9 +83,23 @@ export function UpgradeScreen({ tier, payment, inAppUpgrade, onClose }: Props):
5583
<TierPill tier={tier} />
5684
</View>
5785
{premium ? (
58-
<Text style={styles.copy}>
59-
You’re on Premium — full speed on every gateway. Nothing to do here.
60-
</Text>
86+
<>
87+
<Text style={styles.copy}>
88+
You’re on Premium — full speed on every gateway. Pay again any time to add more time;
89+
it stacks on top of your current expiry.
90+
</Text>
91+
{expiry ? (
92+
<View style={styles.priceRow}>
93+
<Text style={styles.priceLabel}>Active until</Text>
94+
<Text style={styles.price}>
95+
{expiry.date}{' '}
96+
<Text style={styles.priceUnit}>
97+
· {expiry.daysLeft} {expiry.daysLeft === 1 ? 'day' : 'days'} left
98+
</Text>
99+
</Text>
100+
</View>
101+
) : null}
102+
</>
61103
) : (
62104
<>
63105
<Text style={styles.copy}>
@@ -76,17 +118,23 @@ export function UpgradeScreen({ tier, payment, inAppUpgrade, onClose }: Props):
76118
)}
77119
</View>
78120

79-
{premium || !payment ? null : inAppUpgrade ? (
80-
<InAppPay payment={payment} />
121+
{!payment ? null : inAppUpgrade ? (
122+
<InAppPay payment={payment} premium={premium} />
81123
) : (
82-
<ManageOnWeb payment={payment} />
124+
<ManageOnWeb payment={payment} premium={premium} />
83125
)}
84126
</ScrollView>
85127
);
86128
}
87129

88130
/** Full in-app pay flow: QR + wallet hand-off + prefilled details. */
89-
function InAppPay({ payment }: { readonly payment: PaymentIdentity }): React.JSX.Element {
131+
function InAppPay({
132+
payment,
133+
premium,
134+
}: {
135+
readonly payment: PaymentIdentity;
136+
readonly premium: boolean;
137+
}): React.JSX.Element {
90138
const [walletError, setWalletError] = useState<string | null>(null);
91139
// The QR carries the BIP21 `flux:` payload — that's what a wallet's in-app
92140
// scanner (Zelcore / SSP) parses to a prefilled send.
@@ -113,6 +161,7 @@ function InAppPay({ payment }: { readonly payment: PaymentIdentity }): React.JSX
113161

114162
return (
115163
<>
164+
<Text style={styles.section}>{premium ? 'Add more time' : 'Pay with FLUX'}</Text>
116165
<View style={styles.qrWrap}>
117166
<Qr value={qrLink} size={196} />
118167
<Text style={styles.qrCap}>Scan with Zelcore / SSP Wallet</Text>
@@ -143,7 +192,11 @@ function InAppPay({ payment }: { readonly payment: PaymentIdentity }): React.JSX
143192
/>
144193
<Step
145194
n={3}
146-
text="This device unlocks automatically within ~1 minute, on every gateway at once."
195+
text={
196+
premium
197+
? 'Another 30 days is added on top of your current expiry within ~1 minute, on every gateway at once.'
198+
: 'This device unlocks automatically within ~1 minute, on every gateway at once.'
199+
}
147200
/>
148201
</View>
149202

@@ -165,10 +218,16 @@ function InAppPay({ payment }: { readonly payment: PaymentIdentity }): React.JSX
165218
}
166219

167220
/** Store-compliant "manage on the web" copy: no QR / address / purchase link. */
168-
function ManageOnWeb({ payment }: { readonly payment: PaymentIdentity }): React.JSX.Element {
221+
function ManageOnWeb({
222+
payment,
223+
premium,
224+
}: {
225+
readonly payment: PaymentIdentity;
226+
readonly premium: boolean;
227+
}): React.JSX.Element {
169228
return (
170229
<>
171-
<Text style={styles.section}>How to upgrade</Text>
230+
<Text style={styles.section}>{premium ? 'How to add more time' : 'How to upgrade'}</Text>
172231
<View style={styles.steps}>
173232
<Step n={1} text={`Open ${UPGRADE_URL} in any browser — on this phone or a computer.`} />
174233
<Step

clients/mobile/src/state/useVpn.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export interface VpnModel {
7676
readonly state: TunnelState;
7777
readonly status: TunnelStatus | null;
7878
readonly tier: Tier;
79+
/** RFC3339 timestamp premium is paid through, or null when free/unknown. */
80+
readonly paidUntil: string | null;
7981
/** True while the initial discovery/enroll bootstrap is running. */
8082
readonly booting: boolean;
8183
/** True while a fleet discovery is in flight (initial or pull-to-refresh). */
@@ -167,6 +169,8 @@ export function useVpn(): VpnModel & VpnActions {
167169
const [state, setState] = useState<TunnelState>('disconnected');
168170
const [status, setStatus] = useState<TunnelStatus | null>(null);
169171
const [tier, setTier] = useState<Tier>('free');
172+
// RFC3339 timestamp premium is paid through (null when free / unknown).
173+
const [paidUntil, setPaidUntil] = useState<string | null>(null);
170174
const [booting, setBooting] = useState(true);
171175
// True while a fleet discovery is in flight (initial + pull-to-refresh). Lets
172176
// the UI show a lightweight "finding servers" hint instead of pinning the
@@ -525,6 +529,7 @@ export function useVpn(): VpnModel & VpnActions {
525529
const st = await fetchStatus(activeIp, pubkey);
526530
if (alive) {
527531
setTier(st.tier);
532+
setPaidUntil(st.tier === 'premium' ? st.paid_until : null);
528533
}
529534
} catch {
530535
// Non-fatal: keep the last known tier.
@@ -538,20 +543,19 @@ export function useVpn(): VpnModel & VpnActions {
538543
if (sample.length === 0) {
539544
return;
540545
}
541-
const tiers = await Promise.all(
542-
sample.map((g) =>
543-
fetchStatus(g.ip, pubkey)
544-
.then((s) => s.tier)
545-
.catch(() => null),
546-
),
546+
const results = await Promise.all(
547+
sample.map((g) => fetchStatus(g.ip, pubkey).catch(() => null)),
547548
);
548549
if (!alive) {
549550
return;
550551
}
551-
if (tiers.some((t) => t === 'premium')) {
552+
const premiumResult = results.find((r) => r?.tier === 'premium');
553+
if (premiumResult) {
552554
setTier('premium');
553-
} else if (tiers.some((t) => t === 'free')) {
555+
setPaidUntil(premiumResult.paid_until);
556+
} else if (results.some((r) => r)) {
554557
setTier('free');
558+
setPaidUntil(null);
555559
}
556560
};
557561
const id = setInterval(poll, STATUS_POLL_MS);
@@ -762,6 +766,7 @@ export function useVpn(): VpnModel & VpnActions {
762766
state,
763767
status,
764768
tier,
769+
paidUntil,
765770
booting,
766771
discovering,
767772
error,

0 commit comments

Comments
 (0)