diff --git a/frontend/mobile/__tests__/dataScreen.test.tsx b/frontend/mobile/__tests__/dataScreen.test.tsx new file mode 100644 index 000000000..48b4cbd1e --- /dev/null +++ b/frontend/mobile/__tests__/dataScreen.test.tsx @@ -0,0 +1,320 @@ +/** + * Tests for the data bundle purchase screen (#1133). + * + * All onramp calls are mocked — never against the live Linq provider. + * + * Covers the issue acceptance criteria: + * 1. Bundles listed per operator with size and validity + * 2. Empty bundle list reported gracefully (not as a failure) + * 3. Fee shown before the passkey prompt (confirm step) + * 4. End-to-end purchase flow against a mocked provider + * 5. Dashboard tile flipped from 'soon' → 'live' (PayForGrid) + */ + +import React from 'react'; + +// ── Mock declarations (top-level, matching feePayerScreen.test.tsx pattern) ─── + +const mockFetchDataBundles = jest.fn(); +const mockGetOnrampRate = jest.fn(); +const mockPayBill = jest.fn(); +const mockSpendAsset = jest.fn(); +const mockGetBillStatus = jest.fn(); +const mockGetCustomerRef = jest.fn(); +const mockGetFeePayerAddress = jest.fn(); +const mockIsNairaVerified = jest.fn(); +const mockPreviewSpendFee = jest.fn(); +const mockRouterPush = jest.fn(); +const mockRouterBack = jest.fn(); + +jest.mock('../lib/onramp', () => ({ + fetchDataBundles: (...a: unknown[]) => mockFetchDataBundles(...a), + getOnrampRate: (...a: unknown[]) => mockGetOnrampRate(...a), + payBill: (...a: unknown[]) => mockPayBill(...a), + getBillStatus: (...a: unknown[]) => mockGetBillStatus(...a), + getCustomerRef: (...a: unknown[]) => mockGetCustomerRef(...a), + isNairaVerified: (...a: unknown[]) => mockIsNairaVerified(...a), +})); + +jest.mock('../lib/spendAsset', () => { + class NotEnoughToSend extends Error { + available: number; + constructor(msg: string, available: number) { + super(msg); + this.available = available; + } + } + return { + spendAsset: (...a: unknown[]) => mockSpendAsset(...a), + previewSpendFee: (...a: unknown[]) => mockPreviewSpendFee(...a), + NotEnoughToSend, + }; +}); + +jest.mock('../lib/activity', () => ({ + getFeePayerAddress: (...a: unknown[]) => mockGetFeePayerAddress(...a), +})); + +jest.mock('../lib/assets', () => ({ + getAssetIssuer: jest.fn(() => 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'), +})); + +jest.mock('../components/WalletProvider', () => ({ + useWallet: () => ({ wallet: { deploy: jest.fn() } }), +})); + +jest.mock('../hooks/useTheme', () => ({ + useTheme: () => ({ + colors: new Proxy({}, { + get: (_t: object, prop: string) => + prop === 'border' ? 'rgba(0,0,0,0.1)' : + prop === 'danger' ? '#FF0000' : + prop === 'dangerSurface' ? 'rgba(255,0,0,0.1)' : + '#000000', + }), + }), +})); + +jest.mock('../hooks/useNetwork', () => ({ + useNetwork: () => ({ networkName: 'mainnet' }), +})); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: mockRouterPush, back: mockRouterBack }), +})); + +jest.mock('../components/FlowHeader', () => ({ + FlowHeader: ({ title }: { title: string }) => { + const { Text } = require('react-native'); + return {title}; + }, +})); + +jest.mock('../components/SlideToConfirm', () => ({ + SlideToConfirm: ({ onConfirm, label }: { onConfirm: () => void; label: string }) => { + const { Pressable, Text } = require('react-native'); + return ( + + {label} + + ); + }, +})); + +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({ children }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +// ── Top-level imports (after mocks, matching feePayerScreen pattern) ────────── + +import DataScreen from '../app/data'; +import { BILL_SERVICES } from '../components/PayForGrid'; + +// ── Test renderer (no @types, minimal slice used) ───────────────────────────── + +type RNInstance = { + props: Record; + children: (RNInstance | string)[]; +}; +type TestRenderer = { + act: (fn: () => Promise | void) => Promise; + create: (el: React.ReactElement) => { + root: { + findByProps: (p: Record) => RNInstance; + findAllByProps: (p: Record) => RNInstance[]; + }; + }; +}; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const renderer: TestRenderer = require('react-test-renderer'); + +function textContent(node: RNInstance): string { + return node.children + .map((c) => (typeof c === 'string' ? c : textContent(c))) + .join(''); +} + +// The first render pays for transforming the whole React Native + Expo graph. +jest.setTimeout(60_000); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const BUNDLES = [ + { id: 'mtn-1gb-30d', label: '1 GB', sizeMb: 1024, validityDays: 30, amountNgn: 1500 }, + { id: 'mtn-500mb-7d', label: '500 MB', sizeMb: 500, validityDays: 7, amountNgn: 500 }, +]; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + mockFetchDataBundles.mockResolvedValue(BUNDLES); + mockGetOnrampRate.mockResolvedValue(1500); // ₦1,500 per USDC + mockGetCustomerRef.mockResolvedValue('veil_testref001'); + mockGetFeePayerAddress.mockResolvedValue('GFEE0000000000000000000000000000000000000000000000000000'); + mockIsNairaVerified.mockResolvedValue(true); + mockPreviewSpendFee.mockResolvedValue(null); + mockPayBill.mockResolvedValue({ + id: 'order-abc123', + customerRef: 'veil_testref001', + wallet: 'GWALLET000000000000000000000000000000000000000000000000000', + status: 'pending', + }); + mockSpendAsset.mockResolvedValue('txhash000000000000000000000000000000000000000000000000000000000000'); + mockGetBillStatus.mockResolvedValue({ status: 'complete' }); + mockRouterPush.mockReset(); + mockRouterBack.mockReset(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function renderScreen() { + let tree!: ReturnType; + await renderer.act(async () => { + tree = renderer.create(); + }); + return tree.root; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +it('renders the form step on mount with operator chips and phone input', async () => { + const root = await renderScreen(); + + // findAllByProps can return both the JS component and host (native) node for the same testID. + expect(root.findAllByProps({ testID: 'phone-input' }).length).toBeGreaterThanOrEqual(1); + + const chips = root.findAllByProps({ accessibilityRole: 'radio' }); + const labels = chips.map((c) => textContent(c)); + expect(labels).toContain('MTN'); + expect(labels).toContain('AIRTEL'); + expect(labels).toContain('GLO'); + expect(labels).toContain('9MOBILE'); +}); + +it('lists bundles with size and validity, not price alone', async () => { + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + const b1 = root.findByProps({ testID: 'bundle-mtn-1gb-30d' }); + const b2 = root.findByProps({ testID: 'bundle-mtn-500mb-7d' }); + + expect(textContent(b1)).toContain('1 GB'); + expect(textContent(b1)).toContain('30 days'); + expect(textContent(b2)).toContain('500 MB'); + expect(textContent(b2)).toContain('7 days'); + + // Price is also present (the user needs it), but not the only thing shown. + expect(textContent(root.findByProps({ testID: 'bundle-price-mtn-1gb-30d' }))).toContain('₦1,500'); +}); + +it('shows "No bundles available" gracefully when operator returns empty list', async () => { + mockFetchDataBundles.mockResolvedValue([]); + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + const notice = root.findByProps({ testID: 'empty-bundles-notice' }); + expect(textContent(notice)).toContain('No bundles available'); + + // The continue button is disabled — no bundle to proceed with. + const btn = root.findByProps({ testID: 'continue-btn' }); + expect(btn.props.disabled).toBe(true); +}); + +it('does NOT render an error card when the bundle list is simply empty', async () => { + mockFetchDataBundles.mockResolvedValue([]); + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + // Empty is informational, not an error. + expect(root.findAllByProps({ testID: 'bundles-error' })).toHaveLength(0); +}); + +it('shows cost summary once a bundle is selected', async () => { + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + // Select the 1 GB bundle. + await renderer.act(async () => { + (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)(); + }); + + const summary = root.findByProps({ testID: 'cost-summary' }); + expect(textContent(summary)).toContain('1 GB'); + expect(textContent(summary)).toContain('30 days'); + + // Rate = ₦1,500 per USDC; bundle = ₦1,500 → 1.00 USDC. + const costNode = root.findByProps({ testID: 'cost-value' }); + expect(textContent(costNode)).toContain('1.00 USDC'); +}); + +it('advances to confirm step with the fee shown before the passkey gesture', async () => { + mockPreviewSpendFee.mockResolvedValue({ amount: '0.01', assetCode: 'USDC' }); + + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + // Select bundle + fill valid phone. + await renderer.act(async () => { + (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)(); + }); + await renderer.act(async () => { + (root.findByProps({ testID: 'phone-input' }).props.onChangeText as (v: string) => void)('08031234567'); + }); + + // Tap continue. + await renderer.act(async () => { + (root.findByProps({ testID: 'continue-btn' }).props.onPress as () => void)(); + await new Promise((r) => setTimeout(r, 100)); + }); + + // SlideToConfirm present → we are on the confirm step. + // findAllByProps can return both JS and host node instances for the same testID. + expect(root.findAllByProps({ testID: 'slide-to-confirm' }).length).toBeGreaterThanOrEqual(1); + + // previewSpendFee must be called before the user can slide (on the confirm step). + expect(mockPreviewSpendFee).toHaveBeenCalled(); +}); + +it('calls payBill with the phone as customerId and the bundle id alongside it, then spendAsset', async () => { + const root = await renderScreen(); + await renderer.act(async () => { await new Promise((r) => setTimeout(r, 50)); }); + + await renderer.act(async () => { + (root.findByProps({ testID: 'bundle-mtn-1gb-30d' }).props.onPress as () => void)(); + }); + await renderer.act(async () => { + (root.findByProps({ testID: 'phone-input' }).props.onChangeText as (v: string) => void)('08031234567'); + }); + + // Advance to confirm. + await renderer.act(async () => { + (root.findByProps({ testID: 'continue-btn' }).props.onPress as () => void)(); + await new Promise((r) => setTimeout(r, 50)); + }); + + // Confirm via slide. + await renderer.act(async () => { + (root.findByProps({ testID: 'slide-to-confirm' }).props.onPress as () => void)(); + await new Promise((r) => setTimeout(r, 150)); + }); + + expect(mockPayBill).toHaveBeenCalledWith( + expect.objectContaining({ + billCategory: 'data', + customerId: '08031234567', + bundleId: 'mtn-1gb-30d', + provider: 'MTN', + }), + ); + expect(mockSpendAsset).toHaveBeenCalled(); +}); + +it('BILL_SERVICES data tile stays soon until the backend route exists', () => { + const dataTile = BILL_SERVICES.find((s) => s.id === 'data'); + expect(dataTile).toBeDefined(); + expect(dataTile?.status).toBe('soon'); + expect(dataTile?.route).toBeUndefined(); +}); diff --git a/frontend/mobile/app/data.tsx b/frontend/mobile/app/data.tsx new file mode 100644 index 000000000..2eca0f8f8 --- /dev/null +++ b/frontend/mobile/app/data.tsx @@ -0,0 +1,916 @@ +/** + * Buy a mobile data bundle with crypto from this wallet. + * + * ## Why this is not airtime + * + * Airtime is an arbitrary naira amount. A data bundle is a *product* — 1 GB + * for 30 days, chosen from the operator's own catalogue. The price comes from + * the selected bundle, not from a free-text field, and showing naira amounts + * alone tells the user nothing about what they are buying. This screen fetches + * the bundle list for the chosen operator and lets the user pick by size and + * validity. + * + * ## Same order-of-operations discipline as airtime + * + * `payBill` reserves the order and locks the rate before anything is signed. + * A reservation that fails costs nothing; a spend with no order behind it is + * money sent to an address that was never expecting it. + * + * ## "Empty list" is not a failure + * + * An operator with no bundles returns an empty array. The screen says so + * clearly rather than treating it as an error — the user has done nothing wrong. + * + * ## Fee shown before the passkey + * + * `previewSpendFee` is called on the confirm step, before the slide-to-pay + * gesture. A charge a user only finds in their history is a surprise, not a fee. + * + * ## Test boundary + * + * Linq has no sandbox. Everything is tested against mocks — never against the + * live provider. See `__tests__/dataScreen.test.tsx`. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; + +import { FlowHeader } from '../components/FlowHeader'; +import { SlideToConfirm } from '../components/SlideToConfirm'; +import { useWallet } from '../components/WalletProvider'; +import { useTheme } from '../hooks/useTheme'; +import { useNetwork } from '../hooks/useNetwork'; +import type { ThemeColors } from '../lib/theme'; +import { fontFamily } from '../theme/typography'; +import { errorMessage } from '../lib/errorMessage'; +import { getAssetIssuer } from '../lib/assets'; +import { getFeePayerAddress } from '../lib/activity'; +import { NotEnoughToSend, previewSpendFee, spendAsset } from '../lib/spendAsset'; +import type { PlatformFee } from '../lib/platformFee'; +import { + getBillStatus, + getCustomerRef, + isNairaVerified, + getOnrampRate, + payBill, + fetchDataBundles, + type NairaCoin, + type DataBundle, +} from '../lib/onramp'; + +type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed'; + +/** + * Nigerian mobile operators — same set as airtime for consistency. + */ +const OPERATORS = ['MTN', 'AIRTEL', 'GLO', '9MOBILE'] as const; +type Operator = (typeof OPERATORS)[number]; + +/** Phone-number prefixes by operator, for auto-detection. */ +const PREFIXES: Record = { + MTN: ['0803', '0806', '0703', '0706', '0813', '0816', '0810', '0814', '0903', '0906', '0913', '0916'], + AIRTEL: ['0802', '0808', '0708', '0812', '0701', '0902', '0901', '0904', '0907', '0912'], + GLO: ['0805', '0807', '0705', '0815', '0811', '0905', '0915'], + '9MOBILE': ['0809', '0818', '0817', '0909', '0908'], +}; + +function detectOperator(phone: string): Operator | null { + const digits = phone.replace(/\D/g, ''); + if (digits.length < 4) return null; + const prefix = digits.slice(0, 4); + for (const [op, list] of Object.entries(PREFIXES) as [Operator, string[]][]) { + if (list.includes(prefix)) return op; + } + return null; +} + +/** Format megabytes as "500 MB" or "1.5 GB" etc. */ +function formatSize(mb: number): string { + if (mb >= 1024) return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`; + return `${mb} MB`; +} + +function naira(n: number): string { + return `₦${n.toLocaleString('en-NG', { maximumFractionDigits: 2 })}`; +} + +export default function DataScreen() { + const router = useRouter(); + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { wallet } = useWallet(); + + const { networkName } = useNetwork(); + const mainnetOnly = networkName !== 'mainnet'; + + const [step, setStep] = useState('form'); + const [error, setError] = useState(null); + + // Form + const [operator, setOperator] = useState('MTN'); + const [operatorTouched, setOperatorTouched] = useState(false); + const [phone, setPhone] = useState(''); + const [coin, setCoin] = useState('usdc'); + const [selectedBundle, setSelectedBundle] = useState(null); + + // Bundle list + const [bundles, setBundles] = useState([]); + const [bundlesState, setBundlesState] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'failed'>('idle'); + + // Exchange rate + const [rate, setRate] = useState(null); + const [rateState, setRateState] = useState<'loading' | 'ready' | 'failed'>('loading'); + + // Order + const [customerRef, setCustomerRef] = useState(null); + const [refundAddress, setRefundAddress] = useState(null); + const [orderId, setOrderId] = useState(null); + const [payHash, setPayHash] = useState(null); + const [spent, setSpent] = useState(null); + // Shown before the passkey prompt. Null when the Veil smart wallet charges no + // fee, which is the usual case — it is shown only when there is something + // to show. + const [veilFee, setVeilFee] = useState(null); + const [waitingForPasskey, setWaitingForPasskey] = useState(false); + + const pollRef = useRef | null>(null); + + // Auto-detect operator from phone prefix. The user can override it — numbers + // are ported, and a ported number sent to the wrong biller fails silently. + const detected = detectOperator(phone); + useEffect(() => { + if (detected && !operatorTouched) setOperator(detected); + }, [detected, operatorTouched]); + + // Resolve the fee-payer address (refund target) and stable customer reference + // once on mount. Neither changes within a session. + useEffect(() => { + let cancelled = false; + void (async () => { + const [addr, ref] = await Promise.all([ + getFeePayerAddress().catch(() => null), + getCustomerRef().catch(() => null), + ]); + if (!cancelled) { + setRefundAddress(addr); + setCustomerRef(ref); + } + })(); + return () => { cancelled = true; }; + }, []); + + // Fetch exchange rate whenever the coin selection changes. Rate is per coin + // (XLM ≠ USDC), so switching the toggle must re-fetch rather than reuse. + const loadRate = useCallback(async () => { + if (mainnetOnly) return; + setRateState('loading'); + try { + setRate(await getOnrampRate(coin)); + setRateState('ready'); + } catch { + setRate(null); + setRateState('failed'); + } + }, [mainnetOnly, coin]); + + useEffect(() => { void loadRate(); }, [loadRate]); + + // Fetch bundle catalogue whenever the chosen operator changes. + const loadBundles = useCallback(async (op: Operator) => { + if (mainnetOnly) return; + setBundlesState('loading'); + setBundles([]); + setSelectedBundle(null); + try { + const result = await fetchDataBundles(op); + if (result.length === 0) { + setBundlesState('empty'); + } else { + setBundles(result); + setBundlesState('ready'); + } + } catch { + setBundlesState('failed'); + } + }, [mainnetOnly]); + + useEffect(() => { void loadBundles(operator); }, [loadBundles, operator]); + + // Poll the vend result. Unlike the onramp, nothing on-chain says whether the + // bundle activated — asking the status endpoint is the only way to know. + useEffect(() => { + if (!orderId || !customerRef || step !== 'paying' || !payHash) return; + const check = async () => { + try { + const s = await getBillStatus(customerRef, orderId); + if (/complete|success|delivered/i.test(s.status)) setStep('done'); + if (/fail|refund|revers/i.test(s.status)) setStep('failed'); + } catch (err) { + // Silent to the user: a failed poll is not actionable, and the payment + // has already left — an error here reads as if it had not, which risks + // a double-spend. + console.warn('[data] status poll failed:', errorMessage(err)); + } + }; + void check(); + pollRef.current = setInterval(check, 10_000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + }; + }, [orderId, customerRef, step, payHash]); + + // Derived + const code = coin.toUpperCase(); + const amountNgn = selectedBundle?.amountNgn ?? 0; + const cost = rate && amountNgn > 0 ? amountNgn / rate : 0; + const phoneDigits = phone.replace(/\D/g, ''); + const phoneValid = phoneDigits.length === 11 && phoneDigits.startsWith('0'); + + // Preview the Veil platform fee on the confirm step — only then, and only + // when there is a real cost to compute. Reading balances on every keystroke + // would be a request per digit. + useEffect(() => { + if (step !== 'confirm' || cost <= 0) return; + let cancelled = false; + void (async () => { + const issuer = coin === 'usdc' ? getAssetIssuer('USDC', 'mainnet') : null; + const fee = await previewSpendFee({ + amount: String(cost), + ...(issuer ? { asset: { code: 'USDC', issuer } } : {}), + }).catch(() => null); + if (!cancelled) setVeilFee(fee); + })(); + return () => { cancelled = true; }; + }, [step, cost, coin]); + + // KYC gate: check before advancing to confirm. If not verified, redirect to + // /verify with enough context to return to this screen after. + const continueFromForm = useCallback(async () => { + if (await isNairaVerified()) { + setStep('confirm'); + return; + } + router.push({ + pathname: '/verify', + params: { + returnTo: '/data', + label: 'Data', + summary: `Data · ${selectedBundle?.label ?? ''} to ${phone}`, + }, + }); + }, [router, selectedBundle, phone]); + + // Reserve the order, then spend. Reserve first — a failed reservation costs + // nothing; a spend with no order behind it is money sent into a void. + const pay = useCallback(async () => { + if (!customerRef || !refundAddress || !rate || !selectedBundle) return; + setStep('paying'); + setError(null); + try { + const amountStableCoin = Number((selectedBundle.amountNgn / rate).toFixed(7)); + const order = await payBill({ + customerRef, + billCategory: 'data', + provider: operator, + // customerId is the subscriber, not the product. The bundle id travels + // alongside it in `bundleId` so Linq still knows which phone to credit. + customerId: phoneDigits, + // Never elide or abbreviate the provider's product id. + bundleId: selectedBundle.id, + amountNgn: selectedBundle.amountNgn, + amountStableCoin, + rate, + coin, + refundAddress, + }); + setOrderId(order.id); + setSpent(amountStableCoin); + + setWaitingForPasskey(true); + const issuer = coin === 'usdc' ? getAssetIssuer('USDC', 'mainnet') : null; + const hash = await spendAsset({ + to: order.wallet, + // From the order, not from the screen: Linq settles at whatever + // arrives, so an amount that drifted would quietly buy less data. + amount: String(amountStableCoin), + ...(issuer ? { asset: { code: 'USDC', issuer } } : {}), + deploy: wallet.deploy, + }); + setWaitingForPasskey(false); + setPayHash(hash); + } catch (err) { + setWaitingForPasskey(false); + if (err instanceof NotEnoughToSend) { + setError( + `This wallet holds ${err.available.toLocaleString('en-US', { maximumFractionDigits: 7 })} ${code}, less than this bundle costs.`, + ); + } else { + setError(errorMessage(err)); + } + setStep('confirm'); + } + }, [customerRef, refundAddress, rate, selectedBundle, operator, phoneDigits, coin, wallet.deploy, code]); + + const stepNumber = step === 'form' ? 1 : 2; + + return ( + + + + {!mainnetOnly && (step === 'form' || step === 'confirm') ? ( + + + + + + {stepNumber} / 2 + + ) : null} + + + + + {mainnetOnly ? ( + + MAINNET ONLY + Switch to mainnet to buy data + + Data bundles are vended against real crypto, so this only works on mainnet — there + is no test mode. Switch network in Settings, then come back. + + + ) : null} + + {error ? ( + + {error} + + ) : null} + + {/* ── d1 · form ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'form' && ( + <> + OPERATOR + + {OPERATORS.map((op) => ( + { + setOperator(op); + setOperatorTouched(true); + }} + style={[styles.chip, operator === op && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ selected: operator === op }} + > + + {op} + + + ))} + + + + PHONE NUMBER + + setPhone(t.replace(/[^\d]/g, '').slice(0, 11))} + keyboardType="number-pad" + placeholder="0803 512 4471" + placeholderTextColor={colors.textMuted} + testID="phone-input" + /> + + {detected ? ( + + {detected} number, detected from {phoneDigits.slice(0, 4)} + {operatorTouched && operator !== detected ? ` — sending to ${operator}` : ''} + + ) : ( + + Numbers get ported — check the operator above matches. + + )} + + + {/* Bundle catalogue ───────────────────────────────────────── */} + BUNDLE + + {bundlesState === 'loading' && ( + + + Loading bundles… + + )} + + {bundlesState === 'empty' && ( + + No bundles available + + {operator} has no data bundles configured right now. Try another operator or + check back later. + + + )} + + {bundlesState === 'failed' && ( + + Could not load bundles. + void loadBundles(operator)} hitSlop={8}> + Try again + + + )} + + {bundlesState === 'ready' && bundles.map((b) => ( + setSelectedBundle(b)} + accessibilityRole="radio" + accessibilityState={{ selected: selectedBundle?.id === b.id }} + accessibilityLabel={`${formatSize(b.sizeMb)}, ${b.validityDays} days, ${naira(b.amountNgn)}`} + style={({ pressed }) => [ + styles.bundleRow, + selectedBundle?.id === b.id && styles.bundleRowActive, + pressed && styles.pressed, + ]} + testID={`bundle-${b.id}`} + > + + + {formatSize(b.sizeMb)} + + + {b.validityDays} day{b.validityDays !== 1 ? 's' : ''} + + + + {naira(b.amountNgn)} + + + ))} + + {selectedBundle ? ( + <> + + {(['xlm', 'usdc'] as NairaCoin[]).map((c) => ( + setCoin(c)} + style={[styles.segmentItem, coin === c && styles.segmentActive]} + > + + {c.toUpperCase()} + + + ))} + + + + + Bundle + + {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''} + + + + Costs + + {rateState === 'ready' && cost > 0 + ? `${cost.toFixed(coin === 'xlm' ? 4 : 2)} ${code}` + : rateState === 'loading' + ? 'Checking…' + : '—'} + + + {rateState === 'failed' && ( + void loadRate()}> + Rate unavailable — try again + + )} + + + ) : null} + + void continueFromForm()} + disabled={!phoneValid || !selectedBundle || rateState !== 'ready'} + style={({ pressed }) => [ + styles.primaryBtn, + (!phoneValid || !selectedBundle || rateState !== 'ready') && styles.disabled, + pressed && styles.pressed, + ]} + testID="continue-btn" + > + Continue + + + )} + + {/* ── d2 · confirm ──────────────────────────────────────────────── */} + {!mainnetOnly && step === 'confirm' && selectedBundle && ( + <> + + + {formatSize(selectedBundle.sizeMb)} data for {phoneDigits} costs + + + {cost.toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + + Operator + {operator} + + + Phone + {phoneDigits} + + + Bundle + + {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''} + + + + Paid from + {code} balance + + + Network fee + Sponsored + + {veilFee ? ( + + Veil fee + + {veilFee.amount} {veilFee.assetCode} + + + ) : null} + + + + + If {operator} can't activate the bundle, the full amount comes straight back + to this wallet. + + + + void pay()} /> + Then confirm with your passkey + + setStep('form')}> + Back + + + )} + + {/* ── d3 · passkey / in flight ──────────────────────────────────── */} + {!mainnetOnly && step === 'paying' && selectedBundle && ( + <> + + + {waitingForPasskey ? 'Confirm with your passkey' : 'Activating your bundle'} + + + {waitingForPasskey + ? 'Face ID, fingerprint or device PIN, whichever this phone uses.' + : `${operator} is activating ${formatSize(selectedBundle.sizeMb)} on ${phoneDigits}.`} + + + + + + Bundle + + {formatSize(selectedBundle.sizeMb)} · {operator} {phoneDigits} + + + + You spend + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + + + + {waitingForPasskey ? 'Waiting for your passkey…' : 'Waiting for activation…'} + + + + )} + + {/* ── d4 · done ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'done' && selectedBundle && ( + <> + + Bundle activated + + {formatSize(selectedBundle.sizeMb)} is on {phoneDigits}. It usually shows within + a minute. + + + + + + Operator + {operator} + + + Bundle + + {formatSize(selectedBundle.sizeMb)} · {selectedBundle.validityDays} day{selectedBundle.validityDays !== 1 ? 's' : ''} + + + + You spent + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + { + setStep('form'); + setSelectedBundle(null); + setOrderId(null); + setPayHash(null); + setSpent(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Buy again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Done + + + )} + + {/* ── d5 · failed, refunding ────────────────────────────────────── */} + {!mainnetOnly && step === 'failed' && selectedBundle && ( + <> + {/* + * "We could not confirm this" — not "Payment failed". + * + * The user's crypto has left. Saying "failed" reads as if it + * has not, which could push them to pay again — double-spending + * against an order that may still complete. The wording follows + * the same discipline as the airtime screen and the issue spec. + */} + + We could not confirm this + + Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} is being returned + to this wallet. No data bundle was activated on {phoneDigits}. + + + + + + {operator} did not complete the activation + — + + + Refund on its way back + Usually a few minutes + + + + + Nothing for you to do. It returns to this wallet and will show in your activity. + + + { + setStep('form'); + setOrderId(null); + setPayHash(null); + setError(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Try again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + {orderId ? ( + Ref {orderId.slice(0, 8).toUpperCase()} + ) : null} + + )} + + + + ); +} + +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.background }, + flex: { flex: 1 }, + header: { paddingHorizontal: 20, paddingTop: 16 }, + body: { padding: 20, paddingBottom: 60, gap: 16 }, + + progressRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }, + progressTrack: { flex: 1, flexDirection: 'row', height: 3, borderRadius: 2, backgroundColor: colors.border }, + progressFill: { backgroundColor: colors.accent, borderRadius: 2 }, + progressLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 12 }, + + card: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + gap: 8, + }, + noteCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + backgroundColor: colors.surface, + }, + errorCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.danger, + borderRadius: 12, + padding: 12, + backgroundColor: colors.dangerSurface, + }, + errorText: { color: colors.danger, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 }, + + eyebrow: { color: colors.textMuted, fontFamily: fontFamily.accent, fontSize: 11, letterSpacing: 0.8 }, + question: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 20 }, + hint: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 }, + centeredHint: { + color: colors.textMuted, + fontFamily: fontFamily.body, + fontSize: 13, + textAlign: 'center', + }, + money: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 28 }, + + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4, gap: 12 }, + rowLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, flexShrink: 1 }, + rowValue: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 13 }, + rowValueStrong: { color: colors.textPrimary, fontFamily: fontFamily.bodySemiBold, fontSize: 15 }, + + field: { gap: 6 }, + label: { color: colors.textMuted, fontFamily: fontFamily.accent, fontSize: 11, letterSpacing: 0.8 }, + + phoneRow: { + paddingTop: 8, + paddingBottom: 10, + borderBottomWidth: 1.5, + borderBottomColor: colors.accent, + }, + phoneInput: { + color: colors.textStrong, + fontFamily: fontFamily.address, + fontSize: 21, + letterSpacing: 0.8, + padding: 0, + }, + + chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + chip: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + chipActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + chipText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 13 }, + chipTextActive: { color: colors.onAccent }, + + bundleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 14, + padding: 14, + gap: 12, + }, + bundleRowActive: { + borderColor: colors.accent, + backgroundColor: 'rgba(253,218,36,0.06)', + }, + bundleInfo: { gap: 2 }, + bundleSize: { + color: colors.textPrimary, + fontFamily: fontFamily.bodySemiBold, + fontSize: 15, + }, + bundleSizeActive: { color: colors.accent }, + bundleValidity: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 12 }, + bundlePrice: { + color: colors.textPrimary, + fontFamily: fontFamily.bodySemiBold, + fontSize: 15, + }, + bundlePriceActive: { color: colors.accent }, + + segment: { flexDirection: 'row', gap: 8 }, + segmentItem: { + flex: 1, + alignItems: 'center', + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + segmentActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + segmentText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 14 }, + segmentTextActive: { color: colors.onAccent }, + + centeredRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + justifyContent: 'center', + paddingVertical: 8, + }, + waitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + justifyContent: 'center', + paddingVertical: 8, + }, + + primaryBtn: { + backgroundColor: colors.accent, + borderRadius: 999, + paddingVertical: 16, + alignItems: 'center', + }, + primaryText: { color: colors.onAccent, fontFamily: fontFamily.bodySemiBold, fontSize: 16 }, + secondaryBtn: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 999, + paddingVertical: 16, + alignItems: 'center', + }, + secondaryText: { color: colors.textPrimary, fontFamily: fontFamily.bodySemiBold, fontSize: 16 }, + linkText: { color: colors.accent, fontFamily: fontFamily.body, fontSize: 13, paddingTop: 6, textAlign: 'center' }, + + disabled: { opacity: 0.4 }, + pressed: { opacity: 0.85 }, + }); +} diff --git a/frontend/mobile/lib/onramp.ts b/frontend/mobile/lib/onramp.ts index 5b50f5ce0..27504ccb9 100644 --- a/frontend/mobile/lib/onramp.ts +++ b/frontend/mobile/lib/onramp.ts @@ -500,6 +500,44 @@ export function getOnrampStatus(customerRef: string, orderId: string): Promise { + const { bundles } = await call<{ bundles: DataBundle[] }>( + `/ngn/bills/data/bundles?operator=${encodeURIComponent(operator)}`, + ); + return Array.isArray(bundles) ? bundles : []; +} + export interface BillOrder { id: string; customerRef: string; @@ -521,6 +559,12 @@ export function payBill(params: { provider: string; /** The phone number for airtime, the meter number for electricity. */ customerId: string; + /** + * The provider's opaque product id for a data purchase. Travelling next to + * `customerId` rather than replacing it is the whole point: the phone number + * says who, the bundle id says what. Mirrors wraith#305's purchase field. + */ + bundleId?: string; amountNgn: number; amountStableCoin: number; rate: number;