react-native-fpay 0.4.30 → 0.4.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/lib/module/FountainPayProvider.js +5 -0
  2. package/lib/module/FountainPayProvider.js.map +1 -1
  3. package/lib/module/core/api/index.js +59 -0
  4. package/lib/module/core/api/index.js.map +1 -1
  5. package/lib/module/core/types/index.js +32 -0
  6. package/lib/module/core/types/index.js.map +1 -1
  7. package/lib/module/engine/FPEngine.js +9 -0
  8. package/lib/module/engine/FPEngine.js.map +1 -1
  9. package/lib/module/ui/components/ConfirmScreen.js +43 -51
  10. package/lib/module/ui/components/ConfirmScreen.js.map +1 -1
  11. package/lib/module/ui/components/RecurringToggle.js +94 -0
  12. package/lib/module/ui/components/RecurringToggle.js.map +1 -0
  13. package/lib/module/ui/modals/FPShell.js +19 -0
  14. package/lib/module/ui/modals/FPShell.js.map +1 -1
  15. package/lib/module/ui/screens/BillsScreen.js +187 -0
  16. package/lib/module/ui/screens/BillsScreen.js.map +1 -0
  17. package/lib/module/ui/screens/ResultScreen.js +113 -28
  18. package/lib/module/ui/screens/ResultScreen.js.map +1 -1
  19. package/lib/module/ui/screens/SendScreen.js +54 -6
  20. package/lib/module/ui/screens/SendScreen.js.map +1 -1
  21. package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js +257 -0
  22. package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js.map +1 -0
  23. package/lib/module/ui/screens/sub/billPayment/CableScreen.js +264 -0
  24. package/lib/module/ui/screens/sub/billPayment/CableScreen.js.map +1 -0
  25. package/lib/module/ui/screens/sub/billPayment/DataScreen.js +273 -0
  26. package/lib/module/ui/screens/sub/billPayment/DataScreen.js.map +1 -0
  27. package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js +337 -0
  28. package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js.map +1 -0
  29. package/lib/typescript/src/FountainPayProvider.d.ts.map +1 -1
  30. package/lib/typescript/src/core/api/index.d.ts +52 -63
  31. package/lib/typescript/src/core/api/index.d.ts.map +1 -1
  32. package/lib/typescript/src/core/types/index.d.ts +146 -0
  33. package/lib/typescript/src/core/types/index.d.ts.map +1 -1
  34. package/lib/typescript/src/engine/FPEngine.d.ts +4 -2
  35. package/lib/typescript/src/engine/FPEngine.d.ts.map +1 -1
  36. package/lib/typescript/src/index.d.ts +1 -1
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/ui/components/ConfirmScreen.d.ts +25 -4
  39. package/lib/typescript/src/ui/components/ConfirmScreen.d.ts.map +1 -1
  40. package/lib/typescript/src/ui/components/RecurringToggle.d.ts +7 -0
  41. package/lib/typescript/src/ui/components/RecurringToggle.d.ts.map +1 -0
  42. package/lib/typescript/src/ui/modals/FPShell.d.ts.map +1 -1
  43. package/lib/typescript/src/ui/screens/BillsScreen.d.ts +10 -0
  44. package/lib/typescript/src/ui/screens/BillsScreen.d.ts.map +1 -0
  45. package/lib/typescript/src/ui/screens/ResultScreen.d.ts +20 -3
  46. package/lib/typescript/src/ui/screens/ResultScreen.d.ts.map +1 -1
  47. package/lib/typescript/src/ui/screens/SendScreen.d.ts.map +1 -1
  48. package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts +15 -0
  49. package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts.map +1 -0
  50. package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts +14 -0
  51. package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts.map +1 -0
  52. package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts +14 -0
  53. package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts.map +1 -0
  54. package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts +16 -0
  55. package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts.map +1 -0
  56. package/package.json +2 -2
  57. package/src/FountainPayProvider.tsx +7 -0
  58. package/src/core/api/index.ts +149 -27
  59. package/src/core/types/index.ts +181 -0
  60. package/src/engine/FPEngine.ts +12 -1
  61. package/src/index.ts +9 -1
  62. package/src/ui/components/ConfirmScreen.tsx +47 -54
  63. package/src/ui/components/RecurringToggle.tsx +106 -0
  64. package/src/ui/modals/FPShell.tsx +26 -3
  65. package/src/ui/screens/BillsScreen.tsx +198 -0
  66. package/src/ui/screens/ResultScreen.tsx +129 -28
  67. package/src/ui/screens/SendScreen.tsx +43 -6
  68. package/src/ui/screens/sub/billPayment/AirtimeScreen.tsx +252 -0
  69. package/src/ui/screens/sub/billPayment/CableScreen.tsx +274 -0
  70. package/src/ui/screens/sub/billPayment/DataScreen.tsx +263 -0
  71. package/src/ui/screens/sub/billPayment/ElectricityScreen.tsx +344 -0
@@ -1,11 +1,13 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { FC } from 'react';
2
3
  import { Animated, Easing, TouchableOpacity } from 'react-native';
3
4
  import Svg, { Path, Circle } from 'react-native-svg';
4
5
  import styled from 'styled-components/native';
5
6
  import { C, F, R, S } from '../theme';
6
- import type { FPTransaction } from '../../core/types';
7
- import { transferAPI } from '../../core/api';
7
+ import type { SubscriptionFreq } from '../../core/types';
8
+ import { subscriptionAPI, transferAPI } from '../../core/api';
8
9
  import Gradients from '../components/Gradients';
10
+ import RecurringToggle from '../components/RecurringToggle';
9
11
 
10
12
  // ── Icons ─────────────────────────────────────────────────────
11
13
 
@@ -187,7 +189,7 @@ function CountdownRing({ duration }: { duration: number }) {
187
189
 
188
190
  // ── Row ──────────────────────────────────────────────────────
189
191
 
190
- function Row({ label, value }: { label: string; value: string }) {
192
+ const Row: FC<{ label: string; value: string }> = ({ label, value }) => {
191
193
  return (
192
194
  <RowWrap>
193
195
  <RowLabel>{label}</RowLabel>
@@ -198,37 +200,77 @@ function Row({ label, value }: { label: string; value: string }) {
198
200
 
199
201
  // ── Main ─────────────────────────────────────────────────────
200
202
 
203
+ /**
204
+ * Generalized props — ResultScreen no longer assumes a "recipient" or that
205
+ * the underlying transaction came through transferAPI.
206
+ * - reference: used to poll status (via statusFetcher, defaults to transferAPI.status)
207
+ * - summaryRows: extra rows to render in the detail card (e.g. "To", "Channel")
208
+ * beyond the always-present Reference/Date/Status rows
209
+ * - allowRecurring: SendScreen keeps the existing recurring-payment upsell;
210
+ * domains with no concept of "this recipient" (bills) pass false
211
+ * - statusFetcher: which endpoint to poll — defaults to the shared
212
+ * agencyTransaction-backed transferAPI.status, which bill transactions
213
+ * also live in
214
+ */
201
215
  interface Props {
202
- transaction: FPTransaction | any;
216
+ reference: string;
217
+ summaryRows?: { label: string; value: string }[];
218
+ allowRecurring?: boolean;
219
+ statusFetcher?: (reference: string) => Promise<any>;
203
220
  onClose: () => void;
204
221
  }
205
222
 
206
- export function ResultScreen({ transaction, onClose }: Props) {
223
+ export function ResultScreen({
224
+ reference,
225
+ summaryRows,
226
+ allowRecurring = true,
227
+ statusFetcher = transferAPI.status,
228
+ onClose,
229
+ }: Props) {
207
230
  const [loading, setLoading] = useState<boolean>(false);
208
231
  const [transactionDetail, setTransactionDetail] = useState<any>(null)
209
232
  const opacAnim = useRef(new Animated.Value(1)).current; // ← start visible
210
233
  const slideAnim = useRef(new Animated.Value(0)).current; // ← start in place
211
234
  const scaleAnim = useRef(new Animated.Value(1)).current; // ← start full size
212
235
 
236
+ const [recurringEnabled, setRecurringEnabled] = useState(false);
237
+ const [recurringFrequency, setRecurringFrequency] = useState<SubscriptionFreq>('MONTHLY');
238
+
239
+
213
240
  const isSuccess = transactionDetail?.status.toLowerCase() === 'successful';
214
241
  const accentColor = isSuccess ? C.green : C.red;
215
242
  const bgColor = isSuccess ? '#E3FCEF' : '#FFEBE6';
216
243
 
244
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
245
+ const startTimeRef = useRef<number>(Date.now());
246
+ const remainingTimeRef = useRef<number>(COUNTDOWN_SECONDS * 1000);
247
+ const isPausedRef = useRef<boolean>(false);
248
+
249
+ const startTimer = useCallback((duration: number) => {
250
+ if (timerRef.current) clearTimeout(timerRef.current);
251
+ startTimeRef.current = Date.now();
252
+ timerRef.current = setTimeout(onClose, duration);
253
+ }, [onClose]);
254
+
255
+ const pauseTimer = useCallback(() => {
256
+ if (isPausedRef.current) return;
257
+ isPausedRef.current = true;
258
+ if (timerRef.current) clearTimeout(timerRef.current);
259
+ // Calculate how much time is left
260
+ const elapsed = Date.now() - startTimeRef.current;
261
+ remainingTimeRef.current = Math.max(remainingTimeRef.current - elapsed, 0);
262
+ }, []);
263
+
264
+ const resumeTimer = useCallback(() => {
265
+ if (!isPausedRef.current) return;
266
+ isPausedRef.current = false;
267
+ startTimer(remainingTimeRef.current);
268
+ }, [startTimer]);
269
+
217
270
  const formatted = `${transactionDetail?.currency || 'NGN'} ${Number(transactionDetail?.amount).toLocaleString('en-NG', {
218
271
  minimumFractionDigits: 2,
219
272
  })}`;
220
273
 
221
- const recipient = transaction?.recipient as any;
222
- const recipientName = recipient?.accountName ?? recipient?.name ?? '—';
223
-
224
- const channelLabel: Record<string, string> = {
225
- transfer: 'Bank Transfer',
226
- bluetooth: 'Bluetooth',
227
- proximity: 'Nearby',
228
- nqr: 'QR Code',
229
- nfc: 'NFC Tap',
230
- };
231
-
232
274
  const dateStr = transactionDetail?.createdAt
233
275
  ? new Date(transactionDetail?.createdAt).toLocaleString('en-NG')
234
276
  : '—';
@@ -237,7 +279,7 @@ export function ResultScreen({ transaction, onClose }: Props) {
237
279
  setLoading(true);
238
280
  setTransactionDetail(null);
239
281
  try{
240
- const response = await transferAPI.status(transaction.reference) as any;
282
+ const response = await statusFetcher(reference) as any;
241
283
  console.log("Transaction payload: ", response);
242
284
  if(response.status){
243
285
  setTransactionDetail(response.payload);
@@ -250,19 +292,70 @@ export function ResultScreen({ transaction, onClose }: Props) {
250
292
 
251
293
  }
252
294
 
295
+ const calculateNextDate=(current: Date | string, frequency: SubscriptionFreq): Date=> {
296
+ const date = new Date(current);
297
+
298
+ switch (frequency) {
299
+ case 'DAILY':
300
+ date.setDate(date.getDate() + 1);
301
+ break;
302
+ case 'WEEKLY':
303
+ date.setDate(date.getDate() + 7);
304
+ break;
305
+ case 'MONTHLY':
306
+ date.setMonth(date.getMonth() + 1);
307
+ break;
308
+ case 'YEARLY':
309
+ date.setFullYear(date.getFullYear() + 1);
310
+ break;
311
+ default:
312
+ throw new Error(`Unknown frequency: ${frequency}`);
313
+ }
314
+
315
+ return date;
316
+ }
317
+
318
+ const handlePayment = async () => {
319
+ pauseTimer();
320
+ try {
321
+ await subscriptionAPI.create({
322
+ serviceName: summaryRows?.find(r => r.label === 'To')?.value ?? 'Payment',
323
+ serviceCategory: 'Payment', // default category
324
+ amount: transactionDetail.amount, // from your existing payment data
325
+ currency: transactionDetail.currency,
326
+ frequency: recurringFrequency,
327
+ accountNumber: transactionDetail.senderAccountNumber,
328
+ receiverAccountNumber: transactionDetail.destAccountNumber,
329
+ logoUrl: transactionDetail.recipientLogo ?? '',
330
+ nextPaymentDate: calculateNextDate(new Date(), recurringFrequency).toISOString(),
331
+ });
332
+ } catch {
333
+ console.warn('Subscription registration failed silently');
334
+ } finally {
335
+ resumeTimer();
336
+ }
337
+ };
338
+
253
339
 
254
340
 
341
+ // useEffect(() => {
342
+ // const timer = setTimeout(onClose, COUNTDOWN_SECONDS * 1000);
343
+ // return () => clearTimeout(timer);
344
+ // }, [onClose]);
345
+
255
346
  useEffect(() => {
256
- const timer = setTimeout(onClose, COUNTDOWN_SECONDS * 1000);
257
- return () => clearTimeout(timer);
258
- }, [onClose]);
347
+ startTimer(remainingTimeRef.current);
348
+ return () => {
349
+ if (timerRef.current) clearTimeout(timerRef.current);
350
+ };
351
+ }, [startTimer])
259
352
 
260
353
 
261
354
  useEffect(()=>{
262
- if(transaction){
355
+ if(reference){
263
356
  loadTransaction();
264
357
  }
265
- }, [transaction]);
358
+ }, [reference]);
266
359
 
267
360
 
268
361
  if (loading && !transactionDetail) {
@@ -286,13 +379,22 @@ export function ResultScreen({ transaction, onClose }: Props) {
286
379
  <Amount>{formatted}</Amount>
287
380
 
288
381
  <Card>
289
- <Row label="To" value={recipientName} />
290
- <Row label="Channel" value={channelLabel[transactionDetail?.channel] ?? transaction?.channel} />
382
+ {summaryRows?.map((row) => (
383
+ <Row key={row.label} label={row.label} value={row.value} />
384
+ ))}
291
385
  <Row label="Reference" value={transactionDetail?.reference} />
292
386
  <Row label="Date" value={dateStr} />
293
387
  <Row label="Status" value={transactionDetail?.status?.toUpperCase()} />
294
388
  </Card>
295
-
389
+ {allowRecurring && (
390
+ <RecurringToggle
391
+ onToggle={(enabled, freq) => {
392
+ setRecurringEnabled(enabled);
393
+ setRecurringFrequency(freq);
394
+ handlePayment();
395
+ }}
396
+ />
397
+ )}
296
398
  <Footer>
297
399
  <CountdownRing duration={COUNTDOWN_SECONDS * 1000} />
298
400
  <CloseBtn onPress={onClose} activeOpacity={0.8}>
@@ -301,5 +403,4 @@ export function ResultScreen({ transaction, onClose }: Props) {
301
403
  </Footer>
302
404
  </Container>
303
405
  );
304
- }
305
-
406
+ }
@@ -362,8 +362,6 @@ const SendScreen = ({
362
362
  // No address — Django will reverse-geocode
363
363
  };
364
364
  }
365
-
366
-
367
365
  }
368
366
  const newTransactionPayload ={...transactionPayload, movement}
369
367
  switch (newTransactionPayload?.channel) {
@@ -450,10 +448,25 @@ const SendScreen = ({
450
448
  };
451
449
 
452
450
  if (completedTx) {
451
+ const mergedTx = { ...transactionPayload, ...completedTx } as any;
452
+ const recipient = mergedTx?.recipient as any;
453
+ const recipientName = recipient?.accountName ?? recipient?.name ?? '—';
454
+ const channelLabel: Record<string, string> = {
455
+ transfer: 'Bank Transfer',
456
+ bluetooth: 'Bluetooth',
457
+ proximity: 'Nearby',
458
+ nqr: 'QR Code',
459
+ nfc: 'NFC Tap',
460
+ };
453
461
 
454
462
  return (
455
463
  <ResultScreen
456
- transaction={{...transactionPayload, ...completedTx}}
464
+ reference={mergedTx.reference}
465
+ summaryRows={[
466
+ { label: 'To', value: recipientName },
467
+ { label: 'Channel', value: channelLabel[mergedTx?.channel] ?? mergedTx?.channel },
468
+ ]}
469
+ allowRecurring
457
470
  onClose={handleResultClose}
458
471
  />
459
472
  );
@@ -568,8 +581,32 @@ const SendScreen = ({
568
581
  </SheetHeader>
569
582
 
570
583
  <ConfirmScreen
571
- userId={getFPStore().psspId || ''}
572
- transaction={transactionPayload}
584
+ amount={Number(transactionPayload?.amount || 0)}
585
+ currency={transactionPayload?.currency}
586
+ subtitle="Double check the transfer details before you proceed. Please note that successful transfers cannot be reversed."
587
+ summaryRows={(() => {
588
+ const recipient = transactionPayload?.recipient as any;
589
+ const recipientName = recipient?.accountName ?? '';
590
+ const accountNumber = recipient?.accountNumber ?? '';
591
+ const bankName = (recipient as any)?.bankName;
592
+ const rows = [{ label: 'Name', value: recipientName }];
593
+ if (transactionPayload?.isBank) {
594
+ if (transactionPayload?.channel === 'transfer') {
595
+ rows.push({ label: 'Bank', value: bankName });
596
+ rows.push({ label: 'Account No.', value: accountNumber });
597
+ }
598
+ } else {
599
+ rows.push({ label: 'Account Number', value: accountNumber });
600
+ }
601
+ return rows;
602
+ })()}
603
+ validate={(pin: string) =>
604
+ transferAPI.validateTransfer(
605
+ pin,
606
+ getFPStore().psspId || '',
607
+ (transactionPayload?.recipient as any)?.id
608
+ )
609
+ }
573
610
  onContinue={handleProcessingTransaction}
574
611
  />
575
612
  </SheetScrollView>
@@ -579,4 +616,4 @@ const SendScreen = ({
579
616
  );
580
617
  };
581
618
 
582
- export default SendScreen;
619
+ export default SendScreen;
@@ -0,0 +1,252 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { View, Text, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
3
+ import styled from 'styled-components/native';
4
+ import { C, F, R, S } from '../../../theme';
5
+ import { billsAPI } from '../../../../core/api';
6
+ import type { FPNetworkCode, FPNetworkOperator, FPAirtimePurchaseRequest } from '../../../../core/types';
7
+
8
+ // ── Network auto-detect ──────────────────────────────────────
9
+ // Nigerian mobile prefixes — not exhaustive, just enough to make the
10
+ // "auto-detect, allow override" UX work for the common case. Ported
11
+ // numbers will be wrong, which is exactly why manual override exists.
12
+ const PREFIX_MAP: Record<string, FPNetworkCode> = {
13
+ '0803': 'MTN', '0806': 'MTN', '0703': 'MTN', '0706': 'MTN', '0813': 'MTN', '0816': 'MTN', '0810': 'MTN', '0814': 'MTN', '0903': 'MTN', '0906': 'MTN', '0913': 'MTN', '0916': 'MTN',
14
+ '0802': 'AIRTEL', '0808': 'AIRTEL', '0708': 'AIRTEL', '0812': 'AIRTEL', '0701': 'AIRTEL', '0902': 'AIRTEL', '0907': 'AIRTEL', '0901': 'AIRTEL', '0911': 'AIRTEL',
15
+ '0805': 'GLO', '0807': 'GLO', '0815': 'GLO', '0811': 'GLO', '0905': 'GLO', '0915': 'GLO',
16
+ '0809': '9MOBILE', '0817': '9MOBILE', '0818': '9MOBILE', '0908': '9MOBILE', '0909': '9MOBILE',
17
+ };
18
+
19
+ function detectNetwork(phone: string): FPNetworkCode | null {
20
+ const prefix = phone.slice(0, 4);
21
+ return PREFIX_MAP[prefix] ?? null;
22
+ }
23
+
24
+ // ── Styled Components ────────────────────────────────────────
25
+
26
+ const Container = styled(View)`
27
+ flex: 1;
28
+ background-color: ${C.white};
29
+ `;
30
+
31
+ const Body = styled(ScrollView)`
32
+ flex: 1;
33
+ padding: ${S.lg}px;
34
+ `;
35
+
36
+ const FieldLabel = styled(Text)`
37
+ font-size: ${F.sm}px;
38
+ font-weight: 600;
39
+ color: ${C.muted};
40
+ margin-bottom: ${S.xs}px;
41
+ `;
42
+
43
+ const InputBox = styled.View`
44
+ background-color: ${C.surface};
45
+ border-radius: ${R.lg}px;
46
+ padding: 0 ${S.md}px;
47
+ height: 52px;
48
+ flex-direction: row;
49
+ align-items: center;
50
+ margin-bottom: ${S.md}px;
51
+ `;
52
+
53
+ const StyledInput = styled.TextInput`
54
+ flex: 1;
55
+ font-size: ${F.md}px;
56
+ color: ${C.ink};
57
+ `;
58
+
59
+ const CurrencyPrefix = styled(Text)`
60
+ font-size: ${F.md}px;
61
+ color: ${C.muted};
62
+ margin-right: ${S.xs}px;
63
+ `;
64
+
65
+ const NetworkRow = styled(View)`
66
+ flex-direction: row;
67
+ gap: ${S.sm}px;
68
+ margin-bottom: ${S.md}px;
69
+ `;
70
+
71
+ const NetworkChip = styled(TouchableOpacity)<{ selected: boolean; brandColor?: string }>`
72
+ flex: 1;
73
+ height: 44px;
74
+ border-radius: ${R.full}px;
75
+ align-items: center;
76
+ justify-content: center;
77
+ background-color: ${(props: any) => (props.selected ? (props.brandColor ?? C.brand) : C.surface)};
78
+ `;
79
+
80
+ const NetworkChipText = styled(Text)<{ selected: boolean }>`
81
+ font-size: ${F.sm}px;
82
+ font-weight: 700;
83
+ color: ${(props: any) => (props.selected ? C.white : C.muted)};
84
+ `;
85
+
86
+ const AmountRow = styled(View)`
87
+ flex-direction: row;
88
+ flex-wrap: wrap;
89
+ gap: ${S.sm}px;
90
+ margin-bottom: ${S.lg}px;
91
+ `;
92
+
93
+ const AmountChip = styled(TouchableOpacity)<{ selected: boolean }>`
94
+ width: 30%;
95
+ height: 56px;
96
+ border-radius: ${R.md}px;
97
+ align-items: center;
98
+ justify-content: center;
99
+ background-color: ${(props: any) => (props.selected ? C.brand : C.surface)};
100
+ `;
101
+
102
+ const AmountChipText = styled(Text)<{ selected: boolean }>`
103
+ font-size: ${F.md}px;
104
+ font-weight: 700;
105
+ color: ${(props: any) => (props.selected ? C.white : C.ink)};
106
+ `;
107
+
108
+ const ContinueButton = styled(TouchableOpacity)<{ disabled?: boolean }>`
109
+ background-color: ${(props: any) => (props.disabled ? C.ghost : C.brand)};
110
+ border-radius: ${R.full}px;
111
+ padding: ${S.md}px 0;
112
+ align-items: center;
113
+ margin-top: ${S.sm}px;
114
+ `;
115
+
116
+ const ContinueButtonText = styled(Text)`
117
+ color: ${C.white};
118
+ font-weight: 800;
119
+ font-size: ${F.md}px;
120
+ `;
121
+
122
+ const QUICK_AMOUNTS = [100, 200, 500, 1000, 2000, 5000];
123
+
124
+ // ── Props ─────────────────────────────────────────────────────
125
+
126
+ interface Props {
127
+ amount: number;
128
+ onProcessTransaction: (
129
+ payload: FPAirtimePurchaseRequest,
130
+ summaryRows: { label: string; value: string }[]
131
+ ) => void;
132
+ onError?: (err: { code: string; message: string }) => void;
133
+ }
134
+
135
+ export default function AirtimeScreen({ amount, onProcessTransaction, onError }: Props) {
136
+ const [networks, setNetworks] = useState<FPNetworkOperator[]>([]);
137
+ const [phone, setPhone] = useState('');
138
+ const [network, setNetwork] = useState<FPNetworkCode | null>(null);
139
+ const [networkTouched, setNetworkTouched] = useState(false);
140
+ const [purchaseAmount, setPurchaseAmount] = useState<string>(amount > 0 ? String(amount) : '');
141
+
142
+ useEffect(() => {
143
+ billsAPI
144
+ .getNetworks()
145
+ .then((res: any) => {
146
+ if (res.status) setNetworks(res.payload.filter((n: FPNetworkOperator) => n.isActive));
147
+ })
148
+ .catch(() => {
149
+ // Non-fatal — chips still render from the static prefix map's
150
+ // codes even if the live operator list (logos/colors) fails to load
151
+ onError?.({ code: 'NETWORK_LOAD_FAILED', message: 'Could not load network list' });
152
+ });
153
+ }, []);
154
+
155
+ useEffect(() => {
156
+ if (!networkTouched) {
157
+ const detected = detectNetwork(phone);
158
+ if (detected) setNetwork(detected);
159
+ }
160
+ }, [phone, networkTouched]);
161
+
162
+ const isValid =
163
+ /^0\d{10}$/.test(phone) && !!network && Number(purchaseAmount) >= 50 && Number(purchaseAmount) <= 50000;
164
+
165
+ const handleContinue = () => {
166
+ if (!isValid || !network) return;
167
+ const amountInKobo = Math.round(Number(purchaseAmount) * 100);
168
+ onProcessTransaction(
169
+ {
170
+ category: 'AIRTIME',
171
+ phoneNumber: phone,
172
+ network,
173
+ amountInKobo,
174
+ idempotencyKey: `airtime_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
175
+ },
176
+ [
177
+ { label: 'Phone Number', value: phone },
178
+ { label: 'Network', value: network },
179
+ ]
180
+ );
181
+ };
182
+
183
+ return (
184
+ <Container>
185
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
186
+ <Body keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
187
+ <FieldLabel>Phone Number</FieldLabel>
188
+ <InputBox>
189
+ <StyledInput
190
+ placeholder="080XXXXXXXX"
191
+ placeholderTextColor={C.ghost}
192
+ keyboardType="number-pad"
193
+ maxLength={11}
194
+ value={phone}
195
+ onChangeText={setPhone}
196
+ />
197
+ </InputBox>
198
+
199
+ <FieldLabel>Network</FieldLabel>
200
+ <NetworkRow>
201
+ {(['MTN', 'AIRTEL', 'GLO', '9MOBILE'] as FPNetworkCode[]).map((code) => {
202
+ const op = networks.find((n: any) => n.code === code);
203
+ return (
204
+ <NetworkChip
205
+ key={code}
206
+ selected={network === code}
207
+ brandColor={op?.brandColor}
208
+ onPress={() => {
209
+ setNetworkTouched(true);
210
+ setNetwork(code);
211
+ }}
212
+ >
213
+ <NetworkChipText selected={network === code}>{code}</NetworkChipText>
214
+ </NetworkChip>
215
+ );
216
+ })}
217
+ </NetworkRow>
218
+
219
+ <FieldLabel>Amount</FieldLabel>
220
+ <AmountRow>
221
+ {QUICK_AMOUNTS.map((amt) => (
222
+ <AmountChip
223
+ key={amt}
224
+ selected={Number(purchaseAmount) === amt}
225
+ onPress={() => setPurchaseAmount(String(amt))}
226
+ >
227
+ <AmountChipText selected={Number(purchaseAmount) === amt}>
228
+ ₦{amt.toLocaleString()}
229
+ </AmountChipText>
230
+ </AmountChip>
231
+ ))}
232
+ </AmountRow>
233
+
234
+ <InputBox>
235
+ <CurrencyPrefix>₦</CurrencyPrefix>
236
+ <StyledInput
237
+ placeholder="Enter amount"
238
+ placeholderTextColor={C.ghost}
239
+ keyboardType="number-pad"
240
+ value={purchaseAmount}
241
+ onChangeText={setPurchaseAmount}
242
+ />
243
+ </InputBox>
244
+
245
+ <ContinueButton disabled={!isValid} onPress={handleContinue} activeOpacity={0.85}>
246
+ <ContinueButtonText>Continue</ContinueButtonText>
247
+ </ContinueButton>
248
+ </Body>
249
+ </KeyboardAvoidingView>
250
+ </Container>
251
+ );
252
+ }