diff --git a/package-lock.json b/package-lock.json index d8d20c3..d80df58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-dom": "18.2.0", "react-i18next": "13.2.2", "react-native": "0.73.2", + "react-native-animated-bottom-drawer": "0.0.23", "react-native-animated-linear-gradient": "1.3.0", "react-native-bars": "2.3.0", "react-native-blur-effect": "1.1.3", @@ -52,6 +53,7 @@ "react-native-dotenv": "3.4.9", "react-native-linear-gradient": "2.8.3", "react-native-nfc-manager": "3.14.11", + "react-native-pin-view": "3.0.3", "react-native-progress": "5.0.1", "react-native-qrcode-svg": "^6.3.0", "react-native-safe-area-context": "4.7.2", @@ -24671,6 +24673,16 @@ "react": "18.2.0" } }, + "node_modules/react-native-animated-bottom-drawer": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/react-native-animated-bottom-drawer/-/react-native-animated-bottom-drawer-0.0.23.tgz", + "integrity": "sha512-xOYOWxNViNno9wtYtvDpW90M5N4RDgdLq2jRjR9lxTAcpKsl93C7oex/NANQzd5aC9Vh7SEnyh0DlbSqprB6kg==", + "license": "ISC", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-animated-linear-gradient": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-native-animated-linear-gradient/-/react-native-animated-linear-gradient-1.3.0.tgz", @@ -24848,6 +24860,15 @@ "@expo/config-plugins": "~7.2.5" } }, + "node_modules/react-native-pin-view": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/react-native-pin-view/-/react-native-pin-view-3.0.3.tgz", + "integrity": "sha512-0xCQBpwrP06wDjUlfwViFMzi1ph+9Y1ytYWkGtdIcy9XcR5RK94DOGDisNUb8LrvHjVIoeuoDIkYPVcvv43GGA==", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-progress": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-native-progress/-/react-native-progress-5.0.1.tgz", diff --git a/package.json b/package.json index b14076e..7445a2c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-dom": "18.2.0", "react-i18next": "13.2.2", "react-native": "0.73.2", + "react-native-animated-bottom-drawer": "0.0.23", "react-native-animated-linear-gradient": "1.3.0", "react-native-bars": "2.3.0", "react-native-blur-effect": "1.1.3", @@ -58,6 +59,7 @@ "react-native-dotenv": "3.4.9", "react-native-linear-gradient": "2.8.3", "react-native-nfc-manager": "3.14.11", + "react-native-pin-view": "3.0.3", "react-native-progress": "5.0.1", "react-native-qrcode-svg": "^6.3.0", "react-native-safe-area-context": "4.7.2", diff --git a/src/components/PinPad/PinPad.tsx b/src/components/PinPad/PinPad.tsx new file mode 100644 index 0000000..fece970 --- /dev/null +++ b/src/components/PinPad/PinPad.tsx @@ -0,0 +1,79 @@ +import { useRef, useState, useEffect } from 'react'; +import ReactNativePinView from "react-native-pin-view"; +import BottomDrawer, { + BottomDrawerMethods, +} from 'react-native-animated-bottom-drawer'; +import { Icon } from "@components"; +import { faDeleteLeft } from "@fortawesome/free-solid-svg-icons"; +import * as S from "./styled"; + +type PinViewFunctions = { + clearAll: () => void; +}; + +type PinPadProps = { + onPinEntered: (pin: string) => void; +} + +const LeftButton = () => ; + +export const PinPad = (props: PinPadProps) => { + const pinView = useRef(null); + const bottomDrawerRef = useRef(null); + const [showRemoveButton, setShowRemoveButton] = useState(false); + const [enteredPin, setEnteredPin] = useState(""); + const [buttonPressed, setButtonPressed] = useState(""); + + useEffect(() => { + if (enteredPin.length > 0) { + setShowRemoveButton(true) + } else { + setShowRemoveButton(false) + } + if (enteredPin.length === 4) { + props.onPinEntered(enteredPin) + } + }, [enteredPin]); + + useEffect(() => { + if (buttonPressed === "custom_left" && pinView.current) { + pinView.current.clearAll() + } + }, [buttonPressed]); + + return ( + <> + + + + Boltcard PIN + + setEnteredPin(value)} + buttonAreaStyle={{ marginTop: 24 }} + inputAreaStyle={{ marginBottom: 24 }} + inputViewEmptyStyle={S.PinViewStyles.whiteBorderTransparent} + inputViewFilledStyle={{ backgroundColor: "#FFF" }} + buttonViewStyle={S.PinViewStyles.whiteBorder} + buttonTextStyle={{ color: "#FFF" }} + onButtonPress={key => setButtonPressed(key)} + // @ts-ignore + customLeftButton={showRemoveButton ? : undefined} + /> + + + + ); +} \ No newline at end of file diff --git a/src/components/PinPad/index.ts b/src/components/PinPad/index.ts new file mode 100644 index 0000000..3be94e5 --- /dev/null +++ b/src/components/PinPad/index.ts @@ -0,0 +1 @@ +export { PinPad } from "./PinPad"; diff --git a/src/components/PinPad/styled.ts b/src/components/PinPad/styled.ts new file mode 100644 index 0000000..a05454a --- /dev/null +++ b/src/components/PinPad/styled.ts @@ -0,0 +1,37 @@ +import styled from "styled-components"; +import { View, Text } from "@components"; +import { StyleSheet } from "react-native"; + +export const PinPadContainer = styled(View)` + backgroundColor: transparent; + alignItems: center; + flex: 1; +`; + +export const PinPadTitle = styled(Text)` + paddingTop: 48px; + paddingBottom: 24px; + color: rgba(255,255,255,1); + fontSize: 48px; +`; + +export const BottomDrawerStyles = StyleSheet.create({ + container: { + backgroundColor: "rgba(0,0,0,0.5)", + }, + handleContainer: { + backgroundColor: "transparent", + } +}); + +export const PinViewStyles = StyleSheet.create({ + whiteBorder: { + borderWidth: 1, + borderColor: "#FFF", + }, + whiteBorderTransparent: { + borderWidth: 1, + borderColor: "#FFF", + backgroundColor: "transparent" + }, +}); \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index 59dbaf9..086f6f8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -39,3 +39,4 @@ export { SplashScreen } from "./SplashScreen"; export { StatusBar } from "./StatusBar"; export { CircleProgress } from "./CircleProgress"; export { BitcoinIcon } from "./BitcoinIcon"; +export { PinPad } from "./PinPad"; diff --git a/src/hooks/useNfc.ts b/src/hooks/useNfc.ts index eb78537..54ccf4b 100644 --- a/src/hooks/useNfc.ts +++ b/src/hooks/useNfc.ts @@ -17,6 +17,31 @@ export const useNfc = () => { const [isNfcNeedsTap, setIsNfcNeedsTap] = useState(false); const [isNfcNeedsPermission, setIsNfcNeedsPermission] = useState(false); const [isNfcActionSuccess, setIsNfcActionSuccess] = useState(false); + const [isPinRequired, setIsPinRequired] = useState(false); + const [isPinConfirmed, setIsPinConfirmed] = useState(false); + + const [ pinResolver, setPinResolver ] = useState<{ + resolve: (v: string) => void; + }>(); + + const setPin = (pin: string) => { + if(pinResolver?.resolve) { + pinResolver.resolve(pin); + } + } + + const getPin = useCallback(async () => { + const pinInputPromise = () => { + let _pinResolver; + return [ new Promise(( resolve ) => { + _pinResolver = resolve + }), _pinResolver] + } + + const [ promise, resolve ] = pinInputPromise() + setPinResolver({ resolve } as unknown as { resolve: (v: string) => void }) + return promise as Promise; + }, []); const setupNfc = useCallback(async () => { if (await getIsNfcSupported()) { @@ -49,7 +74,8 @@ export const useNfc = () => { }, []); const readingNfcLoop = useCallback( - async (pr: string) => { + async (pr: string, amount?: number | null) => { + setIsNfcActionSuccess(false); await NFC.stopRead(); @@ -73,7 +99,7 @@ export const useNfc = () => { }); if (!isIos) { - readingNfcLoop(pr); + readingNfcLoop(pr, amount); } else { setIsNfcAvailable(false); } @@ -114,19 +140,41 @@ export const useNfc = () => { tag: "withdrawRequest"; callback: string; k1: string; + pinLimit?: number; }>(cardData); - const { data: callbackResponseData } = await axios.get<{ - reason: { detail: string }; - status: "OK" | "ERROR"; - }>(cardDataResponse.callback, { - params: { - k1: cardDataResponse.k1, - pr + let pin = ""; + if (cardDataResponse.pinLimit !== undefined) { + + if (!amount) { + error = { reason: "No amount set. Can't make withdrawRequest"} + } else { + //if the card has pin enabled + //check the amount didn't exceed the limit + const limitSat = cardDataResponse.pinLimit; + if (limitSat <= amount) { + setIsPinRequired(true); + pin = await getPin(); + setIsPinConfirmed(true); + } } - }); + } else { + setIsPinRequired(false); + } - debitCardData = callbackResponseData; + if (!error) { + const { data: callbackResponseData } = await axios.get<{ + reason: { detail: string }; + status: "OK" | "ERROR"; + }>(cardDataResponse.callback, { + params: { + k1: cardDataResponse.k1, + pr, + pin + } + }) + debitCardData = callbackResponseData; + } } else { // const { data: cardRequest } = await axios.get<{ payLink?: string }>( // lnHttpsRequest @@ -190,9 +238,14 @@ export const useNfc = () => { await NFC.stopRead(); setIsNfcLoading(false); + setIsPinConfirmed(false); + setIsPinRequired(false); if (debitCardData?.status === "OK" && !error) { setIsNfcActionSuccess(true); } else { + if (debitCardData?.status === "ERROR" && !error) { + error = debitCardData; + } toast.show( typeof error?.reason === "string" ? error.reason @@ -203,7 +256,7 @@ export const useNfc = () => { ); if (!isIos) { - readingNfcLoop(pr); + readingNfcLoop(pr, amount); } } @@ -213,7 +266,7 @@ export const useNfc = () => { } }); }, - [toast, t] + [toast, t, getPin] ); const stopNfc = useCallback(() => { @@ -233,6 +286,9 @@ export const useNfc = () => { isNfcNeedsTap, isNfcNeedsPermission, isNfcActionSuccess, + isPinRequired, + isPinConfirmed, + setPin, setupNfc, stopNfc, readingNfcLoop diff --git a/src/screens/Invoice/Invoice.tsx b/src/screens/Invoice/Invoice.tsx index e8dc10e..0cad627 100644 --- a/src/screens/Invoice/Invoice.tsx +++ b/src/screens/Invoice/Invoice.tsx @@ -6,7 +6,8 @@ import { CheckboxField, ComponentStack, Loader, - Text + Text, + PinPad } from "@components"; import { faBolt, @@ -61,7 +62,10 @@ export const Invoice = () => { isNfcScanning, isNfcLoading, isNfcNeedsTap, - isNfcActionSuccess + isNfcActionSuccess, + isPinRequired, + isPinConfirmed, + setPin } = useNfc(); const { @@ -98,9 +102,9 @@ export const Invoice = () => { useEffect(() => { if (isNfcAvailable && !isNfcNeedsTap && lightningInvoice) { - void readingNfcLoop(lightningInvoice); + void readingNfcLoop(lightningInvoice, satoshis); } - }, [readingNfcLoop, isNfcAvailable, isNfcNeedsTap, lightningInvoice]); + }, [readingNfcLoop, isNfcAvailable, isNfcNeedsTap, lightningInvoice, satoshis]); useEffect(() => { if (isNfcActionSuccess) { @@ -164,7 +168,7 @@ export const Invoice = () => { type: "bitcoin", title: t("startScanning"), onPress: () => { - void readingNfcLoop(lightningInvoice); + void readingNfcLoop(lightningInvoice, satoshis); } } } @@ -280,6 +284,8 @@ export const Invoice = () => { onPress={onReturnToHome} /> + ) : isPinRequired && !isPinConfirmed ? ( + ) : isNfcLoading || isNfcScanning ? (