diff --git a/apps/wallet-mobile/.storybook/storybook.requires.js b/apps/wallet-mobile/.storybook/storybook.requires.js index 84ff74a253..d2c35fc4b2 100644 --- a/apps/wallet-mobile/.storybook/storybook.requires.js +++ b/apps/wallet-mobile/.storybook/storybook.requires.js @@ -86,9 +86,11 @@ const getStories = () => { "./src/components/LanguagePicker/LanguagePickerWarning.stories.tsx": require("../src/components/LanguagePicker/LanguagePickerWarning.stories.tsx"), "./src/components/Link/Link.stories.tsx": require("../src/components/Link/Link.stories.tsx"), "./src/components/LoadingOverlay/LoadingOverlay.stories.tsx": require("../src/components/LoadingOverlay/LoadingOverlay.stories.tsx"), + "./src/components/ModalScreenWrapper/ModalScreenWrapper.stories.tsx": require("../src/components/ModalScreenWrapper/ModalScreenWrapper.stories.tsx"), "./src/components/NftImageGallery/NftImageGallery.stories.tsx": require("../src/components/NftImageGallery/NftImageGallery.stories.tsx"), "./src/components/NftPreview/NftPreview.stories.tsx": require("../src/components/NftPreview/NftPreview.stories.tsx"), "./src/components/PairedBalance/PairedBalance.stories.tsx": require("../src/components/PairedBalance/PairedBalance.stories.tsx"), + "./src/components/PressableIcon/PressableIcon.stories.tsx": require("../src/components/PressableIcon/PressableIcon.stories.tsx"), "./src/components/StandardModal/StandardModal.stories.tsx": require("../src/components/StandardModal/StandardModal.stories.tsx"), "./src/components/TextInput/TextInput.stories.tsx": require("../src/components/TextInput/TextInput.stories.tsx"), "./src/components/TokenIcon/ModeratedNftIcon.stories.tsx": require("../src/components/TokenIcon/ModeratedNftIcon.stories.tsx"), @@ -101,12 +103,16 @@ const getStories = () => { "./src/Dashboard/WithdrawStakingRewards/ConfirmTx/ConfirmTxWithPassword.stories.tsx": require("../src/Dashboard/WithdrawStakingRewards/ConfirmTx/ConfirmTxWithPassword.stories.tsx"), "./src/Dashboard/WithdrawStakingRewards/TransferSummary/TransferSummary.stories.tsx": require("../src/Dashboard/WithdrawStakingRewards/TransferSummary/TransferSummary.stories.tsx"), "./src/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.stories.tsx": require("../src/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.stories.tsx"), + "./src/features/Claim/illustrations/Ilustrations.stories.tsx": require("../src/features/Claim/illustrations/Ilustrations.stories.tsx"), + "./src/features/Claim/useCases/AskConfirmation.stories.tsx": require("../src/features/Claim/useCases/AskConfirmation.stories.tsx"), + "./src/features/Claim/useCases/ShowSuccessScreen.stories.tsx": require("../src/features/Claim/useCases/ShowSuccessScreen.stories.tsx"), "./src/features/Initialization/AnalyticsChangedScreen/AnalyticsChangedScreen.stories.tsx": require("../src/features/Initialization/AnalyticsChangedScreen/AnalyticsChangedScreen.stories.tsx"), "./src/features/Initialization/InitialScreen/InitialScreen.stories.tsx": require("../src/features/Initialization/InitialScreen/InitialScreen.stories.tsx"), "./src/features/Initialization/LanguagePickerScreen/LanguagePickerScreen.stories.tsx": require("../src/features/Initialization/LanguagePickerScreen/LanguagePickerScreen.stories.tsx"), "./src/features/Initialization/TermsOfServiceChangedScreen/TermsOfServiceChangedScreen.stories.tsx": require("../src/features/Initialization/TermsOfServiceChangedScreen/TermsOfServiceChangedScreen.stories.tsx"), "./src/features/Menu/Menu.stories.tsx": require("../src/features/Menu/Menu.stories.tsx"), "./src/features/Scan/common/CodeScannerButton.stories.tsx": require("../src/features/Scan/common/CodeScannerButton.stories.tsx"), + "./src/features/Scan/illustrations/Ilustrations.stories.tsx": require("../src/features/Scan/illustrations/Ilustrations.stories.tsx"), "./src/features/Scan/useCases/ScanCodeScreen.stories.tsx": require("../src/features/Scan/useCases/ScanCodeScreen.stories.tsx"), "./src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/OpenDeviceAppSettingsButton.stories.tsx": require("../src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/OpenDeviceAppSettingsButton.stories.tsx"), "./src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen.stories.tsx": require("../src/features/Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen.stories.tsx"), diff --git a/apps/wallet-mobile/src/TxHistory/TxHistoryNavigator.tsx b/apps/wallet-mobile/src/TxHistory/TxHistoryNavigator.tsx index 8c2407a465..6ed7a9fe92 100644 --- a/apps/wallet-mobile/src/TxHistory/TxHistoryNavigator.tsx +++ b/apps/wallet-mobile/src/TxHistory/TxHistoryNavigator.tsx @@ -14,6 +14,9 @@ import {defineMessages, useIntl} from 'react-intl' import {StyleSheet, Text, TouchableOpacity, TouchableOpacityProps, View, ViewProps} from 'react-native' import {Boundary, Icon, Spacer} from '../components' +import {claimApiMaker} from '../features/Claim/module/api' +import {ClaimProvider} from '../features/Claim/module/ClaimProvider' +import {ShowSuccessScreen} from '../features/Claim/useCases/ShowSuccessScreen' import {CodeScannerButton} from '../features/Scan/common/CodeScannerButton' import {ScanCodeScreen} from '../features/Scan/useCases/ScanCodeScreen' import {ShowCameraPermissionDeniedScreen} from '../features/Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen' @@ -80,216 +83,232 @@ export const TxHistoryNavigator = () => { return swapManagerMaker({swapStorage, swapApi, frontendFeeTiers, aggregator, aggregatorTokenId}) }, [wallet.networkId, wallet.primaryTokenInfo.id, stakingKey, frontendFees, aggregatorTokenId]) + // claim + const claimApi = React.useMemo(() => { + return claimApiMaker({ + address: wallet.externalAddresses[0], + primaryTokenId: wallet.primaryTokenInfo.id, + }) + }, [wallet.externalAddresses, wallet.primaryTokenInfo.id]) + // navigator components const headerRightHistory = React.useCallback(() => , []) return ( - - - - - - {() => ( - - - - )} - - - , - headerStyle: { - elevation: 0, - shadowOpacity: 0, - backgroundColor: '#fff', - }, - }} - /> - - - - - - - - - - - - - - - - - - - {() => ( - - - - )} - - - - {() => ( - - - - )} - - - + + - {() => ( - - - - )} - - - - {() => ( - - - - )} - - - - - - - - - , - }} - /> - - - - - - {strings.receiveInfoText} - - + + + + {() => ( + + + + )} + + + , + headerStyle: { + elevation: 0, + shadowOpacity: 0, + backgroundColor: '#fff', + }, + }} + /> + + + + + + + + + + + + + + + + + + + {() => ( + + + + )} + + + + {() => ( + + + + )} + + + + {() => ( + + + + )} + + + + {() => ( + + + + )} + + + + + + + + + , + }} + /> + + + + + + + + {strings.receiveInfoText} + + + ) @@ -359,6 +378,10 @@ const messages = defineMessages({ id: 'scan.title', defaultMessage: '!!!Please scan a QR code', }, + claimShowSuccessTitle: { + id: 'claim.showSuccess.title', + defaultMessage: '!!!Success', + }, }) const useStrings = () => { @@ -380,6 +403,7 @@ const useStrings = () => { listAmountsToSendTitle: intl.formatMessage(messages.listAmountsToSendTitle), confirmationTransaction: intl.formatMessage(messages.confirmationTransaction), scanTitle: intl.formatMessage(messages.scanTitle), + claimShowSuccess: intl.formatMessage(messages.claimShowSuccessTitle), } } diff --git a/apps/wallet-mobile/src/api/useApiErrorResolver.tsx b/apps/wallet-mobile/src/api/useApiErrorResolver.tsx new file mode 100644 index 0000000000..a0c6b67651 --- /dev/null +++ b/apps/wallet-mobile/src/api/useApiErrorResolver.tsx @@ -0,0 +1,27 @@ +import {Api} from '@yoroi/types' + +import {useDialogsApi} from './useDialogsApi' + +export const useApiErrorResolver = () => { + const dialogs = useDialogsApi() + + const resolver = (error: unknown) => { + if (error instanceof Api.Errors.BadRequest) return dialogs.errorBadRequest + if (error instanceof Api.Errors.Conflict) return dialogs.errorConflict + if (error instanceof Api.Errors.Forbidden) return dialogs.errorForbidden + if (error instanceof Api.Errors.Gone) return dialogs.errorGone + if (error instanceof Api.Errors.InvalidState) return dialogs.errorInvalidState + if (error instanceof Api.Errors.Network) return dialogs.errorNetwork + if (error instanceof Api.Errors.NotFound) return dialogs.errorNotFound + if (error instanceof Api.Errors.ResponseMalformed) return dialogs.errorResponseMalformed + if (error instanceof Api.Errors.ServerSide) return dialogs.errorServerSide + if (error instanceof Api.Errors.TooEarly) return dialogs.errorTooEarly + if (error instanceof Api.Errors.TooManyRequests) return dialogs.errorTooManyRequests + if (error instanceof Api.Errors.Unauthorized) return dialogs.errorUnauthorized + if (error instanceof Api.Errors.Unknown) return dialogs.errorUnknown + + return dialogs.errorUnknown + } + + return resolver +} diff --git a/apps/wallet-mobile/src/api/useDialogsApi.tsx b/apps/wallet-mobile/src/api/useDialogsApi.tsx new file mode 100644 index 0000000000..9dc09e0341 --- /dev/null +++ b/apps/wallet-mobile/src/api/useDialogsApi.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' + +import {useStringsApiErrors} from './useStringsApiErrors' + +export const useDialogsApi = () => { + const strings = useStringsApiErrors() + + return React.useRef({ + errorBadRequest: { + title: strings.errorTitle, + message: strings.badRequest, + }, + errorUnauthorized: { + title: strings.errorTitle, + message: strings.unauthorized, + }, + errorForbidden: { + title: strings.errorTitle, + message: strings.forbidden, + }, + errorNotFound: { + title: strings.errorTitle, + message: strings.notFound, + }, + errorConflict: { + title: strings.errorTitle, + message: strings.conflict, + }, + errorGone: { + title: strings.errorTitle, + message: strings.gone, + }, + errorTooEarly: { + title: strings.errorTitle, + message: strings.tooEarly, + }, + errorTooManyRequests: { + title: strings.errorTitle, + message: strings.tooManyRequests, + }, + errorServerSide: { + title: strings.errorTitle, + message: strings.serverSide, + }, + errorUnknown: { + title: strings.errorTitle, + message: strings.unknown, + }, + errorNetwork: { + title: strings.errorTitle, + message: strings.network, + }, + errorInvalidState: { + title: strings.errorTitle, + message: strings.invalidState, + }, + errorResponseMalformed: { + title: strings.errorTitle, + message: strings.responseMalformed, + }, + } as const).current +} diff --git a/apps/wallet-mobile/src/api/useStringsApiErrors.tsx b/apps/wallet-mobile/src/api/useStringsApiErrors.tsx new file mode 100644 index 0000000000..16b8b20265 --- /dev/null +++ b/apps/wallet-mobile/src/api/useStringsApiErrors.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import {useIntl} from 'react-intl' + +import {apiErrors} from '../i18n/global-messages' + +export const useStringsApiErrors = () => { + const intl = useIntl() + + return React.useRef({ + errorTitle: intl.formatMessage(apiErrors.title), + + badRequest: intl.formatMessage(apiErrors.badRequest), + conflict: intl.formatMessage(apiErrors.conflict), + forbidden: intl.formatMessage(apiErrors.forbidden), + gone: intl.formatMessage(apiErrors.gone), + invalidState: intl.formatMessage(apiErrors.invalidState), + network: intl.formatMessage(apiErrors.network), + notFound: intl.formatMessage(apiErrors.notFound), + responseMalformed: intl.formatMessage(apiErrors.responseMalformed), + serverSide: intl.formatMessage(apiErrors.serverSide), + tooEarly: intl.formatMessage(apiErrors.tooEarly), + tooManyRequests: intl.formatMessage(apiErrors.tooManyRequests), + unauthorized: intl.formatMessage(apiErrors.unauthorized), + unknown: intl.formatMessage(apiErrors.unknown), + } as const).current +} diff --git a/apps/wallet-mobile/src/components/LoadingOverlay/LoadingOverlay.tsx b/apps/wallet-mobile/src/components/LoadingOverlay/LoadingOverlay.tsx index 08298da5d4..679a5a32c3 100644 --- a/apps/wallet-mobile/src/components/LoadingOverlay/LoadingOverlay.tsx +++ b/apps/wallet-mobile/src/components/LoadingOverlay/LoadingOverlay.tsx @@ -1,14 +1,15 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View, ViewProps} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -export const LoadingOverlay = ({loading}: {loading: boolean}) => { +export const LoadingOverlay = ({loading, style, ...props}: ViewProps & {loading: boolean}) => { return loading ? ( void closeModal: () => void + startLoading: () => void + stopLoading: () => void } const ModalContext = React.createContext(undefined) @@ -40,6 +43,8 @@ export const ModalProvider = ({ dispatch({type: 'open', title, content, height}) navigation.navigate('modal') }, + startLoading: () => dispatch({type: 'startLoading'}), + stopLoading: () => dispatch({type: 'stopLoading'}), }).current const context = React.useMemo(() => ({...state, ...actions}), [state, actions]) @@ -50,6 +55,8 @@ export const ModalProvider = ({ type ModalAction = | {type: 'open'; height: ModalState['height'] | undefined; content: ModalState['content']; title: ModalState['title']} | {type: 'close'} + | {type: 'startLoading'} + | {type: 'stopLoading'} const modalReducer = (state: ModalState, action: ModalAction) => { switch (action.type) { @@ -60,14 +67,27 @@ const modalReducer = (state: ModalState, action: ModalAction) => { height: action.height ?? defaultState.height, title: action.title, isOpen: true, + isLoading: false, } case 'close': return {...defaultState} + case 'stopLoading': + return {...state, isLoading: false} + + case 'startLoading': + return {...state, isLoading: true} + default: throw new Error(`modalReducer invalid action`) } } -const defaultState: ModalState = Object.freeze({content: undefined, height: 350, title: '', isOpen: false}) +const defaultState: ModalState = Object.freeze({ + content: undefined, + height: 350, + title: '', + isOpen: false, + isLoading: false, +}) diff --git a/apps/wallet-mobile/src/components/Modal/ModalScreen.tsx b/apps/wallet-mobile/src/components/Modal/ModalScreen.tsx index 8c4a7df543..0c69e74523 100644 --- a/apps/wallet-mobile/src/components/Modal/ModalScreen.tsx +++ b/apps/wallet-mobile/src/components/Modal/ModalScreen.tsx @@ -12,11 +12,12 @@ import { } from 'react-native' import {Spacer} from '..' +import {LoadingOverlay} from '../LoadingOverlay/LoadingOverlay' import {useModal} from './ModalContext' export const ModalScreen = () => { const {current} = useCardAnimation() - const {height, closeModal, content, isOpen} = useModal() + const {height, closeModal, content, isOpen, isLoading} = useModal() const [swipeLocationY, setSwipeLocationY] = React.useState(height) const onResponderMove = ({nativeEvent}: GestureResponderEvent) => { @@ -55,7 +56,9 @@ export const ModalScreen = () => { styles.animatedView, ]} > - + + +
true} /> {content} @@ -103,11 +106,13 @@ const styles = StyleSheet.create({ animatedView: { alignSelf: 'stretch', }, + rounded: { + borderTopRightRadius: 20, + borderTopLeftRadius: 20, + }, sheet: { flex: 1, backgroundColor: 'white', - borderTopRightRadius: 20, - borderTopLeftRadius: 20, alignSelf: 'stretch', paddingHorizontal: 16, paddingBottom: 16, diff --git a/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.stories.tsx b/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.stories.tsx new file mode 100644 index 0000000000..ee1d14e986 --- /dev/null +++ b/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.stories.tsx @@ -0,0 +1,23 @@ +import {action} from '@storybook/addon-actions' +import {storiesOf} from '@storybook/react-native' +import React from 'react' +import {Alert, Text, View} from 'react-native' + +import {ModalScreenWrapper} from './ModalScreenWrapper' + +storiesOf('ModalScreenWrapper', module).add('initial', () => { + return ( + { + action('onClose') + Alert.alert('onClose', 'Will it close?') + }} + > + + Body text + + + ) +}) diff --git a/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.tsx b/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.tsx new file mode 100644 index 0000000000..b1ddd2c750 --- /dev/null +++ b/apps/wallet-mobile/src/components/ModalScreenWrapper/ModalScreenWrapper.tsx @@ -0,0 +1,141 @@ +import {useCardAnimation} from '@react-navigation/stack' +import React from 'react' +import { + Animated, + GestureResponderEvent, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' + +import {Spacer} from '..' + +// This is another option for modals that will keep the context tree, it requires navigation and if you need to wrap it a fragment is a must +// Tested and working but it will require a big refactor on the navigator to work with it +type ModalScreenProps = { + height: number + title: string + onClose: () => void + children?: Exclude +} +export const ModalScreenWrapper = ({height, onClose, children, title}: ModalScreenProps) => { + const {current} = useCardAnimation() + const [swipeLocationY, setSwipeLocationY] = React.useState(height) + + const onResponderMove = ({nativeEvent}: GestureResponderEvent) => { + if (swipeLocationY < nativeEvent.locationY) { + setSwipeLocationY(height) + onClose() + return + } + + setSwipeLocationY(nativeEvent.locationY) + } + + return ( + + + + + + +
true} /> + + {children} + + + + + ) +} + +const Header = ({ + title, + ...props +}: { + onResponderMove?: (event: GestureResponderEvent) => void + onStartShouldSetResponder?: () => boolean + title: string +}) => { + return ( + + + + + + + + {title} + + ) +} + +const SliderIndicator = () => + +const styles = StyleSheet.create({ + root: { + flex: 1, + alignItems: 'center', + justifyContent: 'flex-end', + alignSelf: 'stretch', + }, + cancellableArea: { + flexGrow: 1, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + animatedView: { + alignSelf: 'stretch', + }, + sheet: { + flex: 1, + backgroundColor: 'white', + borderTopRightRadius: 20, + borderTopLeftRadius: 20, + alignSelf: 'stretch', + paddingHorizontal: 16, + paddingBottom: 16, + }, + title: { + fontWeight: '500', + fontFamily: 'Rubik-Medium', + fontSize: 20, + padding: 16, + color: '#000', + }, + header: { + alignItems: 'center', + alignSelf: 'stretch', + }, + slider: { + height: 4, + backgroundColor: 'black', + width: 32, + borderRadius: 10, + }, +}) diff --git a/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.stories.tsx b/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.stories.tsx new file mode 100644 index 0000000000..8a971478b5 --- /dev/null +++ b/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.stories.tsx @@ -0,0 +1,10 @@ +import {action} from '@storybook/addon-actions' +import {storiesOf} from '@storybook/react-native' +import * as React from 'react' + +import {Icon} from '../Icon' +import {PressableIcon} from './PressableIcon' + +storiesOf('PressableIcon', module).add('Icon.ExternalLink', () => ( + +)) diff --git a/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.tsx b/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.tsx new file mode 100644 index 0000000000..9e901b145e --- /dev/null +++ b/apps/wallet-mobile/src/components/PressableIcon/PressableIcon.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import {TouchableOpacity, TouchableOpacityProps} from 'react-native' + +type PressableIconProps = { + size?: number + color?: string + icon: (props: {size?: number; color?: string}) => React.ReactNode +} & TouchableOpacityProps +export const PressableIcon = ({icon, size, color, ...props}: PressableIconProps) => ( + {icon({size, color})} +) diff --git a/apps/wallet-mobile/src/features/Claim/common/useClaimErrorResolver.tsx b/apps/wallet-mobile/src/features/Claim/common/useClaimErrorResolver.tsx new file mode 100644 index 0000000000..e73528c13d --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/common/useClaimErrorResolver.tsx @@ -0,0 +1,29 @@ +import {useApiErrorResolver} from '../../../api/useApiErrorResolver' +import { + ClaimApiErrorsAlreadyClaimed, + ClaimApiErrorsExpired, + ClaimApiErrorsInvalidRequest, + ClaimApiErrorsNotFound, + ClaimApiErrorsRateLimited, + ClaimApiErrorsTooEarly, +} from '../module/errors' +import {useDialogs} from './useDialogs' + +export const useClaimErrorResolver = () => { + const dialogs = useDialogs() + const apiResolver = useApiErrorResolver() + + const resolver = (error: unknown) => { + if (error instanceof ClaimApiErrorsAlreadyClaimed) return dialogs.errorAlreadyClaimed + if (error instanceof ClaimApiErrorsExpired) return dialogs.errorExpired + if (error instanceof ClaimApiErrorsInvalidRequest) return dialogs.errorInvalidRequest + if (error instanceof ClaimApiErrorsNotFound) return dialogs.errorNotFound + if (error instanceof ClaimApiErrorsRateLimited) return dialogs.errorRateLimited + if (error instanceof ClaimApiErrorsTooEarly) return dialogs.errorTooEarly + + // falback to api errors + return apiResolver(error) + } + + return resolver +} diff --git a/apps/wallet-mobile/src/features/Claim/common/useDialogs.tsx b/apps/wallet-mobile/src/features/Claim/common/useDialogs.tsx new file mode 100644 index 0000000000..e58ba66572 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/common/useDialogs.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' + +import {useStrings} from './useStrings' + +export const useDialogs = () => { + const strings = useStrings() + + return React.useRef({ + errorInvalidRequest: { + title: strings.apiErrorTitle, + message: strings.apiErrorInvalidRequest, + }, + errorNotFound: { + title: strings.apiErrorTitle, + message: strings.apiErrorNotFound, + }, + errorAlreadyClaimed: { + title: strings.apiErrorTitle, + message: strings.apiErrorAlreadyClaimed, + }, + errorExpired: { + title: strings.apiErrorTitle, + message: strings.apiErrorExpired, + }, + errorTooEarly: { + title: strings.apiErrorTitle, + message: strings.apiErrorTooEarly, + }, + errorRateLimited: { + title: strings.apiErrorTitle, + message: strings.apiErrorRateLimited, + }, + + accepted: { + title: strings.acceptedTitle, + message: strings.acceptedMesage, + }, + processing: { + title: strings.processingTitle, + message: strings.processingMessage, + }, + done: { + title: strings.doneTitle, + message: strings.doneMessage, + }, + } as const).current +} diff --git a/apps/wallet-mobile/src/features/Claim/common/useNavigateTo.tsx b/apps/wallet-mobile/src/features/Claim/common/useNavigateTo.tsx new file mode 100644 index 0000000000..ab77056f02 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/common/useNavigateTo.tsx @@ -0,0 +1,14 @@ +import {useNavigation} from '@react-navigation/native' +import {useRef} from 'react' + +import {TxHistoryRouteNavigation} from '../../../navigation' + +export const useNavigateTo = () => { + const navigation = useNavigation() + + return useRef({ + showSuccess: () => navigation.navigate('claim-show-success'), + + back: () => navigation.goBack(), + } as const).current +} diff --git a/apps/wallet-mobile/src/features/Claim/common/useStrings.tsx b/apps/wallet-mobile/src/features/Claim/common/useStrings.tsx new file mode 100644 index 0000000000..acfd6985d4 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/common/useStrings.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' +import {defineMessages, useIntl} from 'react-intl' + +import globalMessages, {txLabels} from '../../../i18n/global-messages' + +export const useStrings = () => { + const intl = useIntl() + + return React.useRef({ + askConfirmationTitle: intl.formatMessage(messages.askConfirmationTitle), + showSuccessTitle: intl.formatMessage(messages.showSuccessTitle), + + acceptedTitle: intl.formatMessage(messages.acceptedTitle), + acceptedMesage: intl.formatMessage(messages.acceptedMessage), + processingTitle: intl.formatMessage(messages.processingTitle), + processingMessage: intl.formatMessage(messages.processingMessage), + doneTitle: intl.formatMessage(messages.doneTitle), + doneMessage: intl.formatMessage(messages.doneMessage), + transactionId: intl.formatMessage(txLabels.txId), + + addressSharingWarning: intl.formatMessage(messages.addressSharingWarning), + domain: intl.formatMessage(messages.domain), + code: intl.formatMessage(messages.code), + + apiErrorTitle: intl.formatMessage(messages.apiErrorTitle), + apiErrorInvalidRequest: intl.formatMessage(messages.apiErrorInvalidRequest), + apiErrorNotFound: intl.formatMessage(messages.apiErrorNotFound), + apiErrorAlreadyClaimed: intl.formatMessage(messages.apiErrorAlreadyClaimed), + apiErrorExpired: intl.formatMessage(messages.apiErrorExpired), + apiErrorTooEarly: intl.formatMessage(messages.apiErrorTooEarly), + apiErrorRateLimited: intl.formatMessage(messages.apiErrorRateLimited), + + ok: intl.formatMessage(globalMessages.ok), + cancel: intl.formatMessage(globalMessages.cancel), + continue: intl.formatMessage(messages.continue), + } as const).current +} + +export const messages = Object.freeze( + defineMessages({ + askConfirmationTitle: { + id: 'claim.askConfirmation.title', + defaultMessage: '!!!Confirm Claim', + }, + showSuccessTitle: { + id: 'claim.showSuccess.title', + defaultMessage: '!!!Success', + }, + + doneTitle: { + id: 'claim.done.title', + defaultMessage: '!!!Done', + }, + doneMessage: { + id: 'claim.done.message', + defaultMessage: '!!!Done', + }, + acceptedTitle: { + id: 'claim.accepted.title', + defaultMessage: '!!!Accepted', + }, + acceptedMessage: { + id: 'claim.accepted.message', + defaultMessage: '!!!Accepted', + }, + processingTitle: { + id: 'claim.processing.title', + defaultMessage: '!!!Processing', + }, + processingMessage: { + id: 'claim.processing.message', + defaultMessage: '!!!Processing', + }, + + addressSharingWarning: { + id: 'claim.addressSharingWarning', + defaultMessage: '!!!Address sharing warning', + }, + domain: { + id: 'claim.domain', + defaultMessage: '!!!Domain', + }, + code: { + id: 'claim.code', + defaultMessage: '!!!Code', + }, + + apiErrorTitle: { + id: 'claim.apiError.title', + defaultMessage: '!!!Error title', + }, + apiErrorInvalidRequest: { + id: 'claim.apiError.invalidRequest', + defaultMessage: '!!!Invalid request', + }, + apiErrorNotFound: { + id: 'claim.apiError.notFound', + defaultMessage: '!!!Not found', + }, + apiErrorAlreadyClaimed: { + id: 'claim.apiError.alreadyClaimed', + defaultMessage: '!!!Already claimed', + }, + apiErrorExpired: { + id: 'claim.apiError.expired', + defaultMessage: '!!!Expired', + }, + apiErrorTooEarly: { + id: 'claim.apiError.tooEarly', + defaultMessage: '!!!Too early', + }, + apiErrorRateLimited: { + id: 'claim.apiError.rateLimited', + defaultMessage: '!!!Rate limited', + }, + + continue: { + id: 'global.actions.dialogs.commonbuttons.continueButton', + defaultMessage: '!!!Continue', + }, + }), +) diff --git a/apps/wallet-mobile/src/features/Claim/illustrations/ClaimSuccessIllustration.tsx b/apps/wallet-mobile/src/features/Claim/illustrations/ClaimSuccessIllustration.tsx new file mode 100644 index 0000000000..5492d78896 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/illustrations/ClaimSuccessIllustration.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' +import Svg, {Defs, G, LinearGradient, Path, Stop, SvgProps} from 'react-native-svg' + +// TODO: Get from Product the oficial illustration +export const ClaimSuccessIllustration = ({zoom = 1, ...props}: SvgProps & {zoom?: number}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/wallet-mobile/src/features/Claim/illustrations/Ilustrations.stories.tsx b/apps/wallet-mobile/src/features/Claim/illustrations/Ilustrations.stories.tsx new file mode 100644 index 0000000000..704a81d3c0 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/illustrations/Ilustrations.stories.tsx @@ -0,0 +1,8 @@ +import {storiesOf} from '@storybook/react-native' +import React from 'react' + +import {ClaimSuccessIllustration} from './ClaimSuccessIllustration' + +storiesOf('Claim Illustrations Gallery', module).add('Claim request success', () => { + return +}) diff --git a/apps/wallet-mobile/src/features/Claim/module/ClaimProvider.tsx b/apps/wallet-mobile/src/features/Claim/module/ClaimProvider.tsx new file mode 100644 index 0000000000..7b32481321 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/ClaimProvider.tsx @@ -0,0 +1,52 @@ +import {invalid} from '@yoroi/common' +import * as React from 'react' + +import {ScanActionClaim} from '../../Scan/common/types' +import {claimApiMockInstances} from './api.mocks' +import {ClaimActions, ClaimActionType, claimReducer, defaultClaimActions, defaultClaimState} from './state' +import {ClaimApi, ClaimState, ClaimToken} from './types' + +export type ClaimProviderContext = React.PropsWithChildren + +const initialClaimProvider: ClaimProviderContext = { + ...defaultClaimState, + ...defaultClaimActions, + ...claimApiMockInstances.error, +} +const ClaimContext = React.createContext(initialClaimProvider) + +type ClaimProviderProps = React.PropsWithChildren<{ + claimApi: ClaimApi + initialState?: ClaimState +}> +export const ClaimProvider = ({children, claimApi, initialState}: ClaimProviderProps) => { + const [state, dispatch] = React.useReducer(claimReducer, { + ...defaultClaimState, + ...initialState, + }) + + const actions = React.useRef({ + claimTokenChanged: (claimToken: ClaimToken) => { + dispatch({type: ClaimActionType.ClaimTokenChanged, claimToken}) + }, + scanActionClaimChanged: (scanActionClaim: ScanActionClaim) => { + dispatch({type: ClaimActionType.ScanActionClaimChanged, scanActionClaim}) + }, + reset: () => { + dispatch({type: ClaimActionType.Reset}) + }, + }).current + + const context = React.useMemo( + () => ({ + ...state, + ...claimApi, + ...actions, + }), + [state, claimApi, actions], + ) + + return {children} +} +export const useClaim = () => + React.useContext(ClaimContext) ?? invalid('useClaim: needs to be wrapped in a ClaimManagerProvider') diff --git a/apps/wallet-mobile/src/features/Claim/module/api-faucet.mocks.ts b/apps/wallet-mobile/src/features/Claim/module/api-faucet.mocks.ts new file mode 100644 index 0000000000..64dd6a1758 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/api-faucet.mocks.ts @@ -0,0 +1,34 @@ +import {ClaimApiClaimTokensResponse} from './types' + +const claimTokens: Record = { + success: { + accepted: { + status: 'accepted', + lovelaces: '2000000', + tokens: { + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65f.74484f444c52': '1000000', + }, + queue_position: 100, + }, + queued: { + lovelaces: '2000000', + tokens: { + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65f.74484f444c52': '1000000', + }, + status: 'queued', + queue_position: 1, + }, + claimed: { + lovelaces: '2000000', + tokens: { + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65f.74484f444c52': '1000000', + }, + status: 'claimed', + tx_hash: 'tx_hash', + }, + }, +} as const + +export const claimFaucetResponses = { + claimTokens, +} as const diff --git a/apps/wallet-mobile/src/features/Claim/module/api.mocks.ts b/apps/wallet-mobile/src/features/Claim/module/api.mocks.ts new file mode 100644 index 0000000000..1f9958605d --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/api.mocks.ts @@ -0,0 +1,78 @@ +import {ClaimApi, ClaimToken} from './types' + +const claimTokensResponse: {[key: string]: ClaimToken} = { + accepted: { + status: 'accepted', + amounts: { + '': '2000000', + '698a6ea0ca99f315034072af31eaac6ec11fe8558d3f48e9775aab9d.7444524950': '44', + '29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6.4d494e': '410', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65f.74484f444c52': '5', + '1ca1fc0c880d25850cb00303788dfb51bdf2f902f6dce47d1ad09d5b.44': '2463889379', + '08d91ec4e6c743a92de97d2fde5ca0d81493555c535894a3097061f7.c8b0': '148', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c53': '100008', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c54': '10000000012', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c55': '100000000000000020', + }, + }, + processing: { + status: 'processing', + amounts: { + '': '2000000', + '698a6ea0ca99f315034072af31eaac6ec11fe8558d3f48e9775aab9d.7444524950': '44', + }, + }, + done: { + status: 'done', + amounts: { + '': '2000000', + '698a6ea0ca99f315034072af31eaac6ec11fe8558d3f48e9775aab9d.7444524950': '44', + '29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6.4d494e': '410', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65f.74484f444c52': '5', + '1ca1fc0c880d25850cb00303788dfb51bdf2f902f6dce47d1ad09d5b.44': '2463889379', + '08d91ec4e6c743a92de97d2fde5ca0d81493555c535894a3097061f7.c8b0': '148', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c53': '100008', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c54': '10000000012', + '1d129dc9c03f95a863489883914f05a52e13135994a32f0cbeacc65e.74484f444c55': '100000000000000020', + }, + txHash: '3a27ac29f4218a4503ed241a19e59291835b38ccdb1f1f71ae4dc889d7dbfeb4', + }, +} as const + +export const claimApiMockResponses = { + claimTokens: claimTokensResponse, +} as const + +const claimTokensApi = { + success: { + accepted: () => { + return Promise.resolve(claimTokensResponse.accepted) + }, + processing: () => { + return Promise.resolve(claimTokensResponse.processing) + }, + done: () => { + return Promise.resolve(claimTokensResponse.done) + }, + }, + error: () => { + return Promise.reject(new Error('Something went wrong')) + }, + loading: () => { + return new Promise(() => null) as unknown as ClaimToken + }, +} as const + +export const claimApiMockFetchers = { + claimTokens: claimTokensApi, +} as const + +const claimApiError: ClaimApi = { + claimTokens: claimTokensApi.error, + address: 'address', + primaryTokenId: '', +} as const + +export const claimApiMockInstances = { + error: claimApiError, +} as const diff --git a/apps/wallet-mobile/src/features/Claim/module/api.tests.ts b/apps/wallet-mobile/src/features/Claim/module/api.tests.ts new file mode 100644 index 0000000000..d4d13d04ed --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/api.tests.ts @@ -0,0 +1,16 @@ +import {fetchData} from '@yoroi/common' + +import {claimApiMaker} from './api' + +describe('claimApiMaker', () => { + it('success', () => { + const appApi = claimApiMaker({address: 'addr_test', primaryTokenId: 'primaryTokenId'}) + expect(appApi).toBeDefined() + + const appApiWithFetcher = claimApiMaker( + {address: 'addr_test', primaryTokenId: 'primaryTokenId'}, + {request: fetchData}, + ) + expect(appApiWithFetcher).toBeDefined() + }) +}) diff --git a/apps/wallet-mobile/src/features/Claim/module/api.ts b/apps/wallet-mobile/src/features/Claim/module/api.ts new file mode 100644 index 0000000000..81a1deeccd --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/api.ts @@ -0,0 +1,48 @@ +import {FetchData, fetchData, isLeft} from '@yoroi/common' +import {Api, Balance} from '@yoroi/types' + +import {ScanActionClaim} from '../../Scan/common/types' +import {asClaimApiError, asClaimToken} from './transformers' +import {ClaimApi, ClaimApiClaimTokensResponse} from './types' +import {ClaimTokensApiResponseSchema} from './validators' + +type ClaimApiMaker = Readonly<{ + address: string + primaryTokenId: Balance.TokenInfo['id'] +}> + +export const claimApiMaker = ( + {address, primaryTokenId}: ClaimApiMaker, + deps: Readonly<{request: FetchData}> = {request: fetchData} as const, +): Readonly => { + const claimTokens = postClaimTokens({address, primaryTokenId}, deps) + + return { + claimTokens, + address, + primaryTokenId, + } as const +} + +const postClaimTokens = + ({address, primaryTokenId}: ClaimApiMaker, {request} = {request: fetchData}) => + async (claimAction: ScanActionClaim) => { + // builds the request from the action, overides address and code + const {code, params, url} = claimAction + const payload = {...params, address, code} + + const response = await request({ + url, + method: 'post', + data: payload, + }) + + if (isLeft(response)) { + return asClaimApiError(response.error) + } else { + const claimToken = response.value.data + if (!ClaimTokensApiResponseSchema.safeParse(claimToken).success) throw new Api.Errors.ResponseMalformed() + + return asClaimToken(claimToken, primaryTokenId) + } + } diff --git a/apps/wallet-mobile/src/features/Claim/module/errors.ts b/apps/wallet-mobile/src/features/Claim/module/errors.ts new file mode 100644 index 0000000000..1b1fe489fb --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/errors.ts @@ -0,0 +1,28 @@ +export class ClaimApiErrorsInvalidRequest extends Error { + static readonly statusCode = 400 +} +export class ClaimApiErrorsNotFound extends Error { + static readonly statusCode = 404 +} +export class ClaimApiErrorsAlreadyClaimed extends Error { + static readonly statusCode = 409 +} +export class ClaimApiErrorsExpired extends Error { + static readonly statusCode = 410 +} +export class ClaimApiErrorsTooEarly extends Error { + static readonly statusCode = 425 +} +export class ClaimApiErrorsRateLimited extends Error { + static readonly statusCode = 429 +} + +// API errors that when returned from a faucet have a more specific error message +export const claimApiErrors = [ + ClaimApiErrorsInvalidRequest, + ClaimApiErrorsNotFound, + ClaimApiErrorsAlreadyClaimed, + ClaimApiErrorsExpired, + ClaimApiErrorsTooEarly, + ClaimApiErrorsRateLimited, +] as const diff --git a/apps/wallet-mobile/src/features/Claim/module/state.mocks.ts b/apps/wallet-mobile/src/features/Claim/module/state.mocks.ts new file mode 100644 index 0000000000..b2e0673fc2 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/state.mocks.ts @@ -0,0 +1,37 @@ +import {claimApiMockResponses} from './api.mocks' +import {defaultClaimState} from './state' +import {ClaimState} from './types' + +const empty: ClaimState = {...defaultClaimState} as const +const withScanActionClaim: ClaimState = { + ...defaultClaimState, + scanActionClaim: { + action: 'claim', + code: 'code', + params: {}, + url: 'https://example.com', + }, +} as const + +const withClaimTokenAccepted: ClaimState = { + ...withScanActionClaim, + claimToken: claimApiMockResponses.claimTokens['accepted'], +} as const + +const withClaimTokenProcessing: ClaimState = { + ...withScanActionClaim, + claimToken: claimApiMockResponses.claimTokens['processing'], +} as const + +const withClaimTokenDone: ClaimState = { + ...withScanActionClaim, + claimToken: claimApiMockResponses.claimTokens['done'], +} as const + +export const mocks = { + empty, + withScanActionClaim, + withClaimTokenAccepted, + withClaimTokenDone, + withClaimTokenProcessing, +} as const diff --git a/apps/wallet-mobile/src/features/Claim/module/state.ts b/apps/wallet-mobile/src/features/Claim/module/state.ts new file mode 100644 index 0000000000..cb7c0defb4 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/state.ts @@ -0,0 +1,57 @@ +import {invalid} from '@yoroi/common' +import {produce} from 'immer' + +import {ScanActionClaim} from '../../Scan/common/types' +import {ClaimState, ClaimToken} from './types' +export type ClaimActions = Readonly<{ + claimTokenChanged: (claimToken: ClaimToken) => void + scanActionClaimChanged: (scanActionClaim: ScanActionClaim) => void + reset: () => void +}> + +export enum ClaimActionType { + ClaimTokenChanged = 'claimTokenChanged', + ScanActionClaimChanged = 'scanActionClaimChanged', + Reset = 'reset', +} + +export type ClaimAction = + | { + type: ClaimActionType.ClaimTokenChanged + claimToken: ClaimToken + } + | { + type: ClaimActionType.ScanActionClaimChanged + scanActionClaim: ScanActionClaim + } + | { + type: ClaimActionType.Reset + } + +export const defaultClaimState: ClaimState = { + claimToken: undefined, + scanActionClaim: undefined, +} as const + +export const defaultClaimActions: ClaimActions = { + claimTokenChanged: () => invalid('missing init'), + scanActionClaimChanged: () => invalid('missing init'), + reset: () => invalid('missing init'), +} as const + +export const claimReducer = (state: ClaimState, action: ClaimAction): ClaimState => { + return produce(state, (draft) => { + switch (action.type) { + case ClaimActionType.ClaimTokenChanged: + draft.claimToken = action.claimToken + break + case ClaimActionType.ScanActionClaimChanged: + draft.scanActionClaim = action.scanActionClaim + break + case ClaimActionType.Reset: + draft.claimToken = undefined + draft.scanActionClaim = undefined + break + } + }) +} diff --git a/apps/wallet-mobile/src/features/Claim/module/transformers.ts b/apps/wallet-mobile/src/features/Claim/module/transformers.ts new file mode 100644 index 0000000000..5f136fdd51 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/transformers.ts @@ -0,0 +1,47 @@ +import {handleApiError} from '@yoroi/common' +import {Api, Balance} from '@yoroi/types' + +import {Amounts, asQuantity} from '../../../yoroi-wallets/utils/utils' +import {claimApiErrors} from './errors' +import {ClaimApiClaimTokensResponse, ClaimToken} from './types' + +// if the error is a known claim api error, throw it with a more specific error message otherwise throw the api error +export const asClaimApiError = (error: Api.ResponseError) => { + const ClaimApiError = claimApiErrors.find(({statusCode}) => statusCode === error.status) + if (ClaimApiError) throw new ClaimApiError() + handleApiError(error) +} + +export const asClaimToken = ( + claimItemResponse: ClaimApiClaimTokensResponse, + primaryTokenId: Balance.TokenInfo['id'], +) => { + const {lovelaces, tokens, status} = claimItemResponse + const ptQuantity = asQuantity(lovelaces) + const amounts = Amounts.fromArray( + Object.entries(tokens) + .concat([primaryTokenId, ptQuantity]) + .map(([tokenId, quantity]): Balance.Amount => ({tokenId, quantity: asQuantity(quantity)})), + ) + + if (status === 'claimed') { + const claimed: Readonly = { + status: 'done', + amounts, + txHash: claimItemResponse.tx_hash, + } + return claimed + } else if (status === 'queued') { + const queued: Readonly = { + status: 'processing', + amounts, + } + return queued + } else { + const accepted: Readonly = { + status: 'accepted', + amounts, + } + return accepted + } +} diff --git a/apps/wallet-mobile/src/features/Claim/module/types.ts b/apps/wallet-mobile/src/features/Claim/module/types.ts new file mode 100644 index 0000000000..724fa8f4ba --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/types.ts @@ -0,0 +1,52 @@ +import {Balance} from '@yoroi/types' + +import {ScanActionClaim} from '../../Scan/common/types' + +export type ClaimApiClaimTokensRequestPayload = { + address: string + code: string + [key: string]: unknown +} + +export type ClaimApiClaimTokensResponse = { + lovelaces: string + tokens: { + [tokenId: string]: string + } +} & ( + | { + // code: 200 + status: 'accepted' + queue_position: number + } + | { + // code: 201 + status: 'queued' + queue_position: number + } + | { + // code: 202 + status: 'claimed' + tx_hash: string + } +) + +export type ClaimStatus = 'accepted' | 'processing' | 'done' + +export type ClaimToken = Readonly<{ + // api + status: ClaimStatus + amounts: Balance.Amounts + txHash?: string +}> + +export type ClaimApi = Readonly<{ + claimTokens: (action: ScanActionClaim) => Promise + address: string + primaryTokenId: Balance.TokenInfo['id'] +}> + +export type ClaimState = Readonly<{ + claimToken: ClaimToken | undefined + scanActionClaim: ScanActionClaim | undefined +}> diff --git a/apps/wallet-mobile/src/features/Claim/module/useClaimTokens.tsx b/apps/wallet-mobile/src/features/Claim/module/useClaimTokens.tsx new file mode 100644 index 0000000000..50110cfec8 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/useClaimTokens.tsx @@ -0,0 +1,21 @@ +import {UseMutationOptions} from 'react-query' + +import {useMutationWithInvalidations} from '../../../yoroi-wallets/hooks' +import {ScanActionClaim} from '../../Scan/common/types' +import {useClaim} from './ClaimProvider' +import {ClaimToken} from './types' + +export const useClaimTokens = (options: UseMutationOptions = {}) => { + const {claimTokens, address} = useClaim() + const mutation = useMutationWithInvalidations({ + ...options, + mutationFn: claimTokens, + invalidateQueries: [['useClaimTokens', address]], + }) + + return { + claimTokens: mutation.mutate, + + ...mutation, + } as const +} diff --git a/apps/wallet-mobile/src/features/Claim/module/validators.ts b/apps/wallet-mobile/src/features/Claim/module/validators.ts new file mode 100644 index 0000000000..a792f6f6ca --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/module/validators.ts @@ -0,0 +1,33 @@ +import {z} from 'zod' + +const QuantitySchema = z.string().regex(/^\d+$/, { + message: 'Expected a string containing only numeric characters (0-9)', +}) + +const AmountsSchema = z.record(QuantitySchema) + +const BaseClaimTokensSchema = z.object({ + lovelaces: QuantitySchema, + tokens: AmountsSchema, +}) + +const ClaimTokensAcceptedSchema = z.object({ + status: z.literal('accepted'), + queue_position: z.number(), +}) + +const ClaimTokensQueuedSchema = z.object({ + status: z.literal('queued'), + queue_position: z.number(), +}) + +const ClaimTokensClaimedSchema = z.object({ + status: z.literal('claimed'), + tx_hash: z.string(), +}) + +export const ClaimTokensApiResponseSchema = z.union([ + BaseClaimTokensSchema.merge(ClaimTokensAcceptedSchema), + BaseClaimTokensSchema.merge(ClaimTokensClaimedSchema), + BaseClaimTokensSchema.merge(ClaimTokensQueuedSchema), +]) diff --git a/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.stories.tsx b/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.stories.tsx new file mode 100644 index 0000000000..e7b9023f3e --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.stories.tsx @@ -0,0 +1,9 @@ +import {action} from '@storybook/addon-actions' +import {storiesOf} from '@storybook/react-native' +import React from 'react' + +import {AskConfirmation} from './AskConfirmation' + +storiesOf('AskConfirmation', module).add('initial', () => { + return +}) diff --git a/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.tsx b/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.tsx new file mode 100644 index 0000000000..c5a3898b42 --- /dev/null +++ b/apps/wallet-mobile/src/features/Claim/useCases/AskConfirmation.tsx @@ -0,0 +1,122 @@ +import * as React from 'react' +import {Platform, ScrollView, StyleSheet, Text, View, ViewProps} from 'react-native' + +import {Button} from '../../../components/Button/Button' +import {useModal} from '../../../components/Modal/ModalContext' +import {Spacer} from '../../../components/Spacer/Spacer' +import {useStrings} from '../common/useStrings' + +type Props = { + address: string + url: string + code: string + onContinue: () => void +} +export const AskConfirmation = ({address, url, code, onContinue}: Props) => { + const strings = useStrings() + const {closeModal, isLoading} = useModal() + const domain = getDomain(url) + + return ( + + + {strings.addressSharingWarning} + + + + {address} + + + + + + + + + + + + + +