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
@@ -0,0 +1,274 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { View, Text, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, ActivityIndicator } 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 { FPBillProvider, FPBillTariff, FPCablePurchaseRequest } from '../../../../core/types';
7
+
8
+ const Container = styled(View)`
9
+ flex: 1;
10
+ background-color: ${C.white};
11
+ `;
12
+
13
+ const Body = styled(ScrollView)`
14
+ flex: 1;
15
+ padding: ${S.lg}px;
16
+ `;
17
+
18
+ const FieldLabel = styled(Text)`
19
+ font-size: ${F.sm}px;
20
+ font-weight: 600;
21
+ color: ${C.muted};
22
+ margin-bottom: ${S.xs}px;
23
+ `;
24
+
25
+ const InputBox = styled.View`
26
+ background-color: ${C.surface};
27
+ border-radius: ${R.lg}px;
28
+ padding: 0 ${S.md}px;
29
+ height: 52px;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ margin-bottom: ${S.md}px;
33
+ `;
34
+
35
+ const StyledInput = styled.TextInput`
36
+ flex: 1;
37
+ font-size: ${F.md}px;
38
+ color: ${C.ink};
39
+ `;
40
+
41
+ const ProviderRow = styled(View)`
42
+ flex-direction: row;
43
+ gap: ${S.sm}px;
44
+ margin-bottom: ${S.md}px;
45
+ `;
46
+
47
+ const ProviderChip = styled(TouchableOpacity)<{ selected: boolean }>`
48
+ flex: 1;
49
+ height: 44px;
50
+ border-radius: ${R.md}px;
51
+ align-items: center;
52
+ justify-content: center;
53
+ background-color: ${(props: any) => (props.selected ? C.brand : C.surface)};
54
+ `;
55
+
56
+ const ProviderChipText = styled(Text)<{ selected: boolean }>`
57
+ font-size: ${F.sm}px;
58
+ font-weight: 700;
59
+ color: ${(props: any) => (props.selected ? C.white : C.muted)};
60
+ `;
61
+
62
+ const CustomerNameBox = styled(View)`
63
+ background-color: ${C.greenLight};
64
+ border-radius: ${R.md}px;
65
+ padding: ${S.sm}px ${S.md}px;
66
+ margin-bottom: ${S.md}px;
67
+ `;
68
+
69
+ const CustomerNameText = styled(Text)`
70
+ color: ${C.green};
71
+ font-weight: 700;
72
+ font-size: ${F.sm}px;
73
+ `;
74
+
75
+ const TariffCard = styled(TouchableOpacity)<{ selected: boolean; disabled?: boolean }>`
76
+ border-radius: ${R.lg}px;
77
+ padding: ${S.md}px;
78
+ margin-bottom: ${S.sm}px;
79
+ background-color: ${(props: any) => (props.selected ? C.brandLight : C.surface)};
80
+ border-width: 1.5px;
81
+ border-color: ${(props: any) => (props.selected ? C.brand : 'transparent')};
82
+ opacity: ${(props: any) => (props.disabled ? 0.45 : 1)};
83
+ flex-direction: row;
84
+ justify-content: space-between;
85
+ align-items: center;
86
+ `;
87
+
88
+ const TariffLabel = styled(Text)`
89
+ font-size: ${F.md}px;
90
+ font-weight: 700;
91
+ color: ${C.ink};
92
+ `;
93
+
94
+ const TariffPrice = styled(Text)`
95
+ font-size: ${F.md}px;
96
+ font-weight: 800;
97
+ color: ${C.brand};
98
+ `;
99
+
100
+ const EmptyText = styled(Text)`
101
+ text-align: center;
102
+ color: ${C.muted};
103
+ margin-top: ${S.lg}px;
104
+ `;
105
+
106
+ const ContinueButton = styled(TouchableOpacity)<{ disabled?: boolean }>`
107
+ background-color: ${(props: any) => (props.disabled ? C.ghost : C.brand)};
108
+ border-radius: ${R.full}px;
109
+ padding: ${S.md}px 0;
110
+ align-items: center;
111
+ margin-top: ${S.sm}px;
112
+ `;
113
+
114
+ const ContinueButtonText = styled(Text)`
115
+ color: ${C.white};
116
+ font-weight: 800;
117
+ font-size: ${F.md}px;
118
+ `;
119
+
120
+ interface Props {
121
+ onProcessTransaction: (
122
+ payload: FPCablePurchaseRequest,
123
+ summaryRows: { label: string; value: string }[]
124
+ ) => void;
125
+ onError?: (err: { code: string; message: string }) => void;
126
+ }
127
+
128
+ export default function CableScreen({ onProcessTransaction, onError }: Props) {
129
+ const [providers, setProviders] = useState<FPBillProvider[]>([]);
130
+ const [provider, setProvider] = useState<FPBillProvider | null>(null);
131
+ const [smartcardNumber, setSmartcardNumber] = useState('');
132
+ const [validating, setValidating] = useState(false);
133
+ const [customerName, setCustomerName] = useState<string | null>(null);
134
+ const [tariffs, setTariffs] = useState<FPBillTariff[]>([]);
135
+ const [tariffsLoading, setTariffsLoading] = useState(false);
136
+ const [selectedTariff, setSelectedTariff] = useState<FPBillTariff | null>(null);
137
+ const [phone, setPhone] = useState('');
138
+
139
+ useEffect(() => {
140
+ billsAPI
141
+ .getCableProviders()
142
+ .then((res: any) => {
143
+ if (res.status) setProviders(res.payload.filter((p: FPBillProvider) => p.isActive));
144
+ })
145
+ .catch(() => onError?.({ code: 'PROVIDER_LOAD_FAILED', message: 'Could not load cable providers' }));
146
+ }, []);
147
+
148
+ useEffect(() => {
149
+ setCustomerName(null);
150
+ if (!provider || smartcardNumber.length < 10) return;
151
+
152
+ setValidating(true);
153
+ billsAPI
154
+ .validateSmartcard(smartcardNumber, provider.code)
155
+ .then((res: any) => {
156
+ if (res.status) {
157
+ setCustomerName(res.payload.customerName);
158
+ } else {
159
+ onError?.({ code: 'SMARTCARD_INVALID', message: res.message });
160
+ }
161
+ })
162
+ .catch(() => onError?.({ code: 'SMARTCARD_VALIDATION_FAILED', message: 'Could not validate smartcard number' }))
163
+ .finally(() => setValidating(false));
164
+ }, [provider, smartcardNumber]);
165
+
166
+ useEffect(() => {
167
+ if (!provider) {
168
+ setTariffs([]);
169
+ setSelectedTariff(null);
170
+ return;
171
+ }
172
+ setTariffsLoading(true);
173
+ setSelectedTariff(null);
174
+ billsAPI
175
+ .getCableTariffs(provider.code)
176
+ .then((res: any) => {
177
+ if (res.status) setTariffs(res.payload);
178
+ })
179
+ .catch(() => onError?.({ code: 'TARIFFS_LOAD_FAILED', message: 'Could not load bouquets' }))
180
+ .finally(() => setTariffsLoading(false));
181
+ }, [provider]);
182
+
183
+ const isValid =
184
+ !!provider && smartcardNumber.length >= 10 && !!customerName && !!selectedTariff && /^0\d{10}$/.test(phone);
185
+
186
+ const handleContinue = () => {
187
+ if (!isValid || !provider || !selectedTariff) return;
188
+ onProcessTransaction(
189
+ {
190
+ category: 'CABLE',
191
+ smartcardNumber,
192
+ provider: provider.code,
193
+ tariffCode: selectedTariff.code,
194
+ amountInKobo: selectedTariff.priceInKobo,
195
+ phoneNumber: phone,
196
+ idempotencyKey: `cable_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
197
+ },
198
+ [
199
+ { label: 'Provider', value: provider.displayName },
200
+ { label: 'Smartcard Number', value: smartcardNumber },
201
+ { label: 'Customer', value: customerName ?? '' },
202
+ { label: 'Bouquet', value: selectedTariff.label },
203
+ ]
204
+ );
205
+ };
206
+
207
+ return (
208
+ <Container>
209
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
210
+ <Body keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
211
+ <FieldLabel>Provider</FieldLabel>
212
+ <ProviderRow>
213
+ {providers.map((p: any) => (
214
+ <ProviderChip key={p.code} selected={provider?.code === p.code} onPress={() => setProvider(p)}>
215
+ <ProviderChipText selected={provider?.code === p.code}>{p.displayName}</ProviderChipText>
216
+ </ProviderChip>
217
+ ))}
218
+ </ProviderRow>
219
+
220
+ <FieldLabel>Smartcard / IUC Number</FieldLabel>
221
+ <InputBox>
222
+ <StyledInput
223
+ placeholder="Enter smartcard number"
224
+ placeholderTextColor={C.ghost}
225
+ keyboardType="number-pad"
226
+ value={smartcardNumber}
227
+ onChangeText={setSmartcardNumber}
228
+ />
229
+ </InputBox>
230
+ {validating && <ActivityIndicator color={C.brand} style={{ marginBottom: S.md }} />}
231
+ {customerName && (
232
+ <CustomerNameBox>
233
+ <CustomerNameText>{customerName}</CustomerNameText>
234
+ </CustomerNameBox>
235
+ )}
236
+
237
+ <FieldLabel>Phone Number</FieldLabel>
238
+ <InputBox>
239
+ <StyledInput
240
+ placeholder="080XXXXXXXX"
241
+ placeholderTextColor={C.ghost}
242
+ keyboardType="number-pad"
243
+ maxLength={11}
244
+ value={phone}
245
+ onChangeText={setPhone}
246
+ />
247
+ </InputBox>
248
+
249
+ <FieldLabel>Bouquet</FieldLabel>
250
+ {tariffsLoading && <ActivityIndicator color={C.brand} style={{ marginTop: S.md }} />}
251
+ {!tariffsLoading && provider && tariffs.length === 0 && (
252
+ <EmptyText>No bouquets available for {provider.displayName} right now.</EmptyText>
253
+ )}
254
+ {!tariffsLoading &&
255
+ tariffs.map((tariff: any) => (
256
+ <TariffCard
257
+ key={tariff.code}
258
+ selected={selectedTariff?.code === tariff.code}
259
+ disabled={!tariff.isAvailable}
260
+ onPress={() => tariff.isAvailable && setSelectedTariff(tariff)}
261
+ >
262
+ <TariffLabel>{tariff.label}</TariffLabel>
263
+ <TariffPrice>₦{(tariff.priceInKobo / 100).toLocaleString()}</TariffPrice>
264
+ </TariffCard>
265
+ ))}
266
+
267
+ <ContinueButton disabled={!isValid} onPress={handleContinue} activeOpacity={0.85}>
268
+ <ContinueButtonText>Continue</ContinueButtonText>
269
+ </ContinueButton>
270
+ </Body>
271
+ </KeyboardAvoidingView>
272
+ </Container>
273
+ );
274
+ }
@@ -0,0 +1,263 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { View, Text, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, ActivityIndicator } 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, FPDataPlan, FPDataPurchaseRequest } from '../../../../core/types';
7
+
8
+ const PREFIX_MAP: Record<string, FPNetworkCode> = {
9
+ '0803': 'MTN', '0806': 'MTN', '0703': 'MTN', '0706': 'MTN', '0813': 'MTN', '0816': 'MTN', '0810': 'MTN', '0814': 'MTN', '0903': 'MTN', '0906': 'MTN', '0913': 'MTN', '0916': 'MTN',
10
+ '0802': 'AIRTEL', '0808': 'AIRTEL', '0708': 'AIRTEL', '0812': 'AIRTEL', '0701': 'AIRTEL', '0902': 'AIRTEL', '0907': 'AIRTEL', '0901': 'AIRTEL', '0911': 'AIRTEL',
11
+ '0805': 'GLO', '0807': 'GLO', '0815': 'GLO', '0811': 'GLO', '0905': 'GLO', '0915': 'GLO',
12
+ '0809': '9MOBILE', '0817': '9MOBILE', '0818': '9MOBILE', '0908': '9MOBILE', '0909': '9MOBILE',
13
+ };
14
+
15
+ function detectNetwork(phone: string): FPNetworkCode | null {
16
+ return PREFIX_MAP[phone.slice(0, 4)] ?? null;
17
+ }
18
+
19
+ const Container = styled(View)`
20
+ flex: 1;
21
+ background-color: ${C.white};
22
+ `;
23
+
24
+ const Body = styled(ScrollView)`
25
+ flex: 1;
26
+ padding: ${S.lg}px;
27
+ `;
28
+
29
+ const FieldLabel = styled(Text)`
30
+ font-size: ${F.sm}px;
31
+ font-weight: 600;
32
+ color: ${C.muted};
33
+ margin-bottom: ${S.xs}px;
34
+ `;
35
+
36
+ const InputBox = styled.View`
37
+ background-color: ${C.surface};
38
+ border-radius: ${R.lg}px;
39
+ padding: 0 ${S.md}px;
40
+ height: 52px;
41
+ flex-direction: row;
42
+ align-items: center;
43
+ margin-bottom: ${S.md}px;
44
+ `;
45
+
46
+ const StyledInput = styled.TextInput`
47
+ flex: 1;
48
+ font-size: ${F.md}px;
49
+ color: ${C.ink};
50
+ `;
51
+
52
+ const NetworkRow = styled(View)`
53
+ flex-direction: row;
54
+ gap: ${S.sm}px;
55
+ margin-bottom: ${S.lg}px;
56
+ `;
57
+
58
+ const NetworkChip = styled(TouchableOpacity)<{ selected: boolean; brandColor?: string }>`
59
+ flex: 1;
60
+ height: 44px;
61
+ border-radius: ${R.full}px;
62
+ align-items: center;
63
+ justify-content: center;
64
+ background-color: ${(props: any) => (props.selected ? (props.brandColor ?? C.brand) : C.surface)};
65
+ `;
66
+
67
+ const NetworkChipText = styled(Text)<{ selected: boolean }>`
68
+ font-size: ${F.sm}px;
69
+ font-weight: 700;
70
+ color: ${(props: any) => (props.selected ? C.white : C.muted)};
71
+ `;
72
+
73
+ const PlanCard = styled(TouchableOpacity)<{ selected: boolean; disabled?: boolean }>`
74
+ border-radius: ${R.lg}px;
75
+ padding: ${S.md}px;
76
+ margin-bottom: ${S.sm}px;
77
+ background-color: ${(props: any) => (props.selected ? C.brandLight : C.surface)};
78
+ border-width: 1.5px;
79
+ border-color: ${(props: any) => (props.selected ? C.brand : 'transparent')};
80
+ opacity: ${(props: any) => (props.disabled ? 0.45 : 1)};
81
+ flex-direction: row;
82
+ justify-content: space-between;
83
+ align-items: center;
84
+ `;
85
+
86
+ const PlanLabel = styled(Text)`
87
+ font-size: ${F.md}px;
88
+ font-weight: 700;
89
+ color: ${C.ink};
90
+ `;
91
+
92
+ const PlanValidity = styled(Text)`
93
+ font-size: ${F.xs}px;
94
+ color: ${C.muted};
95
+ margin-top: 2px;
96
+ `;
97
+
98
+ const PlanPrice = styled(Text)`
99
+ font-size: ${F.md}px;
100
+ font-weight: 800;
101
+ color: ${C.brand};
102
+ `;
103
+
104
+ const EmptyText = styled(Text)`
105
+ text-align: center;
106
+ color: ${C.muted};
107
+ margin-top: ${S.lg}px;
108
+ `;
109
+
110
+ const ContinueButton = styled(TouchableOpacity)<{ disabled?: boolean }>`
111
+ background-color: ${(props: any) => (props.disabled ? C.ghost : C.brand)};
112
+ border-radius: ${R.full}px;
113
+ padding: ${S.md}px 0;
114
+ align-items: center;
115
+ margin-top: ${S.md}px;
116
+ `;
117
+
118
+ const ContinueButtonText = styled(Text)`
119
+ color: ${C.white};
120
+ font-weight: 800;
121
+ font-size: ${F.md}px;
122
+ `;
123
+
124
+ interface Props {
125
+ onProcessTransaction: (
126
+ payload: FPDataPurchaseRequest,
127
+ summaryRows: { label: string; value: string }[]
128
+ ) => void;
129
+ onError?: (err: { code: string; message: string }) => void;
130
+ }
131
+
132
+ export default function DataScreen({ onProcessTransaction, onError }: Props) {
133
+ const [networks, setNetworks] = useState<FPNetworkOperator[]>([]);
134
+ const [phone, setPhone] = useState('');
135
+ const [network, setNetwork] = useState<FPNetworkCode | null>(null);
136
+ const [networkTouched, setNetworkTouched] = useState(false);
137
+ const [plans, setPlans] = useState<FPDataPlan[]>([]);
138
+ const [plansLoading, setPlansLoading] = useState(false);
139
+ const [selectedPlan, setSelectedPlan] = useState<FPDataPlan | null>(null);
140
+
141
+ useEffect(() => {
142
+ billsAPI
143
+ .getNetworks()
144
+ .then((res: any) => {
145
+ if (res.status) setNetworks(res.payload.filter((n: FPNetworkOperator) => n.isActive));
146
+ })
147
+ .catch(() => {
148
+ onError?.({ code: 'NETWORK_LOAD_FAILED', message: 'Could not load network list' });
149
+ });
150
+ }, []);
151
+
152
+ useEffect(() => {
153
+ if (!networkTouched) {
154
+ const detected = detectNetwork(phone);
155
+ if (detected) setNetwork(detected);
156
+ }
157
+ }, [phone, networkTouched]);
158
+
159
+ useEffect(() => {
160
+ if (!network) {
161
+ setPlans([]);
162
+ setSelectedPlan(null);
163
+ return;
164
+ }
165
+ setPlansLoading(true);
166
+ setSelectedPlan(null);
167
+ billsAPI
168
+ .getDataPlans(network)
169
+ .then((res: any) => {
170
+ if (res.status) setPlans(res.payload);
171
+ })
172
+ .catch(() => {
173
+ onError?.({ code: 'PLANS_LOAD_FAILED', message: 'Could not load data plans' });
174
+ })
175
+ .finally(() => setPlansLoading(false));
176
+ }, [network]);
177
+
178
+ const isValid = /^0\d{10}$/.test(phone) && !!network && !!selectedPlan;
179
+
180
+ const handleContinue = () => {
181
+ if (!isValid || !network || !selectedPlan) return;
182
+ onProcessTransaction(
183
+ {
184
+ category: 'DATA',
185
+ phoneNumber: phone,
186
+ network,
187
+ planId: selectedPlan.id,
188
+ amountInKobo: selectedPlan.priceInKobo,
189
+ idempotencyKey: `data_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
190
+ },
191
+ [
192
+ { label: 'Phone Number', value: phone },
193
+ { label: 'Network', value: network },
194
+ { label: 'Plan', value: `${selectedPlan.label} / ${selectedPlan.validity}` },
195
+ ]
196
+ );
197
+ };
198
+
199
+ return (
200
+ <Container>
201
+ <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
202
+ <Body keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
203
+ <FieldLabel>Phone Number</FieldLabel>
204
+ <InputBox>
205
+ <StyledInput
206
+ placeholder="080XXXXXXXX"
207
+ placeholderTextColor={C.ghost}
208
+ keyboardType="number-pad"
209
+ maxLength={11}
210
+ value={phone}
211
+ onChangeText={setPhone}
212
+ />
213
+ </InputBox>
214
+
215
+ <FieldLabel>Network</FieldLabel>
216
+ <NetworkRow>
217
+ {(['MTN', 'AIRTEL', 'GLO', '9MOBILE'] as FPNetworkCode[]).map((code) => {
218
+ const op = networks.find((n: any) => n.code === code);
219
+ return (
220
+ <NetworkChip
221
+ key={code}
222
+ selected={network === code}
223
+ brandColor={op?.brandColor}
224
+ onPress={() => {
225
+ setNetworkTouched(true);
226
+ setNetwork(code);
227
+ }}
228
+ >
229
+ <NetworkChipText selected={network === code}>{code}</NetworkChipText>
230
+ </NetworkChip>
231
+ );
232
+ })}
233
+ </NetworkRow>
234
+
235
+ <FieldLabel>Select a Plan</FieldLabel>
236
+ {plansLoading && <ActivityIndicator color={C.brand} style={{ marginTop: S.md }} />}
237
+ {!plansLoading && network && plans.length === 0 && (
238
+ <EmptyText>No plans available for {network} right now.</EmptyText>
239
+ )}
240
+ {!plansLoading &&
241
+ plans.map((plan: any) => (
242
+ <PlanCard
243
+ key={plan.id}
244
+ selected={selectedPlan?.id === plan.id}
245
+ disabled={!plan.isAvailable}
246
+ onPress={() => plan.isAvailable && setSelectedPlan(plan)}
247
+ >
248
+ <View>
249
+ <PlanLabel>{plan.label}</PlanLabel>
250
+ <PlanValidity>{plan.validity}</PlanValidity>
251
+ </View>
252
+ <PlanPrice>₦{(plan.priceInKobo / 100).toLocaleString()}</PlanPrice>
253
+ </PlanCard>
254
+ ))}
255
+
256
+ <ContinueButton disabled={!isValid} onPress={handleContinue} activeOpacity={0.85}>
257
+ <ContinueButtonText>Continue</ContinueButtonText>
258
+ </ContinueButton>
259
+ </Body>
260
+ </KeyboardAvoidingView>
261
+ </Container>
262
+ );
263
+ }