diff --git a/frontend/mobile/app/airtime.tsx b/frontend/mobile/app/airtime.tsx index 835ac0dc0..d642ed5b9 100644 --- a/frontend/mobile/app/airtime.tsx +++ b/frontend/mobile/app/airtime.tsx @@ -70,6 +70,7 @@ import { getOnrampRate, payBill, type NairaCoin, + classifyBillStatus, } from '../lib/onramp'; import { getCategoryDeclaration, @@ -84,8 +85,9 @@ import { type PersistedBillOrder, } from '../lib/billOrders'; import { classifyBillPollStatus } from '../lib/billFlow'; +import { BillPoller, MAX_CONSECUTIVE_POLL_ERRORS } from '../lib/billPolling'; -type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed'; +type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed'; const QUICK_AMOUNTS = [200, 500, 1000, 2000]; @@ -159,7 +161,9 @@ export default function AirtimeScreen() { const [spent, setSpent] = useState(null); const [waitingForPasskey, setWaitingForPasskey] = useState(false); - const pollRef = useRef | null>(null); + const pollerRef = useRef | null>(null); + const [pollErrorCount, setPollErrorCount] = useState(0); + const [unconfirmedReason, setUnconfirmedReason] = useState<'error' | 'timeout' | null>(null); const refreshOrders = useCallback(async () => { try { @@ -229,47 +233,40 @@ export default function AirtimeScreen() { void loadRate(); }, [loadRate]); - // Poll the vend result. Unlike the onramp, nothing on-chain says whether the - // airtime landed — the deposit leaving this wallet is visible, the top-up is - // not — so asking is the only way to know. + // Poll the vend result using BillPoller for bounded wait and error recovery useEffect(() => { if (!orderId || !customerRef || step !== 'paying' || !payHash) return; - const check = async () => { - try { - const s = await getBillStatus(customerRef, orderId); - const token = s.token ?? s.prepaidToken; - if (/complete|success|delivered/i.test(s.status)) { - setStep('done'); + + const poller = new BillPoller( + customerRef, + orderId, + { + onDone: async (status) => { + const token = status?.token ?? status?.prepaidToken; if (token) setPrepaidToken(token); - await updateBillOrderStatus(orderId, s.status, token); + await updateBillOrderStatus(orderId, status?.status ?? 'completed', token); void refreshOrders(); - } - if (/fail|refund|revers/i.test(s.status)) { - setStep('failed'); - await updateBillOrderStatus(orderId, s.status); + setStep('done'); + }, + onFailed: async (status) => { + await updateBillOrderStatus(orderId, status?.status ?? 'failed'); void refreshOrders(); - } - // One classifier for every bill screen. This used to be an inline - // regex that did not know "settled"/"disbursed" — the words Linq - // actually ends a vend with — so a delivered top-up never left this - // screen. - const outcome = classifyBillPollStatus(s.status); - if (outcome === 'done') setStep('done'); - if (outcome === 'failed') setStep('failed'); - } catch (err) { - // Silent *to the user*: a failed poll is nothing they can act on, and - // the payment has already left, so an error here would read as if it - // had not. But silent to us as well is how a top-up that landed sat on - // a spinner with no way to find out why — the status was fine, the - // request never got there, and nothing anywhere said so. - console.warn('[airtime] status poll failed:', errorMessage(err)); - } - }; - void check(); - pollRef.current = setInterval(check, 10_000); + setStep('failed'); + }, + onUnconfirmed: (reason) => { + setUnconfirmedReason(reason); + setStep('unconfirmed'); + }, + onErrorCountChange: (count) => setPollErrorCount(count), + }, + ); + pollerRef.current = poller; + poller.start(); return () => { - if (pollRef.current) clearInterval(pollRef.current); - pollRef.current = null; + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } }; }, [orderId, customerRef, step, payHash, refreshOrders]); @@ -352,6 +349,8 @@ export default function AirtimeScreen() { if (!customerRef || !refundAddress || !rate) return; setStep('paying'); setError(null); + setPollErrorCount(0); + setUnconfirmedReason(null); try { // Reserve first, spend second. A reservation that fails costs nothing; // a spend with no order behind it is money sent to an address that was @@ -420,6 +419,16 @@ export default function AirtimeScreen() { } }, [customerRef, refundAddress, rate, ngn, provider, phoneDigits, coin, wallet.deploy, code]); + // Stop polling on unmount + useEffect(() => { + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, []); + const stepNumber = step === 'form' ? 1 : 2; const tokenTimeoutRef = useRef | null>(null); @@ -681,7 +690,9 @@ export default function AirtimeScreen() { {waitingForPasskey ? 'Face ID, fingerprint or device PIN, whichever this phone uses.' - : `${provider} is vending ${naira(ngn)} to ${phoneDigits}.`} + : pollErrorCount > 0 + ? `Checking status failed — retrying (${pollErrorCount}/${MAX_CONSECUTIVE_POLL_ERRORS})…` + : `${provider} is vending ${naira(ngn)} to ${phoneDigits}.`} @@ -764,8 +775,13 @@ export default function AirtimeScreen() { setOrderId(null); setPayHash(null); setSpent(null); +<<<<<<< HEAD setPrepaidToken(null); setTokenCopied(false); +======= + setPollErrorCount(0); + setUnconfirmedReason(null); +>>>>>>> funds0033-cmyk/issue-1139-naira-onramp void loadRate(); void refreshOrders(); }} @@ -814,6 +830,8 @@ export default function AirtimeScreen() { setOrderId(null); setPayHash(null); setError(null); + setPollErrorCount(0); + setUnconfirmedReason(null); void loadRate(); }} style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} @@ -831,6 +849,92 @@ export default function AirtimeScreen() { ) : null} )} + + {/* ── b6 · unconfirmed (bounded wait ended or consecutive poll errors) */} + {!mainnetOnly && step === 'unconfirmed' && ( + <> + + + {unconfirmedReason === 'error' + ? "Couldn't confirm status" + : 'Delivery taking longer than usual'} + + + {unconfirmedReason === 'error' + ? `Checking status failed after ${MAX_CONSECUTIVE_POLL_ERRORS} attempts. The network service could not be reached, but your top-up may already have arrived.` + : `Confirmation from ${provider} is taking longer than usual. Your top-up may already have arrived.`} + + + + + + Network + {provider} + + + Recipient + {phoneDigits} + + + Payment + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} sent + + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + + WHAT IS KNOWN + + • Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} left this wallet. + + + • The {naira(ngn)} airtime may already have arrived on {phoneDigits}. + + + • Check the recipient's phone balance (dial your network's balance code) or ask them directly. + + + • If the airtime has not arrived, contact support with reference{' '} + + {orderId ? orderId.slice(0, 8).toUpperCase() : 'above'} + . + + + + { + if (!pollerRef.current || isRechecking) return; + setIsRechecking(true); + pollerRef.current.manualCheck().then((result) => { + setIsRechecking(false); + if (result === 'done') setStep('done'); + else if (result === 'failed') setStep('failed'); + else if (result === 'error') setUnconfirmedReason('error'); + else setUnconfirmedReason('timeout'); + }); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + + {isRechecking ? 'Checking status…' : 'Check status again'} + + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + + )} diff --git a/frontend/mobile/app/betting.tsx b/frontend/mobile/app/betting.tsx new file mode 100644 index 000000000..8c7eac82c --- /dev/null +++ b/frontend/mobile/app/betting.tsx @@ -0,0 +1,857 @@ +/** + * Fund betting accounts with crypto from this wallet. + * + * Follows the same pattern as airtime: reserve, pay from the wallet, + * poll to a terminal state. + * + * Key differences: + * - Account ID verification via lookup where supported by the provider + * - Provider-specific (Bet9ja, SportyBet, etc.) + */ + +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, + lookupBetting, + type NairaCoin, + type BettingLookup, + classifyBillStatus, +} from '../lib/onramp'; +import { BillPoller, MAX_CONSECUTIVE_POLL_ERRORS } from '../lib/billPolling'; + +type Step = 'form' | 'lookup' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed'; + +const PROVIDERS = ['Bet9ja', 'SportyBet', '1xBet', 'BetKing']; + +const QUICK_AMOUNTS = [500, 1000, 2000, 5000]; + +function naira(n: number): string { + return `₦${n.toLocaleString('en-NG', { maximumFractionDigits: 2 })}`; +} + +export default function BettingScreen() { + 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); + + const [provider, setProvider] = useState('Bet9ja'); + const [accountId, setAccountId] = useState(''); + const [amountNGN, setAmountNGN] = useState(''); + const [coin, setCoin] = useState('usdc'); + + const [rate, setRate] = useState(null); + const [rateState, setRateState] = useState<'loading' | 'ready' | 'failed'>('loading'); + + const [customerRef, setCustomerRef] = useState(null); + const [refundAddress, setRefundAddress] = useState(null); + + const [orderId, setOrderId] = useState(null); + const [payHash, setPayHash] = useState(null); + const [veilFee, setVeilFee] = useState(null); + const [spent, setSpent] = useState(null); + const [waitingForPasskey, setWaitingForPasskey] = useState(false); + + const [lookupData, setLookupData] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + + const pollerRef = useRef | null>(null); + const [pollErrorCount, setPollErrorCount] = useState(0); + const [unconfirmedReason, setUnconfirmedReason] = useState<'error' | 'timeout' | null>(null); + const [isRechecking, setIsRechecking] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + const [addr, ref] = await Promise.all([ + getFeePayerAddress().catch(() => null), + getCustomerRef().catch(() => null), + ]); + if (cancelled) return; + setRefundAddress(addr); + setCustomerRef(ref); + })(); + return () => { + cancelled = true; + }; + }, []); + + 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]); + + useEffect(() => { + if (!orderId || !customerRef || step !== 'paying' || !payHash) return; + + const poller = new BillPoller( + customerRef, + orderId, + { + onDone: () => setStep('done'), + onFailed: () => setStep('failed'), + onUnconfirmed: (reason) => { + setUnconfirmedReason(reason); + setStep('unconfirmed'); + }, + onErrorCountChange: (count) => setPollErrorCount(count), + }, + ); + pollerRef.current = poller; + poller.start(); + + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, [orderId, customerRef, step, payHash]); + + const ngn = Number(amountNGN.replace(/[^\d]/g, '')) || 0; + const code = coin.toUpperCase(); + const cost = rate && ngn > 0 ? ngn / rate : 0; + + 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]); + + const lookupAccount = useCallback(async () => { + if (!provider || accountId.length < 3) return; + setLookupLoading(true); + setError(null); + try { + const data = await lookupBetting({ + provider, + accountId, + }); + setLookupData(data); + setStep('lookup'); + } catch (err) { + // Lookup not supported or failed - continue without it + console.warn('[betting] lookup failed:', errorMessage(err)); + setStep('confirm'); + } finally { + setLookupLoading(false); + } + }, [provider, accountId]); + + const continueFromForm = useCallback(async () => { + if (await isNairaVerified()) { + await lookupAccount(); + return; + } + router.push({ + pathname: '/verify', + params: { + returnTo: '/betting', + label: 'Betting', + summary: `Betting · ${provider}`, + }, + }); + }, [router, provider, lookupAccount]); + + const continueFromLookup = useCallback(() => { + setStep('confirm'); + }, []); + + const pay = useCallback(async () => { + if (!customerRef || !refundAddress || !rate || ngn <= 0) return; + setStep('paying'); + setError(null); + setPollErrorCount(0); + setUnconfirmedReason(null); + try { + const amountStableCoin = Number((ngn / rate).toFixed(7)); + const order = await payBill({ + customerRef, + billCategory: 'betting', + provider, + customerId: accountId, + amountNgn: ngn, + 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, + 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 deposit needs.`, + ); + } else { + setError(errorMessage(err)); + } + setStep('confirm'); + } + }, [customerRef, refundAddress, rate, ngn, provider, accountId, coin, wallet.deploy, code]); + + // Stop polling on unmount + useEffect(() => { + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, []); + + const stepNumber = step === 'form' || step === 'lookup' ? 1 : 2; + + return ( + + + + {!mainnetOnly && (step === 'form' || step === 'lookup' || step === 'confirm') ? ( + + + + + + {stepNumber} / 2 + + ) : null} + + + + + {mainnetOnly ? ( + + MAINNET ONLY + Switch to mainnet to fund betting accounts + + Deposits 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} + + {/* ── form ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'form' && ( + <> + PROVIDER + + {PROVIDERS.map((p) => ( + setProvider(p)} + style={[styles.networkChip, provider === p && styles.networkChipActive]} + > + + {p} + + + ))} + + + + ACCOUNT ID / PHONE + + + Enter your betting account ID or registered phone number + + + + AMOUNT + + ₦ + setAmountNGN(t.replace(/[^\d]/g, ''))} + keyboardType="number-pad" + placeholder="0" + placeholderTextColor={colors.textMuted} + /> + + + {QUICK_AMOUNTS.map((q) => ( + setAmountNGN(String(q))} style={styles.quickChip}> + ₦{q.toLocaleString('en-NG')} + + ))} + + + + {(['xlm', 'usdc'] as NairaCoin[]).map((c) => ( + setCoin(c)} + style={[styles.segmentItem, coin === c && styles.segmentActive]} + > + + {c.toUpperCase()} + + + ))} + + + + + 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={accountId.length < 3 || ngn <= 0 || rateState !== 'ready' || lookupLoading} + style={({ pressed }) => [ + styles.primaryBtn, + (accountId.length < 3 || ngn <= 0 || rateState !== 'ready' || lookupLoading) && styles.disabled, + pressed && styles.pressed, + ]} + > + + {lookupLoading ? 'Verifying...' : 'Continue'} + + + + )} + + {/* ── lookup results ─────────────────────────────────────────────── */} + {!mainnetOnly && step === 'lookup' && lookupData && ( + <> + + Account verified + Account: {lookupData.accountName} + Status: {lookupData.accountStatus} + {lookupData.balance !== undefined && ( + Current balance: {naira(lookupData.balance)} + )} + + + void continueFromLookup()} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Continue + + setStep('form')}> + Back + + + )} + + {/* ── confirm ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'confirm' && ( + <> + + + Deposit {naira(ngn)} to {provider} costs + + + {cost.toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + + Provider + {provider} + + + Account + {accountId} + + {lookupData && ( + + Account name + {lookupData.accountName} + + )} + + Paid from + {code} balance + + {veilFee ? ( + + Veil fee + + {veilFee.amount} {veilFee.assetCode} + + + ) : null} + + + void pay()} /> + Then confirm with your passkey + + setStep('form')}> + Back + + + )} + + {/* ── paying ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'paying' && ( + <> + + + {waitingForPasskey ? 'Confirm with your passkey' : 'Processing your deposit'} + + + {waitingForPasskey + ? 'Face ID, fingerprint or device PIN, whichever this phone uses.' + : pollErrorCount > 0 + ? `Checking status failed — retrying (${pollErrorCount}/${MAX_CONSECUTIVE_POLL_ERRORS})…` + : `${provider} is crediting your account.`} + + + + + + Deposit + + {naira(ngn)} · {provider} + + + + You spend + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + + + + {waitingForPasskey ? 'Waiting for your passkey…' : 'Waiting for confirmation…'} + + + + )} + + {/* ── done ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'done' && ( + <> + + Deposit complete + + {naira(ngn)} has been deposited to your {provider} account. It should appear within minutes. + + + + + + Provider + {provider} + + + You spent + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + { + setStep('form'); + setAmountNGN(''); + setAccountId(''); + setOrderId(null); + setPayHash(null); + setSpent(null); + setLookupData(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Deposit again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Done + + + )} + + {/* ── failed ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'failed' && ( + <> + + Payment failed + + Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} is being returned to + this wallet. No deposit was made. + + + + + + {provider} didn't complete the deposit + — + + + 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); + setPollErrorCount(0); + setUnconfirmedReason(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Try again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + + )} + + {/* ── unconfirmed (bounded wait ended or consecutive poll errors) */} + {!mainnetOnly && step === 'unconfirmed' && ( + <> + + + {unconfirmedReason === 'error' + ? "Couldn't confirm status" + : 'Delivery taking longer than usual'} + + + {unconfirmedReason === 'error' + ? `Checking status failed after ${MAX_CONSECUTIVE_POLL_ERRORS} attempts. The network service could not be reached, but your deposit may already have arrived.` + : `Confirmation from ${provider} is taking longer than usual. Your deposit may already have arrived.`} + + + + + + Provider + {provider} + + + Account ID + {accountId} + + {lookupData ? ( + + Account Name + {lookupData.accountName} + + ) : null} + + Payment + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} sent + + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + + WHAT IS KNOWN + + • Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} left this wallet. + + + • The {naira(ngn)} deposit may already have arrived in your {provider} account. + + + • Check your {provider} app or website to confirm the balance. + + + • If the deposit has not arrived, contact support with reference{' '} + + {orderId ? orderId.slice(0, 8).toUpperCase() : 'above'} + . + + + + { + if (!pollerRef.current || isRechecking) return; + setIsRechecking(true); + pollerRef.current.manualCheck().then((result) => { + setIsRechecking(false); + if (result === 'done') setStep('done'); + else if (result === 'failed') setStep('failed'); + else if (result === 'error') setUnconfirmedReason('error'); + else setUnconfirmedReason('timeout'); + }); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + + {isRechecking ? 'Checking status…' : 'Check status again'} + + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + + )} + + + + ); +} + +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, + }, + 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 }, + + noteCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + backgroundColor: colors.surface, + }, + 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 }, + linkText: { color: colors.accent, fontFamily: fontFamily.body, fontSize: 13, paddingTop: 6, textAlign: 'center' }, + 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 }, + input: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + color: colors.textPrimary, + fontFamily: fontFamily.body, + fontSize: 16, + }, + + networkRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + networkChip: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + networkChipActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + networkText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 13 }, + networkTextActive: { color: colors.onAccent }, + + amountRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + currency: { color: colors.textMuted, fontFamily: fontFamily.heading, fontSize: 30 }, + amountInput: { flex: 1, color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 34, paddingVertical: 4 }, + + quickRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + quickChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + quickText: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 13 }, + + 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 }, + + 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/app/cabletv.tsx b/frontend/mobile/app/cabletv.tsx new file mode 100644 index 000000000..2684d9742 --- /dev/null +++ b/frontend/mobile/app/cabletv.tsx @@ -0,0 +1,800 @@ +/** + * Buy cable TV subscriptions with crypto from this wallet. + * + * Follows the same pattern as airtime: reserve, pay from the wallet, + * poll to a terminal state. + * + * Key differences: + * - Smartcard lookup shows customer name, current package, and due date before spending + * - User can choose from available packages + * - Provider-specific (DSTV, GOtv, etc.) + */ + +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, + lookupCableTv, + type NairaCoin, + type CableTvLookup, + classifyBillStatus, +} from '../lib/onramp'; +import { BillPoller, MAX_CONSECUTIVE_POLL_ERRORS } from '../lib/billPolling'; + +type Step = 'form' | 'lookup' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed'; + +const PROVIDERS = ['DSTV', 'GOtv', 'StarTimes']; + +function naira(n: number): string { + return `₦${n.toLocaleString('en-NG', { maximumFractionDigits: 2 })}`; +} + +export default function CableTvScreen() { + 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); + + const [provider, setProvider] = useState('DSTV'); + const [smartcard, setSmartcard] = useState(''); + const [selectedPackage, setSelectedPackage] = useState(null); + const [coin, setCoin] = useState('usdc'); + + const [rate, setRate] = useState(null); + const [rateState, setRateState] = useState<'loading' | 'ready' | 'failed'>('loading'); + + const [customerRef, setCustomerRef] = useState(null); + const [refundAddress, setRefundAddress] = useState(null); + + const [orderId, setOrderId] = useState(null); + const [payHash, setPayHash] = useState(null); + const [veilFee, setVeilFee] = useState(null); + const [spent, setSpent] = useState(null); + const [waitingForPasskey, setWaitingForPasskey] = useState(false); + + const [lookupData, setLookupData] = useState(null); + const [lookupLoading, setLookupLoading] = useState(false); + + const pollerRef = useRef | null>(null); + const [pollErrorCount, setPollErrorCount] = useState(0); + const [unconfirmedReason, setUnconfirmedReason] = useState<'error' | 'timeout' | null>(null); + const [isRechecking, setIsRechecking] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + const [addr, ref] = await Promise.all([ + getFeePayerAddress().catch(() => null), + getCustomerRef().catch(() => null), + ]); + if (cancelled) return; + setRefundAddress(addr); + setCustomerRef(ref); + })(); + return () => { + cancelled = true; + }; + }, []); + + 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]); + + useEffect(() => { + if (!orderId || !customerRef || step !== 'paying' || !payHash) return; + + const poller = new BillPoller( + customerRef, + orderId, + { + onDone: () => setStep('done'), + onFailed: () => setStep('failed'), + onUnconfirmed: (reason) => { + setUnconfirmedReason(reason); + setStep('unconfirmed'); + }, + onErrorCountChange: (count) => setPollErrorCount(count), + }, + ); + pollerRef.current = poller; + poller.start(); + + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, [orderId, customerRef, step, payHash]); + + const smartcardDigits = smartcard.replace(/\D/g, ''); + const ngn = selectedPackage && lookupData + ? lookupData.availablePackages.find(p => p.id === selectedPackage)?.price || 0 + : 0; + const code = coin.toUpperCase(); + const cost = rate && ngn > 0 ? ngn / rate : 0; + + 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]); + + const lookupSmartcard = useCallback(async () => { + if (!provider || smartcardDigits.length < 6) return; + setLookupLoading(true); + setError(null); + try { + const data = await lookupCableTv({ + provider, + smartcardNumber: smartcardDigits, + }); + setLookupData(data); + setStep('lookup'); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLookupLoading(false); + } + }, [provider, smartcardDigits]); + + const continueFromForm = useCallback(async () => { + if (await isNairaVerified()) { + await lookupSmartcard(); + return; + } + router.push({ + pathname: '/verify', + params: { + returnTo: '/cabletv', + label: 'Cable TV', + summary: `Cable TV · ${provider}`, + }, + }); + }, [router, provider, lookupSmartcard]); + + const continueFromLookup = useCallback(() => { + if (!selectedPackage) return; + setStep('confirm'); + }, [selectedPackage]); + + const pay = useCallback(async () => { + if (!customerRef || !refundAddress || !rate || !selectedPackage || !lookupData) return; + setStep('paying'); + setError(null); + setPollErrorCount(0); + setUnconfirmedReason(null); + try { + const pkg = lookupData.availablePackages.find(p => p.id === selectedPackage); + if (!pkg) throw new Error('Selected package not found'); + + const amountStableCoin = Number((pkg.price / rate).toFixed(7)); + const order = await payBill({ + customerRef, + billCategory: 'cabletv', + provider, + customerId: smartcardDigits, + amountNgn: pkg.price, + 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, + 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 subscription needs.`, + ); + } else { + setError(errorMessage(err)); + } + setStep('confirm'); + } + }, [customerRef, refundAddress, rate, selectedPackage, lookupData, provider, smartcardDigits, coin, wallet.deploy, code]); + + // Stop polling on unmount + useEffect(() => { + return () => { + if (pollerRef.current) { + pollerRef.current.stop(); + pollerRef.current = null; + } + }; + }, []); + + const stepNumber = step === 'form' || step === 'lookup' ? 1 : 2; + + return ( + + + + {!mainnetOnly && (step === 'form' || step === 'lookup' || step === 'confirm') ? ( + + + + + + {stepNumber} / 2 + + ) : null} + + + + + {mainnetOnly ? ( + + MAINNET ONLY + Switch to mainnet to buy cable TV + + Subscriptions 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} + + {/* ── form ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'form' && ( + <> + PROVIDER + + {PROVIDERS.map((p) => ( + setProvider(p)} + style={[styles.networkChip, provider === p && styles.networkChipActive]} + > + + {p} + + + ))} + + + + SMARTCARD NUMBER + setSmartcard(t.replace(/\D/g, ''))} + keyboardType="number-pad" + placeholder="Enter smartcard number" + placeholderTextColor={colors.textMuted} + /> + + Enter the smartcard number shown on your decoder or subscription + + + + + {(['xlm', 'usdc'] as NairaCoin[]).map((c) => ( + setCoin(c)} + style={[styles.segmentItem, coin === c && styles.segmentActive]} + > + + {c.toUpperCase()} + + + ))} + + + void continueFromForm()} + disabled={smartcardDigits.length < 6 || lookupLoading} + style={({ pressed }) => [ + styles.primaryBtn, + (smartcardDigits.length < 6 || lookupLoading) && styles.disabled, + pressed && styles.pressed, + ]} + > + + {lookupLoading ? 'Looking up...' : 'Continue'} + + + + )} + + {/* ── lookup results ─────────────────────────────────────────────── */} + {!mainnetOnly && step === 'lookup' && lookupData && ( + <> + + Account found + Customer: {lookupData.customerName} + Current package: {lookupData.currentPackage} + Due date: {lookupData.dueDate} + + + SELECT PACKAGE + + {lookupData.availablePackages.map((pkg) => ( + setSelectedPackage(pkg.id)} + style={[ + styles.packageCard, + selectedPackage === pkg.id && styles.packageCardActive, + ]} + > + {pkg.name} + {naira(pkg.price)} + + ))} + + + void continueFromLookup()} + disabled={!selectedPackage} + style={({ pressed }) => [ + styles.primaryBtn, + !selectedPackage && styles.disabled, + pressed && styles.pressed, + ]} + > + Continue + + setStep('form')}> + Back + + + )} + + {/* ── confirm ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'confirm' && ( + <> + + + {lookupData?.availablePackages.find(p => p.id === selectedPackage)?.name} costs + + + {cost.toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + + Provider + {provider} + + + Smartcard + {smartcardDigits} + + + Customer + {lookupData?.customerName} + + + Paid from + {code} balance + + {veilFee ? ( + + Veil fee + + {veilFee.amount} {veilFee.assetCode} + + + ) : null} + + + void pay()} /> + Then confirm with your passkey + + setStep('lookup')}> + Back + + + )} + + {/* ── paying ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'paying' && ( + <> + + + {waitingForPasskey ? 'Confirm with your passkey' : 'Processing your subscription'} + + + {waitingForPasskey + ? 'Face ID, fingerprint or device PIN, whichever this phone uses.' + : pollErrorCount > 0 + ? `Checking status failed — retrying (${pollErrorCount}/${MAX_CONSECUTIVE_POLL_ERRORS})…` + : `${provider} is activating your subscription.`} + + + + + + + {waitingForPasskey ? 'Waiting for your passkey…' : 'Waiting for confirmation…'} + + + + )} + + {/* ── done ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'done' && ( + <> + + Subscription activated + + Your {provider} subscription is now active. Services usually resume within minutes. + + + + { + setStep('form'); + setSmartcard(''); + setSelectedPackage(null); + setOrderId(null); + setPayHash(null); + setSpent(null); + setLookupData(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Subscribe again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Done + + + )} + + {/* ── failed ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'failed' && ( + <> + + Payment failed + + Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} is being returned to + this wallet. No subscription was activated. + + + + { + setStep('form'); + setOrderId(null); + setPayHash(null); + setError(null); + setPollErrorCount(0); + setUnconfirmedReason(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Try again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + + )} + + {/* ── unconfirmed (bounded wait ended or consecutive poll errors) */} + {!mainnetOnly && step === 'unconfirmed' && ( + <> + + + {unconfirmedReason === 'error' + ? "Couldn't confirm status" + : 'Delivery taking longer than usual'} + + + {unconfirmedReason === 'error' + ? `Checking status failed after ${MAX_CONSECUTIVE_POLL_ERRORS} attempts. The network service could not be reached, but your subscription may already have been activated.` + : `Confirmation from ${provider} is taking longer than usual. Your subscription may already have been activated.`} + + + + + + Provider + {provider} + + + Smartcard + {smartcardDigits} + + {lookupData ? ( + <> + + Customer + {lookupData.customerName} + + + Package + + {lookupData.availablePackages.find(p => p.id === selectedPackage)?.name || 'Unknown'} + + + + ) : null} + + Payment + + {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} sent + + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + + WHAT IS KNOWN + + • Your {(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} {code} left this wallet. + + + • The subscription may already have been activated for smartcard {smartcardDigits}. + + + • Check your provider's app or website, or wait for a confirmation SMS. + + + • If the subscription has not arrived, contact support with reference{' '} + + {orderId ? orderId.slice(0, 8).toUpperCase() : 'above'} + . + + + + { + if (!pollerRef.current || isRechecking) return; + setIsRechecking(true); + pollerRef.current.manualCheck().then((result) => { + setIsRechecking(false); + if (result === 'done') setStep('done'); + else if (result === 'failed') setStep('failed'); + else if (result === 'error') setUnconfirmedReason('error'); + else setUnconfirmedReason('timeout'); + }); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + + {isRechecking ? 'Checking status…' : 'Check status again'} + + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + + )} + + + + ); +} + +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, + }, + 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 }, + + noteCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + backgroundColor: colors.surface, + }, + 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 }, + linkText: { color: colors.accent, fontFamily: fontFamily.body, fontSize: 13, paddingTop: 6, textAlign: 'center' }, + 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 }, + + field: { gap: 6 }, + label: { color: colors.textMuted, fontFamily: fontFamily.accent, fontSize: 11, letterSpacing: 0.8 }, + input: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + color: colors.textPrimary, + fontFamily: fontFamily.body, + fontSize: 16, + }, + + networkRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + networkChip: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + networkChipActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + networkText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 13 }, + networkTextActive: { color: colors.onAccent }, + + 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 }, + + packageList: { gap: 8 }, + packageCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 12, + padding: 14, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + packageCardActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + packageName: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 15 }, + packagePrice: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 15 }, + packageCardActive && { packageName: { color: colors.onAccent }, packagePrice: { color: colors.onAccent } }, + + 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/components/PayForGrid.tsx b/frontend/mobile/components/PayForGrid.tsx index 793a5b72c..6f1466dbb 100644 --- a/frontend/mobile/components/PayForGrid.tsx +++ b/frontend/mobile/components/PayForGrid.tsx @@ -70,9 +70,6 @@ export function getServiceAvailabilityMessage(service: Pick { + let mockGetStatus: jest.Mock; + let mockClassify: jest.Mock; + let callbacks: any; + + beforeEach(() => { + jest.useFakeTimers(); + mockGetStatus = jest.fn(); + mockClassify = jest.fn(classifyBillStatus); + callbacks = { + onDone: jest.fn(), + onFailed: jest.fn(), + onUnconfirmed: jest.fn(), + onErrorCountChange: jest.fn(), + }; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should poll and call onDone when status is complete', async () => { + mockGetStatus.mockResolvedValue({ status: 'completed' }); + mockClassify.mockReturnValue('done'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + await jest.runAllTimersAsync(); + + expect(callbacks.onDone).toHaveBeenCalledWith({ status: 'completed' }); + expect(callbacks.onFailed).not.toHaveBeenCalled(); + expect(callbacks.onUnconfirmed).not.toHaveBeenCalled(); + }); + + it('should poll and call onFailed when status is failed', async () => { + mockGetStatus.mockResolvedValue({ status: 'failed' }); + mockClassify.mockReturnValue('failed'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + await jest.runAllTimersAsync(); + + expect(callbacks.onFailed).toHaveBeenCalledWith({ status: 'failed' }); + expect(callbacks.onDone).not.toHaveBeenCalled(); + expect(callbacks.onUnconfirmed).not.toHaveBeenCalled(); + }); + + it('should retry on poll errors up to MAX_CONSECUTIVE_POLL_ERRORS then call onUnconfirmed with error', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + // Run the initial check plus retries + for (let i = 0; i < MAX_CONSECUTIVE_POLL_ERRORS; i++) { + await jest.advanceTimersByTimeAsync(10_000); + } + + expect(callbacks.onUnconfirmed).toHaveBeenCalledWith('error'); + expect(callbacks.onErrorCountChange).toHaveBeenCalledTimes(MAX_CONSECUTIVE_POLL_ERRORS); + }); + + it('should call onUnconfirmed with timeout after MAX_POLL_ATTEMPTS', async () => { + mockGetStatus.mockResolvedValue({ status: 'pending' }); + mockClassify.mockReturnValue('pending'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + // Run MAX_POLL_ATTEMPTS polls + for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { + await jest.advanceTimersByTimeAsync(10_000); + } + + expect(callbacks.onUnconfirmed).toHaveBeenCalledWith('timeout'); + expect(callbacks.onDone).not.toHaveBeenCalled(); + expect(callbacks.onFailed).not.toHaveBeenCalled(); + }); + + it('should increment error count on consecutive errors', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + await jest.advanceTimersByTimeAsync(10_000); + expect(callbacks.onErrorCountChange).toHaveBeenCalledWith(1); + + await jest.advanceTimersByTimeAsync(10_000); + expect(callbacks.onErrorCountChange).toHaveBeenCalledWith(2); + }); + + it('should reset error count on successful poll', async () => { + mockGetStatus + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValue({ status: 'pending' }); + mockClassify.mockReturnValue('pending'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + await jest.advanceTimersByTimeAsync(10_000); + expect(callbacks.onErrorCountChange).toHaveBeenCalledWith(1); + + await jest.advanceTimersByTimeAsync(10_000); + expect(callbacks.onErrorCountChange).toHaveBeenCalledWith(0); + }); + + it('should stop polling when stop is called', async () => { + mockGetStatus.mockResolvedValue({ status: 'pending' }); + mockClassify.mockReturnValue('pending'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + poller.start(); + + await jest.advanceTimersByTimeAsync(10_000); + poller.stop(); + + // Verify no more polls happen + const pollCount = mockGetStatus.mock.calls.length; + await jest.advanceTimersByTimeAsync(50_000); + expect(mockGetStatus).toHaveBeenCalledTimes(pollCount); + }); + + it('should support manual check', async () => { + mockGetStatus.mockResolvedValue({ status: 'completed' }); + mockClassify.mockReturnValue('done'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + const result = await poller.manualCheck(); + + expect(result).toBe('done'); + expect(callbacks.onDone).toHaveBeenCalledWith({ status: 'completed' }); + }); + + it('should manual check return error on failure', async () => { + mockGetStatus.mockRejectedValue(new Error('Network error')); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + const result = await poller.manualCheck(); + + expect(result).toBe('error'); + }); + + it('should manual check return timeout on pending', async () => { + mockGetStatus.mockResolvedValue({ status: 'pending' }); + mockClassify.mockReturnValue('pending'); + + const poller = new BillPoller('ref123', 'order456', callbacks, mockGetStatus, mockClassify); + const result = await poller.manualCheck(); + + expect(result).toBe('timeout'); + }); +}); diff --git a/frontend/mobile/lib/__tests__/onrampBills.test.ts b/frontend/mobile/lib/__tests__/onrampBills.test.ts new file mode 100644 index 000000000..67bc912bb --- /dev/null +++ b/frontend/mobile/lib/__tests__/onrampBills.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for cable TV and betting lookup functions. + * + * These tests verify the lookup API functions work correctly + * against mocked providers. + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals' + +// We need to mock the internal call function. Since it's not exported, +// we'll test the exported functions with a proper mock setup. +describe('Cable TV and betting lookup', () => { + it('should have lookupCableTv function defined', () => { + const { lookupCableTv } = require('../onramp') + expect(typeof lookupCableTv).toBe('function') + }) + + it('should have lookupBetting function defined', () => { + const { lookupBetting } = require('../onramp') + expect(typeof lookupBetting).toBe('function') + }) + + it('should have isBillCategoryAvailable function defined', () => { + const { isBillCategoryAvailable } = require('../onramp') + expect(typeof isBillCategoryAvailable).toBe('function') + }) +}) + +describe('Cable TV lookup', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return customer details on successful lookup', async () => { + const mockResponse = { + customerName: 'John Doe', + currentPackage: 'DSTV Compact', + dueDate: '2026-11-15', + availablePackages: [ + { id: 'pkg1', name: 'DSTV Compact', price: 10500 }, + { id: 'pkg2', name: 'DSTV Compact Plus', price: 16500 }, + ], + } + + call.mockResolvedValue(mockResponse) + + const result = await lookupCableTv({ + provider: 'DSTV', + smartcardNumber: '1234567890', + }) + + expect(call).toHaveBeenCalledWith('/ngn/bills/lookup/cabletv', { + method: 'POST', + body: { provider: 'DSTV', smartcardNumber: '1234567890' }, + }) + expect(result).toEqual(mockResponse) + }) + + it('should handle lookup failure gracefully', async () => { + call.mockRejectedValue(new Error('Smartcard not found')) + + await expect( + lookupCableTv({ + provider: 'DSTV', + smartcardNumber: 'invalid', + }), + ).rejects.toThrow('Smartcard not found') + }) +}) + +describe('Betting lookup', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return account details on successful lookup', async () => { + const mockResponse = { + accountName: 'John Doe', + accountStatus: 'active', + balance: 5000, + } + + call.mockResolvedValue(mockResponse) + + const result = await lookupBetting({ + provider: 'Bet9ja', + accountId: 'user123', + }) + + expect(call).toHaveBeenCalledWith('/ngn/bills/lookup/betting', { + method: 'POST', + body: { provider: 'Bet9ja', accountId: 'user123' }, + }) + expect(result).toEqual(mockResponse) + }) + + it('should handle lookup failure gracefully', async () => { + call.mockRejectedValue(new Error('Account not found')) + + await expect( + lookupBetting({ + provider: 'Bet9ja', + accountId: 'invalid', + }), + ).rejects.toThrow('Account not found') + }) +}) + +describe('Bill category availability', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return true when naira rails are available', async () => { + call.mockResolvedValue({ rate: 1500 }) + + const result = await isBillCategoryAvailable('cabletv') + + expect(result).toBe(true) + }) + + it('should return false when naira rails are unavailable', async () => { + call.mockRejectedValue(new Error('Service unavailable')) + + const result = await isBillCategoryAvailable('betting') + + expect(result).toBe(false) + }) +}) diff --git a/frontend/mobile/lib/billPolling.ts b/frontend/mobile/lib/billPolling.ts new file mode 100644 index 000000000..5333bc320 --- /dev/null +++ b/frontend/mobile/lib/billPolling.ts @@ -0,0 +1,132 @@ +/** + * Manages status polling for bill purchases. + * + * Implements bounded wait and distinguishable error retries (V276): + * - Status polling is bounded (MAX_POLL_ATTEMPTS = 12 at 10s = 120s max). + * - A poll error is retried visibly and gives up with an explanation (after MAX_CONSECUTIVE_POLL_ERRORS = 3). + * - When polling times out or gives up on network errors, the user is moved to 'unconfirmed' + * and NEVER told the purchase failed, as crypto left the wallet and the top-up may already have landed. + */ + +import { + getBillStatus, + classifyBillStatus, + type BillStatus, + type BillTerminalOutcome, +} from './onramp'; + +export const POLL_INTERVAL_MS = 10_000; +export const MAX_CONSECUTIVE_POLL_ERRORS = 3; +export const MAX_POLL_ATTEMPTS = 12; + +export interface BillPollerCallbacks { + onDone: (status: BillStatus) => void; + onFailed: (status: BillStatus) => void; + onUnconfirmed: (reason: 'error' | 'timeout') => void; + onErrorCountChange: (count: number) => void; +} + +export class BillPoller { + private timer: ReturnType | null = null; + private consecutiveErrors = 0; + private totalPolls = 0; + private inFlight = false; + private stopped = false; + + constructor( + private customerRef: string, + private orderId: string, + private callbacks: BillPollerCallbacks, + private getStatusFn: typeof getBillStatus = getBillStatus, + private classifyFn: typeof classifyBillStatus = classifyBillStatus, + ) {} + + start() { + this.stopped = false; + this.consecutiveErrors = 0; + this.totalPolls = 0; + this.inFlight = false; + this.callbacks.onErrorCountChange(0); + + void this.check(); + this.timer = setInterval(() => void this.check(), POLL_INTERVAL_MS); + } + + stop() { + this.stopped = true; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async check(): Promise { + if (this.inFlight || this.stopped) return; + this.inFlight = true; + this.totalPolls += 1; + + try { + const s = await this.getStatusFn(this.customerRef, this.orderId); + if (this.stopped) return; + + this.consecutiveErrors = 0; + this.callbacks.onErrorCountChange(0); + + const outcome: BillTerminalOutcome = this.classifyFn(s.status); + if (outcome === 'done') { + this.stop(); + this.callbacks.onDone(s); + return; + } + if (outcome === 'failed') { + this.stop(); + this.callbacks.onFailed(s); + return; + } + + if (this.totalPolls >= MAX_POLL_ATTEMPTS) { + this.stop(); + this.callbacks.onUnconfirmed('timeout'); + return; + } + } catch { + if (this.stopped) return; + + this.consecutiveErrors += 1; + const count = this.consecutiveErrors; + this.callbacks.onErrorCountChange(count); + + if (count >= MAX_CONSECUTIVE_POLL_ERRORS) { + this.stop(); + this.callbacks.onUnconfirmed('error'); + return; + } + + if (this.totalPolls >= MAX_POLL_ATTEMPTS) { + this.stop(); + this.callbacks.onUnconfirmed('timeout'); + return; + } + } finally { + this.inFlight = false; + } + } + + async manualCheck(): Promise<'done' | 'failed' | 'timeout' | 'error'> { + try { + const s = await this.getStatusFn(this.customerRef, this.orderId); + const outcome = this.classifyFn(s.status); + if (outcome === 'done') { + this.callbacks.onDone(s); + return 'done'; + } + if (outcome === 'failed') { + this.callbacks.onFailed(s); + return 'failed'; + } + return 'timeout'; + } catch { + return 'error'; + } + } +} diff --git a/frontend/mobile/lib/onramp.ts b/frontend/mobile/lib/onramp.ts index acbf39114..8f645b5c8 100644 --- a/frontend/mobile/lib/onramp.ts +++ b/frontend/mobile/lib/onramp.ts @@ -644,6 +644,69 @@ export function lookupBillCustomer(params: { }); } +// ─── Bill Lookups ─────────────────────────────────────────────────────────── + +export interface CableTvLookup { + customerName: string; + currentPackage: string; + dueDate: string; + availablePackages: Array<{ + id: string; + name: string; + price: number; + }>; +} + +export interface BettingLookup { + accountName: string; + accountStatus: string; + balance?: number; +} + +/** + * Look up a cable TV smartcard before purchase. + * + * Returns customer name, current package, due date, and available packages. + * This allows the user to confirm details before spending. + */ +export function lookupCableTv(params: { + provider: string; + smartcardNumber: string; +}): Promise { + return call('/ngn/bills/lookup/cabletv', { method: 'POST', body: params }); +} + +/** + * Look up a betting account ID before purchase. + * + * Returns account name and status. Supported only where the provider + * offers account ID verification. + */ +export function lookupBetting(params: { + provider: string; + accountId: string; +}): Promise { + return call('/ngn/bills/lookup/betting', { method: 'POST', body: params }); +} + +/** + * Check if a specific bill category is available. + * + * Returns false if the provider is unreachable (outage), true otherwise. + * This allows us to show an outage message instead of hiding the category. + */ +export async function isBillCategoryAvailable(category: BillCategory): Promise { + try { + // Try a lightweight operation to check availability + // For now, we check the main naira availability as a proxy + // In the future, this could be more category-specific + await getOnrampRate('usdc'); + return true; + } catch { + return false; + } +} + // ─── Availability ──────────────────────────────────────────────────────────── /** diff --git a/frontend/wallet/app/bills/page.tsx b/frontend/wallet/app/bills/page.tsx index f93e1b615..f9cc5feef 100644 --- a/frontend/wallet/app/bills/page.tsx +++ b/frontend/wallet/app/bills/page.tsx @@ -35,7 +35,11 @@ import { isNairaVerified, payBill, setNairaVerified, + lookupCableTv, + lookupBetting, type NairaCoin, + type CableTvLookup, + type BettingLookup, } from '@/lib/ngn' import type { PlatformFee } from '@/lib/platformFee' import { validateIdentifierBeforeConfirm } from '@/lib/billCategories' @@ -49,10 +53,21 @@ import { import { beginTx, endTx } from '@/lib/txState' import { walletLocal, walletSession } from '@/lib/walletStorage' -type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed' +type Step = 'form' | 'lookup' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed' + +type BillCategory = 'airtime' | 'cabletv' | 'betting' const QUICK_AMOUNTS = [200, 500, 1000, 2000] +const CATEGORIES: Array<{ id: BillCategory; icon: string; label: string; sublabel: string; status: 'live' | 'soon' }> = [ + { id: 'airtime', icon: '☎', label: 'Airtime', sublabel: 'All networks', status: 'live' }, + { id: 'cabletv', icon: '📺', label: 'Cable TV', sublabel: 'DSTV, GOtv, StarTimes', status: 'live' }, + { id: 'betting', icon: '🎰', label: 'Betting', sublabel: 'Bet9ja, SportyBet, 1xBet', status: 'live' }, +] + +const CABLETV_PROVIDERS = ['DSTV', 'GOtv', 'StarTimes'] +const BETTING_PROVIDERS = ['Bet9ja', 'SportyBet', '1xBet', 'BetKing'] + /** * Nigerian mobile prefixes, so the network is detected rather than asked for. */ @@ -87,9 +102,13 @@ export default function BillsPage() { const [step, setStep] = useState('form') const [error, setError] = useState(null) + const [category, setCategory] = useState('airtime') const [provider, setProvider] = useState('MTN') const [providerTouched, setProviderTouched] = useState(false) const [phone, setPhone] = useState('') + const [smartcard, setSmartcard] = useState('') + const [accountId, setAccountId] = useState('') + const [selectedPackage, setSelectedPackage] = useState(null) const [amountNGN, setAmountNGN] = useState('') const [coin, setCoin] = useState('usdc') @@ -114,6 +133,10 @@ export default function BillsPage() { const [unconfirmedReason, setUnconfirmedReason] = useState<'error' | 'timeout' | null>(null) const [isRechecking, setIsRechecking] = useState(false) + // Lookup state + const [lookupData, setLookupData] = useState(null) + const [lookupLoading, setLookupLoading] = useState(false) + const pollerRef = useRef(null) const refreshOrders = useCallback(() => { @@ -133,12 +156,24 @@ export default function BillsPage() { }, [refreshOrders]) const phoneDigits = useMemo(() => phone.replace(/\D/g, ''), [phone]) + const smartcardDigits = useMemo(() => smartcard.replace(/\D/g, ''), [smartcard]) const detected = useMemo(() => detectNetwork(phone), [phone]) + // Update provider options based on category + useEffect(() => { + if (category === 'airtime') { + setProvider('MTN') + } else if (category === 'cabletv') { + setProvider('DSTV') + } else if (category === 'betting') { + setProvider('Bet9ja') + } + }, [category]) + // A detected network wins until the user overrides it useEffect(() => { - if (detected && !providerTouched) setProvider(detected) - }, [detected, providerTouched]) + if (category === 'airtime' && detected && !providerTouched) setProvider(detected) + }, [detected, providerTouched, category]) useEffect(() => { setMismatch(isNairaKycMismatch()) @@ -187,7 +222,24 @@ export default function BillsPage() { const ngn = Number(amountNGN.replace(/[^\d]/g, '')) || 0 const code = coin.toUpperCase() - const cost = rate && ngn > 0 ? ngn / rate : 0 + + // Calculate effective NGN amount and cost based on category + const effectiveNgn = useMemo(() => { + if (category === 'cabletv' && lookupData && 'availablePackages' in lookupData && selectedPackage) { + const pkg = lookupData.availablePackages.find(p => p.id === selectedPackage) + return pkg?.price || 0 + } + return ngn + }, [category, lookupData, selectedPackage, ngn]) + + const cost = rate && effectiveNgn > 0 ? effectiveNgn / rate : 0 + + // Update step number based on whether lookup is used + const stepNumber = useMemo(() => { + if (step === 'form' || step === 'lookup') return 1 + if (step === 'confirm') return 2 + return 2 + }, [step]) // Preview fee on confirm step useEffect(() => { @@ -224,23 +276,64 @@ export default function BillsPage() { }, []) const phoneValid = phoneDigits.length === 11 && phoneDigits.startsWith('0') + const smartcardValid = smartcardDigits.length >= 6 + const accountValid = accountId.length >= 3 + + const lookup = useCallback(async () => { + if (category === 'cabletv' && smartcardValid) { + setLookupLoading(true) + try { + const data = await lookupCableTv({ + provider, + smartcardNumber: smartcardDigits, + }) + setLookupData(data) + setStep('lookup') + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLookupLoading(false) + } + } else if (category === 'betting' && accountValid) { + setLookupLoading(true) + try { + const data = await lookupBetting({ + provider, + accountId, + }) + setLookupData(data) + setStep('lookup') + } catch (err) { + // Lookup not supported - continue without it + setStep('confirm') + } finally { + setLookupLoading(false) + } + } else { + setStep('confirm') + } + }, [category, smartcardValid, smartcardDigits, provider, accountValid, accountId]) const continueFromForm = useCallback(async () => { - const validation = await validateIdentifierBeforeConfirm('airtime', phone, { provider }) - if (!validation.valid) { - setError(validation.error ?? 'Please check the phone number.') + if (!isNairaVerified()) { + router.push('/verify?returnTo=/bills') return } - setError(null) + await lookup() + }, [router, lookup]) if (!isNairaVerified()) { router.push('/verify?returnTo=/bills') return } + await lookup() + }, [router, lookup]) + + const continueFromLookup = useCallback(() => { setStep('confirm') - }, [router, phone, provider]) + }, []) const pay = useCallback(async () => { - if (!customerRef || !feePayerAddress || !rate || ngn <= 0) return + if (!customerRef || !feePayerAddress || !rate || effectiveNgn <= 0) return const signerSecret = walletSession.getItem('veil_signer_secret') || walletLocal.getItem('veil_signer_secret') if (!signerSecret) { @@ -248,6 +341,17 @@ export default function BillsPage() { return } + // Determine customer ID based on category + let customerId = '' + + if (category === 'airtime') { + customerId = phoneDigits + } else if (category === 'cabletv') { + customerId = smartcardDigits + } else if (category === 'betting') { + customerId = accountId + } + beginTx() setStep('paying') setError(null) @@ -255,15 +359,15 @@ export default function BillsPage() { setUnconfirmedReason(null) try { - const amountStableCoin = Number((ngn / rate).toFixed(7)) + const amountStableCoin = Number((effectiveNgn / rate).toFixed(7)) // 1. Reserve first const order = await payBill({ customerRef, - billCategory: 'airtime', + billCategory: category, provider, - customerId: phoneDigits, - amountNgn: ngn, + customerId, + amountNgn: effectiveNgn, amountStableCoin, rate, coin, @@ -278,10 +382,10 @@ export default function BillsPage() { saveBillOrder({ orderId: order.id, customerRef, - category: 'airtime', + category, provider, - identifier: phoneDigits, - amount: ngn, + identifier: category === 'airtime' ? phoneDigits : category === 'cabletv' ? smartcardDigits : accountId, + amount: effectiveNgn, amountCrypto: amountStableCoin, asset: coin, status: 'pending', @@ -344,7 +448,7 @@ export default function BillsPage() { } finally { endTx() } - }, [customerRef, feePayerAddress, rate, ngn, phoneDigits, provider, coin]) + }, [customerRef, feePayerAddress, rate, effectiveNgn, category, phoneDigits, smartcardDigits, accountId, provider, coin, refreshOrders]) const manualCheckStatus = useCallback(async () => { if (!orderId || !customerRef || isRechecking || !pollerRef.current) return @@ -405,19 +509,37 @@ export default function BillsPage() { Pay for
-
-
☎
-
Airtime
-
- All networks + {CATEGORIES.filter(cat => cat.status === 'live').map((cat) => ( +
{ + setCategory(cat.id) + setStep('form') + setError(null) + setLookupData(null) + setSelectedPackage(null) + }} + className={`rounded-[18px] px-4 py-[18px] flex flex-col gap-[5px] cursor-pointer transition-colors ${ + category === cat.id + ? 'bg-[rgba(253,218,36,0.08)] border border-[rgba(253,218,36,0.35)]' + : 'bg-[rgba(255,255,255,0.03)] border border-border-dim hover:border-white/20' + }`} + > +
+ {cat.icon} +
+
+ {cat.label} +
+
+ {cat.sublabel} +
-
+ ))}
@@ -431,78 +553,119 @@ export default function BillsPage() { {step === 'form' && (
+ {/* Provider selection */}
- Network + Provider
- {NETWORKS.map((n) => ( + {(category === 'airtime' ? NETWORKS : category === 'cabletv' ? CABLETV_PROVIDERS : BETTING_PROVIDERS).map((p) => ( ))}
-
- Phone number -
- setPhone(e.target.value.replace(/[^\d]/g, '').slice(0, 11))} - placeholder="0803 512 4471" - inputMode="numeric" - className="w-full bg-surface-md border border-border-dim rounded-[14px] px-4 py-3.5 text-off-white text-[19px] font-mono tracking-wider outline-none focus:border-gold transition-colors" - /> -

- {detected - ? `${detected} number, detected from ${phoneDigits.slice(0, 4)}${ - providerTouched && provider !== detected ? ` — sending to ${provider}` : '' - }` - : 'Numbers get ported, so check the network above matches.'} -

+ {/* Category-specific fields */} + {category === 'airtime' && ( +
+ Phone number +
+ setPhone(e.target.value.replace(/[^\d]/g, '').slice(0, 11))} + placeholder="0803 512 4471" + inputMode="numeric" + className="w-full bg-surface-md border border-border-dim rounded-[14px] px-4 py-3.5 text-off-white text-[19px] font-mono tracking-wider outline-none focus:border-gold transition-colors" + /> +

+ {detected + ? `${detected} number, detected from ${phoneDigits.slice(0, 4)}${ + providerTouched && provider !== detected ? ` — sending to ${provider}` : '' + }` + : 'Numbers get ported, so check the network above matches.'} +

+
-
+ )} -
- Amount -
- ₦ - setAmountNGN(e.target.value.replace(/[^\d]/g, ''))} - placeholder="0" - inputMode="numeric" - className="w-full bg-transparent border-none text-off-white text-[24px] font-semibold outline-none" - /> + {category === 'cabletv' && ( +
+ Smartcard number +
+ setSmartcard(e.target.value.replace(/\D/g, ''))} + placeholder="Enter smartcard number" + inputMode="numeric" + className="w-full bg-surface-md border border-border-dim rounded-[14px] px-4 py-3.5 text-off-white text-[19px] font-mono tracking-wider outline-none focus:border-gold transition-colors" + /> +

+ Enter the smartcard number shown on your decoder +

+
-
- {QUICK_AMOUNTS.map((q) => ( - - ))} + )} + + {category === 'betting' && ( +
+ Account ID / Phone +
+ +

+ Enter your betting account ID or registered phone number +

+
+
+ )} + + {/* Amount field (not for cabletv - amount comes from package selection) */} + {category !== 'cabletv' && ( +
+ Amount +
+ ₦ + setAmountNGN(e.target.value.replace(/[^\d]/g, ''))} + placeholder="0" + inputMode="numeric" + className="w-full bg-transparent border-none text-off-white text-[24px] font-semibold outline-none" + /> +
+
+ {QUICK_AMOUNTS.map((q) => ( + + ))}
@@ -555,15 +718,22 @@ export default function BillsPage() { void continueFromForm()} > - Continue + {lookupLoading ? 'Looking up...' : 'Continue'}
)} +<<<<<<< HEAD {step === 'form' && recentOrders.length > 0 && (
@@ -650,6 +820,72 @@ export default function BillsPage() { })}
+======= + {/* ── lookup results ─────────────────────────────────────────────── */} + {step === 'lookup' && lookupData && ( + +

+ {category === 'cabletv' ? 'Account found' : 'Account verified'} +

+ {category === 'cabletv' && 'customerName' in lookupData ? ( + <> +

Customer: {lookupData.customerName}

+

Current package: {lookupData.currentPackage}

+

Due date: {lookupData.dueDate}

+
+ Select package +
+ {lookupData.availablePackages.map((pkg) => ( + + ))} +
+
+ + ) : ( + <> +

Account: {lookupData.accountName}

+

Status: {lookupData.accountStatus}

+ {lookupData.balance !== undefined && ( +

Current balance: {naira(lookupData.balance)}

+ )} + + )} +
+ +
+ + Continue + + +
+>>>>>>> funds0033-cmyk/issue-1139-naira-onramp )} {/* ── b2 · confirm ──────────────────────────────────────────────── */} @@ -657,7 +893,14 @@ export default function BillsPage() {

- {naira(ngn)} airtime to {phoneDigits} costs + {category === 'airtime' + ? `${naira(effectiveNgn)} airtime to ${phoneDigits} costs` + : category === 'cabletv' + ? `${lookupData && 'availablePackages' in lookupData + ? lookupData.availablePackages.find(p => p.id === selectedPackage)?.name + : 'Package'} subscription costs` + : `${naira(effectiveNgn)} deposit to ${provider} costs` + }

{cost.toFixed(coin === 'xlm' ? 4 : 2)} {code} @@ -666,8 +909,17 @@ export default function BillsPage() {

- - + + + {category === 'airtime' && } + {category === 'cabletv' && } + {category === 'betting' && } + {lookupData && 'customerName' in lookupData && ( + + )} + {lookupData && 'accountName' in lookupData && ( + + )} {veilFee && ( @@ -712,7 +964,7 @@ export default function BillsPage() {

- {waitingForPasskey ? 'Confirm with your passkey' : 'Sending your top-up'} + {waitingForPasskey ? 'Confirm with your passkey' : 'Processing your payment'}

0 ? `Checking status failed — retrying (${pollErrorCount}/${MAX_CONSECUTIVE_POLL_ERRORS})…` - : `${provider} is vending ${naira(ngn)} to ${phoneDigits}.`} + : `${provider} is processing your ${category === 'airtime' ? 'top-up' : category === 'cabletv' ? 'subscription' : 'deposit'}.`}

-
Airtime
+
+ {category === 'airtime' ? 'Airtime' : category === 'cabletv' ? 'Subscription' : 'Deposit'} +
- {naira(ngn)} · {provider} + {naira(effectiveNgn)} · {provider}
@@ -752,17 +1006,27 @@ export default function BillsPage() {
✓
-

Airtime sent

+

+ {category === 'airtime' ? 'Airtime sent' : category === 'cabletv' ? 'Subscription activated' : 'Deposit complete'} +

- {naira(ngn)} is on {phoneDigits}. It usually shows on the phone within a minute. + {category === 'airtime' + ? `${naira(effectiveNgn)} is on ${phoneDigits}. It usually shows on the phone within a minute.` + : category === 'cabletv' + ? `Your ${provider} subscription is now active. Services usually resume within minutes.` + : `${naira(effectiveNgn)} has been deposited to your ${provider} account. It should appear within minutes.` + }

- - + + + {category === 'airtime' && } + {category === 'cabletv' && } + {category === 'betting' && } { setStep('form') setAmountNGN('') + setPhone('') + setSmartcard('') + setAccountId('') + setSelectedPackage(null) setOrderId(null) setPayHash(null) setSpent(null) +<<<<<<< HEAD setPrepaidToken(null) setTokenCopied(false) refreshOrders() +======= + setLookupData(null) +>>>>>>> funds0033-cmyk/issue-1139-naira-onramp void loadRate() }} > - Buy again + {category === 'airtime' ? 'Buy again' : category === 'cabletv' ? 'Subscribe again' : 'Deposit again'}