From 33921563b24dc92542d04748709da221b24ab34e Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 27 Sep 2024 14:14:45 +0200 Subject: [PATCH] feat(ui): add UI for orange ticket edge cases --- src/navigation/bottom-sheet/BottomSheets.tsx | 10 +- .../bottom-sheet/OrangeTicketNavigation.tsx | 185 +++++++++++++ src/navigation/types/index.ts | 5 + src/screens/OrangeTicket.tsx | 246 ------------------ src/screens/OrangeTicket/Error.tsx | 101 +++++++ src/screens/OrangeTicket/Prize.tsx | 169 ++++++++++++ src/screens/OrangeTicket/UsedCard.tsx | 94 +++++++ src/store/index.ts | 2 +- src/store/migrations/index.ts | 9 + src/store/shapes/settings.ts | 1 + src/store/slices/settings.ts | 5 + 11 files changed, 575 insertions(+), 252 deletions(-) create mode 100644 src/navigation/bottom-sheet/OrangeTicketNavigation.tsx delete mode 100644 src/screens/OrangeTicket.tsx create mode 100644 src/screens/OrangeTicket/Error.tsx create mode 100644 src/screens/OrangeTicket/Prize.tsx create mode 100644 src/screens/OrangeTicket/UsedCard.tsx diff --git a/src/navigation/bottom-sheet/BottomSheets.tsx b/src/navigation/bottom-sheet/BottomSheets.tsx index e9ec78a98..9876cf63c 100644 --- a/src/navigation/bottom-sheet/BottomSheets.tsx +++ b/src/navigation/bottom-sheet/BottomSheets.tsx @@ -9,12 +9,12 @@ import ConnectionClosed from './ConnectionClosed'; import ForceTransfer from './ForceTransfer'; import LNURLWithdrawNavigation from './LNURLWithdrawNavigation'; import NewTxPrompt from '../../screens/Wallets/NewTxPrompt'; -import OrangeTicket from '../../screens/OrangeTicket'; +import OrangeTicketNavigation from './OrangeTicketNavigation'; import PINNavigation from './PINNavigation'; import ReceiveNavigation from './ReceiveNavigation'; import SendNavigation from './SendNavigation'; -import TreasureHuntNavigation from './TreasureHuntNavigation'; -import PubkyAuth from './PubkyAuth.tsx'; +// import TreasureHuntNavigation from './TreasureHuntNavigation'; +import PubkyAuth from './PubkyAuth'; const BottomSheets = (): JSX.Element => { const views = useAppSelector(viewControllersSelector); @@ -27,11 +27,11 @@ const BottomSheets = (): JSX.Element => { {views.forceTransfer.isMounted && } {views.lnurlWithdraw.isMounted && } {views.newTxPrompt.isMounted && } - {views.orangeTicket.isMounted && } + {views.orangeTicket.isMounted && } {views.PINNavigation.isMounted && } {views.receiveNavigation.isMounted && } {views.sendNavigation.isMounted && } - {views.treasureHunt.isMounted && } + {/* {views.treasureHunt.isMounted && } */} {views.pubkyAuth.isMounted && } ); diff --git a/src/navigation/bottom-sheet/OrangeTicketNavigation.tsx b/src/navigation/bottom-sheet/OrangeTicketNavigation.tsx new file mode 100644 index 000000000..1ff2defbe --- /dev/null +++ b/src/navigation/bottom-sheet/OrangeTicketNavigation.tsx @@ -0,0 +1,185 @@ +import React, { + memo, + ReactElement, + useCallback, + useEffect, + useState, +} from 'react'; +import { ldk } from '@synonymdev/react-native-ldk'; +import { + createNativeStackNavigator, + NativeStackNavigationProp, + NativeStackNavigationOptions, +} from '@react-navigation/native-stack'; + +import { NavigationContainer } from '../../styles/components'; +import Prize from '../../screens/OrangeTicket/Prize'; +import UsedCard from '../../screens/OrangeTicket/UsedCard'; +import Error from '../../screens/OrangeTicket/Error'; + +import BottomSheetWrapper from '../../components/BottomSheetWrapper'; +import { useAppSelector } from '../../hooks/redux'; +import { + useBottomSheetBackPress, + useSnapPoints, +} from '../../hooks/bottomSheet'; +import { showToast } from '../../utils/notifications'; +import { getNodeId, waitForLdk } from '../../utils/lightning'; +import { viewControllerSelector } from '../../store/reselect/ui'; +import { __TREASURE_HUNT_HOST__ } from '../../constants/env'; + +export type OrangeTicketNavigationProp = + NativeStackNavigationProp; + +export type OrangeTicketStackParamList = { + Prize: { ticketId: string; amount: number }; + UsedCard: { amount: number }; + Error: { errorCode: number }; +}; + +const Stack = createNativeStackNavigator(); + +const screenOptions: NativeStackNavigationOptions = { + presentation: 'transparentModal', + headerShown: false, +}; + +const OrangeTicket = (): ReactElement => { + const snapPoints = useSnapPoints('large'); + const [isLoading, setIsLoading] = useState(true); + const [amount, setAmount] = useState(); + const [errorCode, setErrorCode] = useState(); + const orangeTickets = useAppSelector((state) => state.settings.orangeTickets); + const [initialScreen, setInitialScreen] = + useState('Prize'); + const { isOpen, ticketId } = useAppSelector((state) => { + return viewControllerSelector(state, 'orangeTicket'); + }); + + useBottomSheetBackPress('orangeTicket'); + + const getPrize = useCallback(async (): Promise => { + const getChest = async (): Promise => { + const response = await fetch(__TREASURE_HUNT_HOST__, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method: 'getChest', + params: { input: { chestId: ticketId } }, + }), + }); + + const { result } = await response.json(); + return result; + }; + + const openChest = async (): Promise => { + await waitForLdk(); + + const nodeId = await getNodeId(); + const nodePublicKey = nodeId.isOk() ? nodeId.value : ''; + const input = { chestId: ticketId, nodePublicKey }; + const signResult = await ldk.nodeSign({ + message: JSON.stringify(input), + messagePrefix: '', + }); + if (signResult.isErr()) { + showToast({ + type: 'error', + title: 'Failed to get prize', + description: 'Bitkit could not sign your claim request.', + }); + return; + } + const signature = signResult.value; + + const response = await fetch(__TREASURE_HUNT_HOST__, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + method: 'openChest', + params: { input, signature }, + }), + }); + + const { result } = await response.json(); + return result; + }; + + if (!ticketId) { + return; + } + + const chestResponse = await getChest(); + if (chestResponse.error) { + setErrorCode(chestResponse.code); + setInitialScreen('Error'); + setIsLoading(false); + return; + } + setAmount(chestResponse.amountSat); + + // Check if the ticket has already been used + if (orangeTickets.includes(ticketId)) { + setInitialScreen('UsedCard'); + setIsLoading(false); + return; + } + + const openResponse = await openChest(); + if (openResponse.error) { + if (openResponse.code === 5000) { + setInitialScreen('UsedCard'); + } else { + setErrorCode(openResponse.code); + setInitialScreen('Error'); + } + } + setIsLoading(false); + setAmount(openResponse.amountSat); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticketId]); + + useEffect(() => { + if (!isOpen) { + setInitialScreen('Prize'); + setIsLoading(true); + return; + } + + getPrize(); + }, [isOpen, getPrize]); + + if (isLoading) { + return <>; + } + + return ( + + + + + + + + + + ); +}; + +export default memo(OrangeTicket); diff --git a/src/navigation/types/index.ts b/src/navigation/types/index.ts index 0ec861794..2659714a2 100644 --- a/src/navigation/types/index.ts +++ b/src/navigation/types/index.ts @@ -20,6 +20,7 @@ import type { ProfileLinkStackParamList } from '../bottom-sheet/ProfileLinkNavig import type { ReceiveStackParamList } from '../bottom-sheet/ReceiveNavigation'; import type { SendStackParamList } from '../bottom-sheet/SendNavigation'; import type { LNURLWithdrawStackParamList } from '../bottom-sheet/LNURLWithdrawNavigation'; +import type { OrangeTicketStackParamList } from '../bottom-sheet/OrangeTicketNavigation'; import type { TreasureHuntStackParamList } from '../bottom-sheet/TreasureHuntNavigation'; // TODO: move all navigation related types here @@ -103,6 +104,10 @@ export type SendScreenProps = export type LNURLWithdrawProps = NativeStackScreenProps; +export type OrangeTicketScreenProps< + T extends keyof OrangeTicketStackParamList, +> = NativeStackScreenProps; + export type TreasureHuntScreenProps< T extends keyof TreasureHuntStackParamList, > = NativeStackScreenProps; diff --git a/src/screens/OrangeTicket.tsx b/src/screens/OrangeTicket.tsx deleted file mode 100644 index 8eceeb3d7..000000000 --- a/src/screens/OrangeTicket.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import React, { - memo, - ReactElement, - useCallback, - useEffect, - useState, -} from 'react'; -import { ActivityIndicator, Image, StyleSheet, View } from 'react-native'; -import { ldk } from '@synonymdev/react-native-ldk'; - -import { BodyM } from '../styles/text'; -import AmountToggle from '../components/AmountToggle'; -import SafeAreaInset from '../components/SafeAreaInset'; -import BottomSheetWrapper from '../components/BottomSheetWrapper'; -import BottomSheetNavigationHeader from '../components/BottomSheetNavigationHeader'; -import { useAppSelector } from '../hooks/redux'; -import { useLightningMaxInboundCapacity } from '../hooks/lightning'; -import { useBottomSheetBackPress, useSnapPoints } from '../hooks/bottomSheet'; -import { showToast } from '../utils/notifications'; -import { - getNodeId, - getNodeIdFromStorage, - waitForLdk, -} from '../utils/lightning'; -import { viewControllerSelector } from '../store/reselect/ui'; -import { createLightningInvoice } from '../store/utils/lightning'; -import { __TREASURE_HUNT_HOST__ } from '../constants/env'; - -const imageSrc = require('../assets/illustrations/bitcoin-emboss.png'); - -const OrangeTicket = (): ReactElement => { - const snapPoints = useSnapPoints('large'); - const maxInboundCapacitySat = useLightningMaxInboundCapacity(); - const [isLoading, setIsLoading] = useState(true); - const [amount, setAmount] = useState(); - const { isOpen, ticketId } = useAppSelector((state) => { - return viewControllerSelector(state, 'orangeTicket'); - }); - - useBottomSheetBackPress('orangeTicket'); - - const getPrize = useCallback(async (): Promise => { - const getLightningInvoice = async (): Promise => { - const response = await createLightningInvoice({ - amountSats: 0, - description: 'Orange Ticket', - expiryDeltaSeconds: 3600, - }); - - if (response.isErr()) { - showToast({ - type: 'error', - title: 'Failed to create invoice', - description: 'Bitkit could not prepare your claim.', - }); - return ''; - } - - return response.value.to_str; - }; - - const getChest = async (): Promise => { - const response = await fetch(__TREASURE_HUNT_HOST__, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - method: 'getChest', - params: { input: { chestId: ticketId } }, - }), - }); - - const { result } = await response.json(); - return result; - }; - - const openChest = async (): Promise => { - await waitForLdk(); - - const nodeId = await getNodeId(); - const nodePublicKey = nodeId.isOk() ? nodeId.value : ''; - const input = { chestId: ticketId, nodePublicKey }; - const signResult = await ldk.nodeSign({ - message: JSON.stringify(input), - messagePrefix: '', - }); - if (signResult.isErr()) { - showToast({ - type: 'error', - title: 'Failed to get prize', - description: 'Bitkit could not sign your claim request.', - }); - return; - } - const signature = signResult.value; - - const response = await fetch(__TREASURE_HUNT_HOST__, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - method: 'openChest', - params: { input, signature }, - }), - }); - - const { result } = await response.json(); - return result; - }; - - const claimPrize = async (): Promise => { - const invoice = await getLightningInvoice(); - const nodePublicKey = getNodeIdFromStorage(); - - if (invoice) { - const input = { - chestId: ticketId, - invoice, - maxInboundCapacitySat, - nodePublicKey, - }; - const signResult = await ldk.nodeSign({ - message: JSON.stringify(input), - messagePrefix: '', - }); - if (signResult.isErr()) { - showToast({ - type: 'error', - title: 'Failed to get prize', - description: 'Bitkit could not sign your claim request.', - }); - return; - } - const signature = signResult.value; - - const response = await fetch(__TREASURE_HUNT_HOST__, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - method: 'grabTreasure', - params: { input, signature }, - }), - }); - - const { result } = await response.json(); - return result; - } - }; - - if (!ticketId) { - return; - } - - const chestResponse = await getChest(); - if (chestResponse.error) { - return; - } - setAmount(chestResponse.amountSat); - - const openResponse = await openChest(); - if (openResponse.error) { - return; - } - setIsLoading(false); - setAmount(openResponse.amountSat); - - const claimResponse = await claimPrize(); - if (claimResponse.error) { - return; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ticketId]); - - useEffect(() => { - if (!isOpen) { - setIsLoading(true); - return; - } - - getPrize(); - }, [isOpen, getPrize]); - - if (isLoading) { - return <>; - } - - return ( - - - - - - {amount && } - - - You've just won some Bitcoin! Your coins will arrive in ±30 seconds. - Please wait. - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - content: { - flex: 1, - paddingHorizontal: 16, - }, - text: { - marginTop: 32, - }, - imageContainer: { - flexShrink: 1, - justifyContent: 'center', - alignItems: 'center', - alignSelf: 'center', - width: 256, - aspectRatio: 1, - marginTop: 'auto', - }, - image: { - flex: 1, - resizeMode: 'contain', - }, - footer: { - marginTop: 'auto', - marginBottom: 16, - width: '100%', - }, -}); - -export default memo(OrangeTicket); diff --git a/src/screens/OrangeTicket/Error.tsx b/src/screens/OrangeTicket/Error.tsx new file mode 100644 index 000000000..602e9bec9 --- /dev/null +++ b/src/screens/OrangeTicket/Error.tsx @@ -0,0 +1,101 @@ +import React, { ReactElement, memo } from 'react'; +import { Image, StyleSheet, View } from 'react-native'; + +import { BodyM } from '../../styles/text'; +import Button from '../../components/buttons/Button'; +import GradientView from '../../components/GradientView'; +import SafeAreaInset from '../../components/SafeAreaInset'; +import BottomSheetNavigationHeader from '../../components/BottomSheetNavigationHeader'; +import { useAppDispatch } from '../../hooks/redux'; +import { closeSheet } from '../../store/slices/ui'; +import type { OrangeTicketScreenProps } from '../../navigation/types'; + +const imageSrc = require('../../assets/illustrations/exclamation-mark.png'); + +const getText = (errorCode: number): { title: string; text: string } => { + switch (errorCode) { + case 5002: + return { + title: 'Card Not Found', + text: 'This Bitkit card was not found. Please contact support.', + }; + case 5005: + return { + title: 'Other Card', + text: 'You have already used a different Bitkit card, and those funds have been paid out to your wallet. You can only use one card.', + }; + default: + return { + title: 'Card Error', + text: "Bitkit couldn't claim the funds. Please try again later or visit us at our booth.", + }; + } +}; + +const Error = ({ route }: OrangeTicketScreenProps<'Error'>): ReactElement => { + const dispatch = useAppDispatch(); + const { errorCode } = route.params; + const { title, text } = getText(errorCode); + + const onContinue = (): void => { + dispatch(closeSheet('orangeTicket')); + }; + + return ( + + + + + {text} + + + + + + +