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.
- package/lib/module/FountainPayProvider.js +5 -0
- package/lib/module/FountainPayProvider.js.map +1 -1
- package/lib/module/core/api/index.js +59 -0
- package/lib/module/core/api/index.js.map +1 -1
- package/lib/module/core/types/index.js +32 -0
- package/lib/module/core/types/index.js.map +1 -1
- package/lib/module/engine/FPEngine.js +9 -0
- package/lib/module/engine/FPEngine.js.map +1 -1
- package/lib/module/ui/components/ConfirmScreen.js +43 -51
- package/lib/module/ui/components/ConfirmScreen.js.map +1 -1
- package/lib/module/ui/components/RecurringToggle.js +94 -0
- package/lib/module/ui/components/RecurringToggle.js.map +1 -0
- package/lib/module/ui/modals/FPShell.js +19 -0
- package/lib/module/ui/modals/FPShell.js.map +1 -1
- package/lib/module/ui/screens/BillsScreen.js +187 -0
- package/lib/module/ui/screens/BillsScreen.js.map +1 -0
- package/lib/module/ui/screens/ResultScreen.js +113 -28
- package/lib/module/ui/screens/ResultScreen.js.map +1 -1
- package/lib/module/ui/screens/SendScreen.js +54 -6
- package/lib/module/ui/screens/SendScreen.js.map +1 -1
- package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js +257 -0
- package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js.map +1 -0
- package/lib/module/ui/screens/sub/billPayment/CableScreen.js +264 -0
- package/lib/module/ui/screens/sub/billPayment/CableScreen.js.map +1 -0
- package/lib/module/ui/screens/sub/billPayment/DataScreen.js +273 -0
- package/lib/module/ui/screens/sub/billPayment/DataScreen.js.map +1 -0
- package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js +337 -0
- package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js.map +1 -0
- package/lib/typescript/src/FountainPayProvider.d.ts.map +1 -1
- package/lib/typescript/src/core/api/index.d.ts +52 -63
- package/lib/typescript/src/core/api/index.d.ts.map +1 -1
- package/lib/typescript/src/core/types/index.d.ts +146 -0
- package/lib/typescript/src/core/types/index.d.ts.map +1 -1
- package/lib/typescript/src/engine/FPEngine.d.ts +4 -2
- package/lib/typescript/src/engine/FPEngine.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/ui/components/ConfirmScreen.d.ts +25 -4
- package/lib/typescript/src/ui/components/ConfirmScreen.d.ts.map +1 -1
- package/lib/typescript/src/ui/components/RecurringToggle.d.ts +7 -0
- package/lib/typescript/src/ui/components/RecurringToggle.d.ts.map +1 -0
- package/lib/typescript/src/ui/modals/FPShell.d.ts.map +1 -1
- package/lib/typescript/src/ui/screens/BillsScreen.d.ts +10 -0
- package/lib/typescript/src/ui/screens/BillsScreen.d.ts.map +1 -0
- package/lib/typescript/src/ui/screens/ResultScreen.d.ts +20 -3
- package/lib/typescript/src/ui/screens/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/ui/screens/SendScreen.d.ts.map +1 -1
- package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts +15 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts.map +1 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts +14 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts.map +1 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts +14 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts.map +1 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts +16 -0
- package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/FountainPayProvider.tsx +7 -0
- package/src/core/api/index.ts +149 -27
- package/src/core/types/index.ts +181 -0
- package/src/engine/FPEngine.ts +12 -1
- package/src/index.ts +9 -1
- package/src/ui/components/ConfirmScreen.tsx +47 -54
- package/src/ui/components/RecurringToggle.tsx +106 -0
- package/src/ui/modals/FPShell.tsx +26 -3
- package/src/ui/screens/BillsScreen.tsx +198 -0
- package/src/ui/screens/ResultScreen.tsx +129 -28
- package/src/ui/screens/SendScreen.tsx +43 -6
- package/src/ui/screens/sub/billPayment/AirtimeScreen.tsx +252 -0
- package/src/ui/screens/sub/billPayment/CableScreen.tsx +274 -0
- package/src/ui/screens/sub/billPayment/DataScreen.tsx +263 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
257
|
-
return () =>
|
|
258
|
-
|
|
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(
|
|
355
|
+
if(reference){
|
|
263
356
|
loadTransaction();
|
|
264
357
|
}
|
|
265
|
-
}, [
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
+
}
|