react-native-fpay 0.4.29 → 0.4.31

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 (79) 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/hooks/useLocation.js +66 -0
  10. package/lib/module/hooks/useLocation.js.map +1 -0
  11. package/lib/module/ui/components/ConfirmScreen.js +43 -51
  12. package/lib/module/ui/components/ConfirmScreen.js.map +1 -1
  13. package/lib/module/ui/components/RecurringToggle.js +94 -0
  14. package/lib/module/ui/components/RecurringToggle.js.map +1 -0
  15. package/lib/module/ui/modals/FPShell.js +19 -0
  16. package/lib/module/ui/modals/FPShell.js.map +1 -1
  17. package/lib/module/ui/screens/BillsScreen.js +186 -0
  18. package/lib/module/ui/screens/BillsScreen.js.map +1 -0
  19. package/lib/module/ui/screens/ResultScreen.js +113 -28
  20. package/lib/module/ui/screens/ResultScreen.js.map +1 -1
  21. package/lib/module/ui/screens/SendScreen.js +85 -16
  22. package/lib/module/ui/screens/SendScreen.js.map +1 -1
  23. package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js +257 -0
  24. package/lib/module/ui/screens/sub/billPayment/AirtimeScreen.js.map +1 -0
  25. package/lib/module/ui/screens/sub/billPayment/CableScreen.js +264 -0
  26. package/lib/module/ui/screens/sub/billPayment/CableScreen.js.map +1 -0
  27. package/lib/module/ui/screens/sub/billPayment/DataScreen.js +273 -0
  28. package/lib/module/ui/screens/sub/billPayment/DataScreen.js.map +1 -0
  29. package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js +337 -0
  30. package/lib/module/ui/screens/sub/billPayment/ElectricityScreen.js.map +1 -0
  31. package/lib/module/ui/screens/sub/sendPayment/TransferSubScreen.js +1 -1
  32. package/lib/module/ui/screens/sub/sendPayment/TransferSubScreen.js.map +1 -1
  33. package/lib/typescript/src/FountainPayProvider.d.ts.map +1 -1
  34. package/lib/typescript/src/core/api/index.d.ts +52 -63
  35. package/lib/typescript/src/core/api/index.d.ts.map +1 -1
  36. package/lib/typescript/src/core/types/index.d.ts +159 -6
  37. package/lib/typescript/src/core/types/index.d.ts.map +1 -1
  38. package/lib/typescript/src/engine/FPEngine.d.ts +4 -2
  39. package/lib/typescript/src/engine/FPEngine.d.ts.map +1 -1
  40. package/lib/typescript/src/hooks/useLocation.d.ts +12 -0
  41. package/lib/typescript/src/hooks/useLocation.d.ts.map +1 -0
  42. package/lib/typescript/src/index.d.ts +1 -1
  43. package/lib/typescript/src/index.d.ts.map +1 -1
  44. package/lib/typescript/src/ui/components/ConfirmScreen.d.ts +25 -4
  45. package/lib/typescript/src/ui/components/ConfirmScreen.d.ts.map +1 -1
  46. package/lib/typescript/src/ui/components/RecurringToggle.d.ts +7 -0
  47. package/lib/typescript/src/ui/components/RecurringToggle.d.ts.map +1 -0
  48. package/lib/typescript/src/ui/modals/FPShell.d.ts.map +1 -1
  49. package/lib/typescript/src/ui/screens/BillsScreen.d.ts +10 -0
  50. package/lib/typescript/src/ui/screens/BillsScreen.d.ts.map +1 -0
  51. package/lib/typescript/src/ui/screens/ResultScreen.d.ts +20 -3
  52. package/lib/typescript/src/ui/screens/ResultScreen.d.ts.map +1 -1
  53. package/lib/typescript/src/ui/screens/SendScreen.d.ts.map +1 -1
  54. package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts +15 -0
  55. package/lib/typescript/src/ui/screens/sub/billPayment/AirtimeScreen.d.ts.map +1 -0
  56. package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts +14 -0
  57. package/lib/typescript/src/ui/screens/sub/billPayment/CableScreen.d.ts.map +1 -0
  58. package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts +14 -0
  59. package/lib/typescript/src/ui/screens/sub/billPayment/DataScreen.d.ts.map +1 -0
  60. package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts +16 -0
  61. package/lib/typescript/src/ui/screens/sub/billPayment/ElectricityScreen.d.ts.map +1 -0
  62. package/package.json +2 -2
  63. package/src/FountainPayProvider.tsx +7 -0
  64. package/src/core/api/index.ts +149 -27
  65. package/src/core/types/index.ts +194 -11
  66. package/src/engine/FPEngine.ts +12 -1
  67. package/src/hooks/useLocation.ts +81 -0
  68. package/src/index.ts +9 -1
  69. package/src/ui/components/ConfirmScreen.tsx +47 -54
  70. package/src/ui/components/RecurringToggle.tsx +106 -0
  71. package/src/ui/modals/FPShell.tsx +26 -3
  72. package/src/ui/screens/BillsScreen.tsx +197 -0
  73. package/src/ui/screens/ResultScreen.tsx +129 -28
  74. package/src/ui/screens/SendScreen.tsx +124 -68
  75. package/src/ui/screens/sub/billPayment/AirtimeScreen.tsx +252 -0
  76. package/src/ui/screens/sub/billPayment/CableScreen.tsx +274 -0
  77. package/src/ui/screens/sub/billPayment/DataScreen.tsx +263 -0
  78. package/src/ui/screens/sub/billPayment/ElectricityScreen.tsx +344 -0
  79. package/src/ui/screens/sub/sendPayment/TransferSubScreen.tsx +1 -1
@@ -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
+ }
@@ -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
+ }