diff --git a/App.tsx b/App.tsx index 35bd70f..48b4367 100644 --- a/App.tsx +++ b/App.tsx @@ -32,10 +32,7 @@ import Cable from './screens/cable'; import Support from './screens/support'; import requestInAppReview from './utils/requestInAppReview'; -// TODO: add autocomplete prop to all text inputs -// TODO: add autofocus, keyboardAvoidingView and onSubmitediting prop to all forms // TODO: install why did you render to monitor avoidable re-renders -// TODO: remove orange cellular icon from splashscreen or redesign it /** * Create Stack navigator diff --git a/android/app/src/main/res/drawable/splashscreen_bg.png b/android/app/src/main/res/drawable/splashscreen_bg.png index c78dec5..7e7da5d 100644 Binary files a/android/app/src/main/res/drawable/splashscreen_bg.png and b/android/app/src/main/res/drawable/splashscreen_bg.png differ diff --git a/components/CarrierAndPhoneNumberField.tsx b/components/CarrierAndPhoneNumberField.tsx index b37a045..d799515 100644 --- a/components/CarrierAndPhoneNumberField.tsx +++ b/components/CarrierAndPhoneNumberField.tsx @@ -69,6 +69,8 @@ const CarrierAndPhoneNumberField = ({ textContentType="telephoneNumber" value={phoneNumber} onChangeText={value => setPhoneNumber(value)} + autoComplete={"tel"} + autoFocus /> diff --git a/components/FaultyTxModal.tsx b/components/FaultyTxModal.tsx new file mode 100644 index 0000000..e041073 --- /dev/null +++ b/components/FaultyTxModal.tsx @@ -0,0 +1,68 @@ +//import libraries +import React, {useState} from 'react'; +import Text from '~components/Text'; +import tw from '../lib/tailwind'; +import BottomSheet, {BOTTOMSHEETHEIGHT} from './BottomSheet'; +import Clipboard from '@react-native-clipboard/clipboard'; +import {NavigationProp, useNavigation} from '@react-navigation/native'; +import {StackParamList} from '../navigation/screenParams'; + +interface FaultyTxModalProps { + /** + * The transaction ref to display. + * This determines the visibility of the component + */ + txRef?: string; + + /** + * Function to be called when modal is dismissed + */ + onDismiss: () => void; +} + +const FAULTY_TX_MSG = `Payment was successful but top-up failed due to slow internet connection. Copy below transaction id, then you'll redirected to contact our customer care and complete top-up. Please keep the copied transaction id safe`; + +/** + * Displays modal for faulty transactions i.e transactions where payment + * was successful but top-up completion failed for some reason. + * It shows the `transaction ref` to user, so user can copy it and resolve issue + * with customer care + */ +const FaultyTxModal = ({txRef, onDismiss}: FaultyTxModalProps) => { + const [txRefCopied, setTxRefCopied] = useState(false); + const {navigate} = useNavigation>(); + + const handleCopyId = () => { + Clipboard.setString(txRef!); + setTxRefCopied(true); + setTimeout(() => navigate('Support'), 1000); + }; + + const noop = () => null; + + return ( + + {FAULTY_TX_MSG} + + {txRef} + + + {txRefCopied ? 'TRANSACTION ID COPIED' : 'COPY'} + + + ); +}; + +export default FaultyTxModal; diff --git a/components/PaymentBottomSheet.tsx b/components/PaymentBottomSheet.tsx index 5043dd7..da8d2b1 100644 --- a/components/PaymentBottomSheet.tsx +++ b/components/PaymentBottomSheet.tsx @@ -9,6 +9,7 @@ import PaymentMethodButton, {PAYMENT_METHODS} from './PaymentMethodButton'; import {FlutterwaveInitOptions} from 'flutterwave-react-native/dist/FlutterwaveInit'; import constants from '../utils/constants'; import {AppServices} from '../global'; +import {decrypt, encrypt} from '../utils/crypto'; interface PaymentBottomSheetProps { /** @@ -26,6 +27,14 @@ interface PaymentBottomSheetProps { */ amount: number; + /** + * The unencrypted transaction ref to use for flutterwave. + * We delegate tx ref generation to housing component because + * we want tx ref to house all top-up details to aid in faulty tx handling. + * This component will handle encryption of tx_ref + */ + flutterwaveTxRef: string; + /** * Indicates the service being paid for */ @@ -62,9 +71,13 @@ const PaymentBottomSheet = ({ onFlutterwaveInit, service = 'airtime', visible = false, + flutterwaveTxRef, }: PaymentBottomSheetProps) => { const balance = useAppStore(state => state.profile?.wallet_balance); const profile = useAppStore(state => state.profile); + const txRef = useMemo(() => encrypt( + flutterwaveTxRef || `service=${service};amt=${amount};dt=${Date.now()}`, + ),[flutterwaveTxRef, amount]); // flutterwave options const flutterwaveOptions = useMemo< @@ -73,9 +86,7 @@ const PaymentBottomSheet = ({ () => ({ ...constants.INCOMPLETE_STATIC_FLUTTERWAVE_PAYMENT_OPTIONS, amount: amount, - tx_ref: `${amount}-${service}-top-up-${ - Date.now() + Math.floor(Math.random() * 100_000).toString(16) - }`, + tx_ref: txRef || `service=${service};amt=${amount};dt=${Date.now()}`, customer: profile?.email && profile?.username ? { @@ -93,7 +104,7 @@ const PaymentBottomSheet = ({ } Payment`, }, }), - [amount, profile?.email, profile?.username], + [amount, profile?.email, profile?.username, txRef], ); return ( diff --git a/ios/EasyVtu/Images.xcassets/SplashScreenBackground.imageset/background.png b/ios/EasyVtu/Images.xcassets/SplashScreenBackground.imageset/background.png index 2a8ebe5..e63c92e 100644 Binary files a/ios/EasyVtu/Images.xcassets/SplashScreenBackground.imageset/background.png and b/ios/EasyVtu/Images.xcassets/SplashScreenBackground.imageset/background.png differ diff --git a/package-lock.json b/package-lock.json index c5cb596..3fbd8ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,13 @@ "version": "0.0.1", "dependencies": { "@hookform/resolvers": "^2.9.8", + "@react-native-clipboard/clipboard": "^1.11.1", "@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/native": "^6.0.13", "@react-navigation/native-stack": "^6.9.0", + "@types/crypto-js": "^4.1.1", "axios": "^1.0.0", + "crypto-js": "^3.1.9-1", "flutterwave-react-native": "^1.0.2", "lottie-react-native": "^5.1.4", "react": "18.1.0", @@ -25,7 +28,7 @@ "react-native-in-app-review": "^4.2.1", "react-native-linear-gradient": "^2.6.2", "react-native-mmkv-storage": "^0.8.0", - "react-native-navigation-bar-color": "^2.0.1", + "react-native-navigation-bar-color": "github:stanleyugwu/react-native-navigation-bar-color#stanleyugwu-patch-1", "react-native-safe-area-context": "^4.4.1", "react-native-screens": "^3.17.0", "react-native-select-contact": "^1.6.3", @@ -2762,6 +2765,15 @@ "node": ">= 8" } }, + "node_modules/@react-native-clipboard/clipboard": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.11.1.tgz", + "integrity": "sha512-nvSIIHzybVWqYxcJE5hpT17ekxAAg383Ggzw5WrYHtkKX61N1AwaKSNmXs5xHV7pmKSOe/yWjtSwxIzfW51I5Q==", + "peerDependencies": { + "react": ">=16.0", + "react-native": ">=0.57.0" + } + }, "node_modules/@react-native-community/cli": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-9.3.2.tgz", @@ -4821,6 +4833,11 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==" + }, "node_modules/@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -6791,6 +6808,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha512-W93aKztssqf29OvUlqfikzGyYbD1rpkXvGP9IQ1JchLY3bxaLXZSWYbwrtib2vk8DobrDzX7PIXcDWHp0B6Ymw==" + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -14403,8 +14425,8 @@ }, "node_modules/react-native-navigation-bar-color": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-native-navigation-bar-color/-/react-native-navigation-bar-color-2.0.1.tgz", - "integrity": "sha512-1kE/oxWt+HYjRxdZdvke9tJ365xaee5n3+euOQA1En8zQuSbOxiE4SYEGM7TeaWnmLJ0l37mRnPHaB2H4mGh0A==" + "resolved": "git+ssh://git@github.com/stanleyugwu/react-native-navigation-bar-color.git#e4f5e6bda1dc2d1b919756853f0aa542245de79e", + "license": "MIT" }, "node_modules/react-native-safe-area-context": { "version": "4.4.1", @@ -19402,6 +19424,12 @@ "fastq": "^1.6.0" } }, + "@react-native-clipboard/clipboard": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.11.1.tgz", + "integrity": "sha512-nvSIIHzybVWqYxcJE5hpT17ekxAAg383Ggzw5WrYHtkKX61N1AwaKSNmXs5xHV7pmKSOe/yWjtSwxIzfW51I5Q==", + "requires": {} + }, "@react-native-community/cli": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-9.3.2.tgz", @@ -20882,6 +20910,11 @@ "@babel/types": "^7.3.0" } }, + "@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==" + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -22366,6 +22399,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha512-W93aKztssqf29OvUlqfikzGyYbD1rpkXvGP9IQ1JchLY3bxaLXZSWYbwrtib2vk8DobrDzX7PIXcDWHp0B6Ymw==" + }, "css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -28104,9 +28142,8 @@ "requires": {} }, "react-native-navigation-bar-color": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-native-navigation-bar-color/-/react-native-navigation-bar-color-2.0.1.tgz", - "integrity": "sha512-1kE/oxWt+HYjRxdZdvke9tJ365xaee5n3+euOQA1En8zQuSbOxiE4SYEGM7TeaWnmLJ0l37mRnPHaB2H4mGh0A==" + "version": "git+ssh://git@github.com/stanleyugwu/react-native-navigation-bar-color.git#e4f5e6bda1dc2d1b919756853f0aa542245de79e", + "from": "react-native-navigation-bar-color@github:stanleyugwu/react-native-navigation-bar-color#stanleyugwu-patch-1" }, "react-native-safe-area-context": { "version": "4.4.1", diff --git a/package.json b/package.json index 2e02df0..0ce7bb0 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ }, "dependencies": { "@hookform/resolvers": "^2.9.8", + "@react-native-clipboard/clipboard": "^1.11.1", "@react-navigation/bottom-tabs": "^6.4.0", "@react-navigation/native": "^6.0.13", "@react-navigation/native-stack": "^6.9.0", + "@types/crypto-js": "^4.1.1", "axios": "^1.0.0", + "crypto-js": "^3.1.9-1", "flutterwave-react-native": "^1.0.2", "lottie-react-native": "^5.1.4", "react": "18.1.0", diff --git a/react-app-env.d.ts b/react-app-env.d.ts index baa80eb..fb768f6 100644 --- a/react-app-env.d.ts +++ b/react-app-env.d.ts @@ -55,4 +55,5 @@ declare module '@env' { // CHORE: When new env var are added in .env file, // export the key as const like below export const FLUTTERWAVE_MERCHANT_API_KEY: S; + export const ENCRYPTION_KEY: S; } diff --git a/screens/airtime/airtime.d.ts b/screens/airtime/airtime.d.ts index 9a84afe..2dd8dd6 100644 --- a/screens/airtime/airtime.d.ts +++ b/screens/airtime/airtime.d.ts @@ -4,9 +4,3 @@ export enum Carrier { Glo = 'glo', Etisalat = 'etisalat', } - -export type IncompleteTopUp = { - phone: string; - amount: number; - carrier: Carrier; -}; diff --git a/screens/airtime/index.tsx b/screens/airtime/index.tsx index eeff944..54c8bb7 100644 --- a/screens/airtime/index.tsx +++ b/screens/airtime/index.tsx @@ -1,6 +1,6 @@ //import libraries -import React, {useRef, useState} from 'react'; -import {View} from 'react-native'; +import React, {useRef, useState, useMemo} from 'react'; +import {Keyboard, View} from 'react-native'; import tw from '../../lib/tailwind'; import SafeAreaScrollView from '~components/SafeAreaScrollView'; import AppHeader from '~components/AppHeader'; @@ -9,7 +9,7 @@ import {useForm, useWatch} from 'react-hook-form'; import {yupResolver} from '@hookform/resolvers/yup'; import AirtimeSchema from './airtime.schema'; import {InferType} from 'yup'; -import {Carrier, IncompleteTopUp} from './airtime.d'; +import {Carrier} from './airtime.d'; import Button from '~components/Button'; import SnackBar from '~components/SnackBar'; import getFirstError from '../../utils/getFirstError'; @@ -24,13 +24,10 @@ import WalletBalance from '~components/WalletBalance'; import reduceWalletBalanceBy from '../../utils/reduceWalletBalance'; import balanceIsSufficient from '../../utils/balanceIsSufficient'; import requestInAppReview from '../../utils/requestInAppReview'; -import useInAppUpdate from '../../hooks/useInAppUpdate'; +import FaultyTxModal from '~components/FaultyTxModal'; // Airtime Screen Component const Airtime = () => { - // in-app update - useInAppUpdate(); - /** * Form and validation logics and handles */ @@ -59,34 +56,10 @@ const Airtime = () => { // handles submission after form-level validation const handleBuyAirtime = handleSubmit(data => { + Keyboard.dismiss(); // TODO: create a proper method of checking user login state instead of // by checking the value of profile balance - // here we check if there's an incomplete top-up so that we - // complete it before initialising new transaction - if (incompleteTopUpCache.current) { - const values = incompleteTopUpCache.current; - setLoaderVisible(true); - return airtimeTopUp(values.phone, values.amount, values.carrier) - .then(res => { - // top-up completed. let's clear incomplete top up cache. - // DON'T REMOVE BELOW LINE - incompleteTopUpCache.current = undefined; - setSuccessMsg( - `Previous incomplete top-up of #${ - values.amount + ' ' + values.carrier - } to ${values.phone} completed successfully`, - ); - }) - .catch(error => { - // we'll show error but won't clear incomplete top-up cache - setRequestError( - 'Error completing previous top-up transaction. Check your internet connection and try again', - ); - }) - .finally(() => setLoaderVisible(false)); - } - // show payment methods bottom sheet setPaymentSheetVisible(true); }); @@ -100,15 +73,7 @@ const Airtime = () => { const [paymentSheetVisible, setPaymentSheetVisible] = useState(false); const [loaderVisible, setLoaderVisible] = useState(false); const [successMsg, setSuccessMsg] = useState(); - - /** - * In order to handle faulty transactions, or edge case where user completes - * payment but hits network error when app is about to top-up his/her account, - * this ref will cache details of an incomplete top-up so when user has stable - * connection, the top-up will be completed. - */ - const incompleteTopUpCache = useRef(undefined); - + const [faultyTxRef, setFaultyTxRef] = useState(undefined); /** * This ref will be used to cache form values when flutterwave payment is initialised. * the form values will be used after successful payment, but we don't want to get the values @@ -118,6 +83,18 @@ const Airtime = () => { undefined, ); + // Airtime Tx ref + const TX_REF = useMemo( + () => + `service=airtime;amt=${getValues('amount')};carrier=${getValues( + 'carrier', + )};phone=${getValues('phoneNumber')};dt=${Date.now()}`, + [ + /* when the value of any form field changes we regenrate tx_ref*/ + Object.values(getValues()).join(''), + ], + ); + // Handles wallet payment const handleWalletPaymentMethod = async () => { const values = getValues(); @@ -179,19 +156,27 @@ const Airtime = () => { setSuccessMsg(res.message); // show success overlay }) .catch(error => { - // TODO: find a better way to prevent faulty transaction or incomplete top-up + /** + * The most secured approach to faulty tx: + * when tx is faulty, try and encrypt and store tx_ref & top-up details in storage. + * if storage failed, show the user the tx_ref so to resolve the issue with customer care. + * customer care would verify tx_ref, release top-up to user, then store tx_ref in resolved tx db + * if storage succeeds, an error is shown to user and a 'retry-previous-tx' button appears on the + * service screen e.g airtime screen. The visibilty of the btn depends on the existence of the faulty tx + * storage in device. When btn is pressed, app retrieves and decrypts stored faulty tx details, verifies tx_ref, + * retrieves top-up amount from verified tx, releases top-up value, stores the tx_ref in resolved tx db, and + * deletes the faulty tx from storage. + * + * Another approach would be to embed every top-up detail e.g amount, service, even tx_id into the tx_ref and encode/encrypt it. + * The user can present the encrypted/encoded text to customer care and customer care will know the details of the + * tx and be able to confirm it. Or the app can take the encrypted/emcoded tx_ref, decode it and know what value to release + * to user + * + * However, the feasible approach now is to resolve issue using tx_ref through customer care + */ - // Edge case. Error when hitting server. we need make sure top-up is - // completed cus user has completed payment. lets cache the top-up details - // for later and show error. - setRequestError( - `Payment was successful but top-up failed. Connect to internet now and press 'Buy Airtime' button to complete your transaction. Don't close the app, and stay on this screen until top-up completes`, - ); - incompleteTopUpCache.current = { - amount: values.amount, - phone: values.phoneNumber, - carrier: values.carrier as Carrier, - }; + // show faulty tx view + setFaultyTxRef(data.tx_ref); }) .finally(() => { setPaymentSheetVisible(false); @@ -232,6 +217,7 @@ const Airtime = () => { value={formValues[0] ? `${formValues[0]}` : undefined} keyboardType="number-pad" onChangeText={value => setValue('amount', +value)} + onSubmitEditing={handleBuyAirtime} />