diff --git a/app/assets/icons/arrow-down-to-bracket.svg b/app/assets/icons/arrow-down-to-bracket.svg new file mode 100644 index 000000000..b2fa9fff6 --- /dev/null +++ b/app/assets/icons/arrow-down-to-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/arrow-up-down.svg b/app/assets/icons/arrow-up-down.svg new file mode 100644 index 000000000..57c0bc041 --- /dev/null +++ b/app/assets/icons/arrow-up-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/arrow-up-from-bracket.svg b/app/assets/icons/arrow-up-from-bracket.svg new file mode 100644 index 000000000..7ce3e68b0 --- /dev/null +++ b/app/assets/icons/arrow-up-from-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/buttons/IconBtn.tsx b/app/components/buttons/IconBtn.tsx index 638de9c5d..ea17a6588 100644 --- a/app/components/buttons/IconBtn.tsx +++ b/app/components/buttons/IconBtn.tsx @@ -10,6 +10,7 @@ import QR from "@app/assets/icons/qr-code-new.svg" import Setting from "@app/assets/icons/setting.svg" import CardRemove from "@app/assets/icons/card-remove.svg" import Dollar from "@app/assets/icons/dollar-new.svg" +import ArrowUpDown from "@app/assets/icons/arrow-up-down.svg" const icons = { up: ArrowUp, @@ -19,6 +20,7 @@ const icons = { setting: Setting, cardRemove: CardRemove, dollar: Dollar, + upDown: ArrowUpDown, } type IconNamesType = keyof typeof icons diff --git a/app/components/cashout-flow/CashoutCard.tsx b/app/components/cashout-flow/CashoutCard.tsx index e6054f974..a3d2ececd 100644 --- a/app/components/cashout-flow/CashoutCard.tsx +++ b/app/components/cashout-flow/CashoutCard.tsx @@ -4,7 +4,7 @@ import { makeStyles, Text } from "@rneui/themed" type Props = { title: string - detail: string | number + detail?: string | number } const CashoutCard: React.FC = ({ title, detail }) => { diff --git a/app/components/home-screen/Buttons.tsx b/app/components/home-screen/Buttons.tsx index 61d274f40..3fc8a696f 100644 --- a/app/components/home-screen/Buttons.tsx +++ b/app/components/home-screen/Buttons.tsx @@ -68,13 +68,13 @@ const Buttons: React.FC = ({ setModalVisible, setDefaultAccountModalVisib }) } - // if (currentLevel === AccountLevel.Two) { - // buttons.push({ - // title: LL.Cashout.title(), - // target: "CashoutDetails", - // icon: "dollar", - // }) - // } + if (currentLevel === AccountLevel.Two || currentLevel === AccountLevel.Three) { + buttons.push({ + title: LL.HomeScreen.transfer(), + target: "BuySellBitcoin", + icon: "upDown", + }) + } return ( diff --git a/app/graphql/front-end-mutations.ts b/app/graphql/front-end-mutations.ts index 9f3cf05ad..4b5ae3d07 100644 --- a/app/graphql/front-end-mutations.ts +++ b/app/graphql/front-end-mutations.ts @@ -117,36 +117,36 @@ gql` } } - # mutation RequestCashout($input: RequestCashoutInput!) { - # requestCashout(input: $input) { - # errors { - # code - # message - # path - # } - # offer { - # exchangeRate - # expiresAt - # flashFee - # offerId - # receiveJmd - # receiveUsd - # send - # walletId - # } - # } - # } - - # mutation InitiateCashout($input: InitiateCashoutInput!) { - # initiateCashout(input: $input) { - # errors { - # path - # message - # code - # } - # success - # } - # } + mutation RequestCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { + errors { + code + message + path + } + offer { + exchangeRate + expiresAt + flashFee + offerId + receiveJmd + receiveUsd + send + walletId + } + } + } + + mutation InitiateCashout($input: InitiateCashoutInput!) { + initiateCashout(input: $input) { + errors { + path + message + code + } + success + } + } mutation accountDelete { accountDelete { diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index 81163878e..59a95c769 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -81,6 +81,19 @@ fragment TransactionList on TransactionConnection { __typename } +mutation InitiateCashout($input: InitiateCashoutInput!) { + initiateCashout(input: $input) { + errors { + path + message + code + __typename + } + success + __typename + } +} + mutation MerchantMapSuggest($input: MerchantMapSuggestInput!) { merchantMapSuggest(input: $input) { errors { @@ -106,6 +119,29 @@ mutation MerchantMapSuggest($input: MerchantMapSuggestInput!) { } } +mutation RequestCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { + errors { + code + message + path + __typename + } + offer { + exchangeRate + expiresAt + flashFee + offerId + receiveJmd + receiveUsd + send + walletId + __typename + } + __typename + } +} + mutation accountDelete { accountDelete { errors { diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index a8cb4512e..6c8791a71 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -2093,6 +2093,20 @@ export type MerchantMapSuggestMutationVariables = Exact<{ export type MerchantMapSuggestMutation = { readonly __typename: 'Mutation', readonly merchantMapSuggest: { readonly __typename: 'MerchantPayload', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null, readonly message: string, readonly path?: ReadonlyArray | null }>, readonly merchant?: { readonly __typename: 'Merchant', readonly createdAt: number, readonly id: string, readonly title: string, readonly username: string, readonly validated: boolean, readonly coordinates: { readonly __typename: 'Coordinates', readonly latitude: number, readonly longitude: number } } | null } }; +export type RequestCashoutMutationVariables = Exact<{ + input: RequestCashoutInput; +}>; + + +export type RequestCashoutMutation = { readonly __typename: 'Mutation', readonly requestCashout: { readonly __typename: 'RequestCashoutResponse', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null, readonly message: string, readonly path?: ReadonlyArray | null }>, readonly offer?: { readonly __typename: 'CashoutOffer', readonly exchangeRate: number, readonly expiresAt: number, readonly flashFee: number, readonly offerId: string, readonly receiveJmd: number, readonly receiveUsd: number, readonly send: number, readonly walletId: string } | null } }; + +export type InitiateCashoutMutationVariables = Exact<{ + input: InitiateCashoutInput; +}>; + + +export type InitiateCashoutMutation = { readonly __typename: 'Mutation', readonly initiateCashout: { readonly __typename: 'SuccessPayload', readonly success?: boolean | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly path?: ReadonlyArray | null, readonly message: string, readonly code?: string | null }> } }; + export type AccountDeleteMutationVariables = Exact<{ [key: string]: never; }>; @@ -3506,6 +3520,91 @@ export function useMerchantMapSuggestMutation(baseOptions?: Apollo.MutationHookO export type MerchantMapSuggestMutationHookResult = ReturnType; export type MerchantMapSuggestMutationResult = Apollo.MutationResult; export type MerchantMapSuggestMutationOptions = Apollo.BaseMutationOptions; +export const RequestCashoutDocument = gql` + mutation RequestCashout($input: RequestCashoutInput!) { + requestCashout(input: $input) { + errors { + code + message + path + } + offer { + exchangeRate + expiresAt + flashFee + offerId + receiveJmd + receiveUsd + send + walletId + } + } +} + `; +export type RequestCashoutMutationFn = Apollo.MutationFunction; + +/** + * __useRequestCashoutMutation__ + * + * To run a mutation, you first call `useRequestCashoutMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRequestCashoutMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [requestCashoutMutation, { data, loading, error }] = useRequestCashoutMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useRequestCashoutMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RequestCashoutDocument, options); + } +export type RequestCashoutMutationHookResult = ReturnType; +export type RequestCashoutMutationResult = Apollo.MutationResult; +export type RequestCashoutMutationOptions = Apollo.BaseMutationOptions; +export const InitiateCashoutDocument = gql` + mutation InitiateCashout($input: InitiateCashoutInput!) { + initiateCashout(input: $input) { + errors { + path + message + code + } + success + } +} + `; +export type InitiateCashoutMutationFn = Apollo.MutationFunction; + +/** + * __useInitiateCashoutMutation__ + * + * To run a mutation, you first call `useInitiateCashoutMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useInitiateCashoutMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [initiateCashoutMutation, { data, loading, error }] = useInitiateCashoutMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useInitiateCashoutMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(InitiateCashoutDocument, options); + } +export type InitiateCashoutMutationHookResult = ReturnType; +export type InitiateCashoutMutationResult = Apollo.MutationResult; +export type InitiateCashoutMutationOptions = Apollo.BaseMutationOptions; export const AccountDeleteDocument = gql` mutation accountDelete { accountDelete { diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index e627b6aca..960982432 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -537,6 +537,7 @@ const en: BaseTranslation = { balance: "Refresh Balance", showQrCode: "Topup via QR", send: "Send", + transfer: "Transfer", sweep: "Sweep to Wallet", pay: "Pay", title: "Home", @@ -565,6 +566,62 @@ const en: BaseTranslation = { backupTitle: "Backup your BTC wallet", backupDesc: "Backup and secure your Bitcoin wallet using recovery phrase." }, + TopUpScreen: { + title: "Top Up", + bankTransfer: "Bank Transfer", + bankTransferDesc: "Transfer funds from your bank", + debitCreditCard: "Debit/Credit Card", + debitCreditCardDesc: "Pay with your debit or credit card" + }, + BuyBitcoinDetails: { + title: "Card Payment", + bankTransfer: "Bank Transfer", + email: "Email", + emailPlaceholder: "Enter your email address", + wallet: "Wallet", + walletPlaceholder: "Select wallet", + amount: "Amount (USD)", + amountPlaceholder: "Enter amount", + continue: "Continue", + usdWallet: "USD Wallet", + btcWallet: "BTC Wallet", + invalidEmail: "Please enter a valid email address", + invalidAmount: "Please enter a valid amount", + minimumAmount: "Minimum amount is $1.00" + }, + FygaroWebViewScreen: { + title: "Fygaro Payment", + loading: "Loading payment page...", + error: "Failed to load payment page", + retry: "Retry" + }, + BankTransfer: { + title: "Bank Transfer", + desc1: "Your order has been created. To complete the order, please transfer ${amount: number} USD to the bank details provided below.", + desc2: "Use {code: string} as the reference description. This unique code will help us associate the payment with your Flash account and process the Bitcoin transfer.", + desc3: "After we have received your payment, you will be credited with ${amount: number} USD in your Cash wallet, with a ${fee: number} USD fee deducted. You can then choose when you convert those USD to Bitcoin on your own using the Convert functionality in the mobile app.", + accountType: "Account Type", + destinationBank: "Destination Bank", + accountNumber: "Account Number", + typeOfClient: "Type of Client", + receiverName: "Receiver's Name", + email: "Email", + amount: "Amount", + uniqueCode: "Unique Code", + fees: "Fees", + desc4: "After payment completion on your end you can send us an email to {email: string} with a screenshot of your payment confirmation.", + desc5: "Your payment will be processed even if we don't receive this email, but having this confirmation can help accelerate the order.", + backHome: "Back to Home" + }, + PaymentSuccessScreen: { + title: "Payment Successful", + successMessage: "Your payment has been processed successfully", + amountSent: "Amount Sent", + depositedTo: "Deposited to", + transactionId: "Transaction ID", + done: "Done", + viewTransaction: "View Transaction" + }, PinScreen: { attemptsRemaining: "Incorrect PIN. {attemptsRemaining: number} attempts remaining.", oneAttemptRemaining: "Incorrect PIN. 1 attempt remaining.", @@ -1086,6 +1143,10 @@ const en: BaseTranslation = { TransferScreen: { title: "Transfer", percentageToConvert: "% to convert", + topUp: "Top Up", + topUpDesc: "Add funds to your wallet", + settle: "Settle", + settleDesc: "Cashout funds from your wallet" }, UpgradeAccountModal: { title: "Upgrade your account", @@ -1337,7 +1398,7 @@ const en: BaseTranslation = { email: "Email", enjoyingApp: "Enjoying the app?", statusPage: "Status Page", - //telegram: "Telegram", + // telegram: "Telegram", discord: "Discord", mattermost: "Mattermost", thankYouText: "Thank you for the feedback, would you like to suggest an improvement?", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 67d0e2ca8..a99a615cd 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -1667,6 +1667,10 @@ type RootTranslation = { * S​e​n​d */ send: string + /** + * T​r​a​n​s​f​e​r + */ + transfer: string /** * S​w​e​e​p​ ​t​o​ ​W​a​l​l​e​t */ @@ -1777,6 +1781,205 @@ type RootTranslation = { */ backupDesc: string } + TopUpScreen: { + /** + * T​o​p​ ​U​p + */ + title: string + /** + * B​a​n​k​ ​T​r​a​n​s​f​e​r + */ + bankTransfer: string + /** + * T​r​a​n​s​f​e​r​ ​f​u​n​d​s​ ​f​r​o​m​ ​y​o​u​r​ ​b​a​n​k​ ​a​c​c​o​u​n​t + */ + bankTransferDesc: string + /** + * D​e​b​i​t​/​C​r​e​d​i​t​ ​C​a​r​d + */ + debitCreditCard: string + /** + * P​a​y​ ​w​i​t​h​ ​y​o​u​r​ ​c​a​r​d​ ​v​i​a​ ​F​y​g​a​r​o + */ + debitCreditCardDesc: string + } + BuyBitcoinDetails: { + /** + * C​a​r​d​ ​P​a​y​m​e​n​t + */ + title: string + /** + * Bank Transfer + */ + bankTransfer: string + /** + * E​m​a​i​l + */ + email: string + /** + * E​n​t​e​r​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s + */ + emailPlaceholder: string + /** + * W​a​l​l​e​t + */ + wallet: string + /** + * S​e​l​e​c​t​ ​w​a​l​l​e​t + */ + walletPlaceholder: string + /** + * A​m​o​u​n​t​ ​(​U​S​D​) + */ + amount: string + /** + * E​n​t​e​r​ ​a​m​o​u​n​t + */ + amountPlaceholder: string + /** + * C​o​n​t​i​n​u​e + */ + 'continue': string + /** + * U​S​D​ ​W​a​l​l​e​t + */ + usdWallet: string + /** + * B​T​C​ ​W​a​l​l​e​t + */ + btcWallet: string + /** + * P​l​e​a​s​e​ ​e​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s + */ + invalidEmail: string + /** + * P​l​e​a​s​e​ ​e​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​a​m​o​u​n​t + */ + invalidAmount: string + /** + * M​i​n​i​m​u​m​ ​a​m​o​u​n​t​ ​i​s​ ​$​1​.​0​0 + */ + minimumAmount: string + } + FygaroWebViewScreen: { + /** + * F​y​g​a​r​o​ ​P​a​y​m​e​n​t + */ + title: string + /** + * L​o​a​d​i​n​g​ ​p​a​y​m​e​n​t​ ​p​a​g​e​.​.​. + */ + loading: string + /** + * F​a​i​l​e​d​ ​t​o​ ​l​o​a​d​ ​p​a​y​m​e​n​t​ ​p​a​g​e + */ + error: string + /** + * R​e​t​r​y + */ + retry: string + } + BankTransfer: { + /** + * Bank Transfer + */ + title: string + /** + * Your order has been created. To complete the order, please transfer ${amount} USD to the bank details provided below. + * @param {number} amount + */ + desc1: string + /** + * Use {code} as the reference description. This unique code will help us associate the payment with your Flash account and process the Bitcoin transfer. + * @param {string} code + */ + desc2: string + /** + * After we have received your payment, you will be credited with ${amount} USD in your Cash wallet, with a ${fee} USD fee deducted. You can then choose when you convert those USD to Bitcoin on your own using the Convert functionality in the mobile app. + * @param {number} amount + * @param {number} fee + */ + desc3: string + /** + * Account Type + */ + accountType: string + /** + * Destination Bank + */ + destinationBank: string + /** + * Account Number + */ + accountNumber: string + /** + * Type of Client + */ + typeOfClient: string + /** + * Receiver's Name + */ + receiverName: string + /** + * Email + */ + email: string + /** + * Amount + */ + amount: string + /** + * Unique Code + */ + uniqueCode: string + /** + * Fees + */ + fees: string + /** + * After payment completion on your end you can send us an email to {email} with a screenshot of your payment confirmation. + * @param {number} email + */ + desc4: string + /** + * Your payment will be processed even if we don't receive this email, but having this confirmation can help accelerate the order. + */ + desc5: string + /** + * Back to Home + */ + backHome: string + } + PaymentSuccessScreen: { + /** + * P​a​y​m​e​n​t​ ​S​u​c​c​e​s​s​f​u​l + */ + title: string + /** + * Y​o​u​r​ ​p​a​y​m​e​n​t​ ​h​a​s​ ​b​e​e​n​ ​p​r​o​c​e​s​s​e​d​ ​s​u​c​c​e​s​s​f​u​l​l​y + */ + successMessage: string + /** + * A​m​o​u​n​t​ ​S​e​n​t + */ + amountSent: string + /** + * D​e​p​o​s​i​t​e​d​ ​t​o + */ + depositedTo: string + /** + * T​r​a​n​s​a​c​t​i​o​n​ ​I​D + */ + transactionId: string + /** + * D​o​n​e + */ + done: string + /** + * V​i​e​w​ ​T​r​a​n​s​a​c​t​i​o​n + */ + viewTransaction: string + } PinScreen: { /** * I​n​c​o​r​r​e​c​t​ ​P​I​N​.​ ​{​a​t​t​e​m​p​t​s​R​e​m​a​i​n​i​n​g​}​ ​a​t​t​e​m​p​t​s​ ​r​e​m​a​i​n​i​n​g​. @@ -3508,6 +3711,22 @@ type RootTranslation = { * %​ ​t​o​ ​c​o​n​v​e​r​t */ percentageToConvert: string + /** + * T​o​p​ ​U​p + */ + topUp: string + /** + * A​d​d​ ​f​u​n​d​s​ ​t​o​ ​y​o​u​r​ ​w​a​l​l​e​t + */ + topUpDesc: string + /** + * S​e​t​t​l​e + */ + settle: string + /** + * S​e​t​t​l​e​ ​p​e​n​d​i​n​g​ ​t​r​a​n​s​a​c​t​i​o​n​s + */ + settleDesc: string } UpgradeAccountModal: { /** @@ -6399,6 +6618,10 @@ export type TranslationFunctions = { * Send */ send: () => LocalizedString + /** + * Transfer + */ + transfer: () => LocalizedString /** * Sweep to Wallet */ @@ -6509,6 +6732,205 @@ export type TranslationFunctions = { */ backupDesc: () => LocalizedString } + TopUpScreen: { + /** + * Top Up + */ + title: () => LocalizedString + /** + * Bank Transfer + */ + bankTransfer: () => LocalizedString + /** + * Transfer funds from your bank + */ + bankTransferDesc: () => LocalizedString + /** + * Debit/Credit Card + */ + debitCreditCard: () => LocalizedString + /** + * Pay with your debit or credit card + */ + debitCreditCardDesc: () => LocalizedString + } + BuyBitcoinDetails: { + /** + * Card Payment + */ + title: () => LocalizedString + /** + * Bank Transfer + */ + bankTransfer: () => LocalizedString + /** + * Email + */ + email: () => LocalizedString + /** + * Enter your email address + */ + emailPlaceholder: () => LocalizedString + /** + * Wallet + */ + wallet: () => LocalizedString + /** + * Select wallet + */ + walletPlaceholder: () => LocalizedString + /** + * Amount (USD) + */ + amount: () => LocalizedString + /** + * Enter amount + */ + amountPlaceholder: () => LocalizedString + /** + * Continue + */ + 'continue': () => LocalizedString + /** + * USD Wallet + */ + usdWallet: () => LocalizedString + /** + * BTC Wallet + */ + btcWallet: () => LocalizedString + /** + * Please enter a valid email address + */ + invalidEmail: () => LocalizedString + /** + * Please enter a valid amount + */ + invalidAmount: () => LocalizedString + /** + * Minimum amount is $1.00 + */ + minimumAmount: () => LocalizedString + } + FygaroWebViewScreen: { + /** + * Fygaro Payment + */ + title: () => LocalizedString + /** + * Loading payment page... + */ + loading: () => LocalizedString + /** + * Failed to load payment page + */ + error: () => LocalizedString + /** + * Retry + */ + retry: () => LocalizedString + } + BankTransfer: { + /** + * Bank Transfer + */ + title: () => LocalizedString + /** + * Your order has been created. To complete the order, please transfer ${amount} USD to the bank details provided below. + * @param {number} amount + */ + desc1: (arg: { amount: number }) => LocalizedString + /** + * Use {code} as the reference description. This unique code will help us associate the payment with your Flash account and process the Bitcoin transfer. + * @param {string} code + */ + desc2: (arg: { code: string }) => LocalizedString + /** + * After we have received your payment, you will be credited with ${amount} USD in your Cash wallet, with a ${fee} USD fee deducted. You can then choose when you convert those USD to Bitcoin on your own using the Convert functionality in the mobile app. + * @param {number} amount + * @param {number} fee + */ + desc3: (arg: { amount: number, fee: number }) => LocalizedString + /** + * Account Type + */ + accountType: () => LocalizedString + /** + * Destination Bank + */ + destinationBank: () => LocalizedString + /** + * Account Number + */ + accountNumber: () => LocalizedString + /** + * Type of Client + */ + typeOfClient: () => LocalizedString + /** + * Receiver's Name + */ + receiverName: () => LocalizedString + /** + * Email + */ + email: () => LocalizedString + /** + * Amount + */ + amount: () => LocalizedString + /** + * Unique Code + */ + uniqueCode: () => LocalizedString + /** + * Fees + */ + fees: () => LocalizedString + /** + * After payment completion on your end you can send us an email to {email} with a screenshot of your payment confirmation. + * @param {string} email + */ + desc4: (arg: { email: string }) => LocalizedString + /** + * Your payment will be processed even if we don't receive this email, but having this confirmation can help accelerate the order. + */ + desc5: () => LocalizedString + /** + * Back to Home + */ + backHome: () => LocalizedString + } + PaymentSuccessScreen: { + /** + * Payment Successful + */ + title: () => LocalizedString + /** + * Your payment has been processed successfully + */ + successMessage: () => LocalizedString + /** + * Amount Sent + */ + amountSent: () => LocalizedString + /** + * Deposited to + */ + depositedTo: () => LocalizedString + /** + * Transaction ID + */ + transactionId: () => LocalizedString + /** + * Done + */ + done: () => LocalizedString + /** + * View Transaction + */ + viewTransaction: () => LocalizedString + } PinScreen: { /** * Incorrect PIN. {attemptsRemaining} attempts remaining. @@ -8191,6 +8613,22 @@ export type TranslationFunctions = { * % to convert */ percentageToConvert: () => LocalizedString + /** + * Top Up + */ + topUp: () => LocalizedString + /** + * Add funds to your wallet + */ + topUpDesc: () => LocalizedString + /** + * Settle + */ + settle: () => LocalizedString + /** + * Cashout funds from your wallet + */ + settleDesc: () => LocalizedString } UpgradeAccountModal: { /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 1416165e3..e9de6c403 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -506,6 +506,7 @@ "balance": "Refresh Balance", "showQrCode": "Topup via QR", "send": "Send", + "transfer": "Transfer", "sweep": "Sweep to Wallet", "pay": "Pay", "title": "Home", @@ -534,6 +535,62 @@ "backupTitle": "Backup your BTC wallet", "backupDesc": "Backup and secure your Bitcoin wallet using recovery phrase." }, + "TopUpScreen": { + "title": "Top Up", + "bankTransfer": "Bank Transfer", + "bankTransferDesc": "Transfer funds from your bank", + "debitCreditCard": "Debit/Credit Card", + "debitCreditCardDesc": "Pay with your debit or credit card" + }, + "BuyBitcoinDetails": { + "title": "Card Payment", + "bankTransfer": "Bank Transfer", + "email": "Email", + "emailPlaceholder": "Enter your email address", + "wallet": "Wallet", + "walletPlaceholder": "Select wallet", + "amount": "Amount (USD)", + "amountPlaceholder": "Enter amount", + "continue": "Continue", + "usdWallet": "USD Wallet", + "btcWallet": "BTC Wallet", + "invalidEmail": "Please enter a valid email address", + "invalidAmount": "Please enter a valid amount", + "minimumAmount": "Minimum amount is $1.00" + }, + "FygaroWebViewScreen": { + "title": "Fygaro Payment", + "loading": "Loading payment page...", + "error": "Failed to load payment page", + "retry": "Retry" + }, + "BankTransfer": { + "title": "Bank Transfer", + "desc1": "Your order has been created. To complete the order, please transfer ${amount: number} USD to the bank details provided below.", + "desc2": "Use {code: string} as the reference description. This unique code will help us associate the payment with your Flash account and process the Bitcoin transfer.", + "desc3": "After we have received your payment, you will be credited with ${amount: number} USD in your Cash wallet, with a ${fee: number} USD fee deducted. You can then choose when you convert those USD to Bitcoin on your own using the Convert functionality in the mobile app.", + "accountType": "Account Type", + "destinationBank": "Destination Bank", + "accountNumber": "Account Number", + "typeOfClient": "Type of Client", + "receiverName": "Receiver's Name", + "email": "Email", + "amount": "Amount", + "uniqueCode": "Unique Code", + "fees": "Fees", + "desc4": "After payment completion on your end you can send us an email to {email: string} with a screenshot of your payment confirmation.", + "desc5": "Your payment will be processed even if we don't receive this email, but having this confirmation can help accelerate the order.", + "backHome": "Back to Home" + }, + "PaymentSuccessScreen": { + "title": "Payment Successful", + "successMessage": "Your payment has been processed successfully", + "amountSent": "Amount Sent", + "depositedTo": "Deposited to", + "transactionId": "Transaction ID", + "done": "Done", + "viewTransaction": "View Transaction" + }, "PinScreen": { "attemptsRemaining": "Incorrect PIN. {attemptsRemaining: number} attempts remaining.", "oneAttemptRemaining": "Incorrect PIN. 1 attempt remaining.", @@ -703,7 +760,6 @@ "amount": "Amount", "MinOnChainLimit": "Minimum amount for this transaction is US$2.00", "MinOnChainSatLimit": "Minimum amount for this transaction is 5,500 sats", - "MinFlashcardLimit": "Minimum amount when reloading from flashcard is 100 sats", "amountExceed": "Amount exceeds your balance of {balance: string}", "amountExceedsLimit": "Amount exceeds your remaining daily limit of {limit: string}", "upgradeAccountToIncreaseLimit": "Upgrade your account to increase your limit", @@ -1011,7 +1067,11 @@ }, "TransferScreen": { "title": "Transfer", - "percentageToConvert": "% to convert" + "percentageToConvert": "% to convert", + "topUp": "Top Up", + "topUpDesc": "Add funds to your wallet", + "settle": "Settle", + "settleDesc": "Cashout funds from your wallet" }, "UpgradeAccountModal": { "title": "Upgrade your account", diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 5ccb6108e..dc9a9d604 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -116,6 +116,14 @@ import { } from "@app/screens/cashout-screen" import { NostrSettingsScreen } from "@app/screens/settings-screen/nostr-settings/nostr-settings-screen" import ContactDetailsScreen from "@app/screens/nip17-chat/contactDetailsScreen" +import { + BankTransfer, + BuyBitcoin, + BuyBitcoinDetails, + BuyBitcoinSuccess, + BuySellBitcoin, + CardPayment, +} from "@app/screens/buy-bitcoin-flow" const useStyles = makeStyles(({ colors }) => ({ bottomNavigatorStyle: { @@ -575,6 +583,23 @@ export const RootStack = () => { cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, }} /> + + + + + + + + ) } diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index 58e3530fa..0987f5fe1 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -151,6 +151,23 @@ export type RootStackParamList = { EditNostrProfile: undefined NostrSettingsScreen: undefined SignInViaQRCode: undefined + BuySellBitcoin: undefined + BuyBitcoin: undefined + BuyBitcoinDetails: { paymentType: "card" | "bankTransfer" } + BankTransfer: { + amount: number + wallet: string + } + CardPayment: { + amount: number + wallet: string + } + BuyBitcoinSuccess: undefined + paymentSuccess: { + amount: number + wallet: string + transactionId: string + } } export type ChatStackParamList = { diff --git a/app/screens/buy-bitcoin-flow/BankTransfer.tsx b/app/screens/buy-bitcoin-flow/BankTransfer.tsx new file mode 100644 index 000000000..c5e4d96d9 --- /dev/null +++ b/app/screens/buy-bitcoin-flow/BankTransfer.tsx @@ -0,0 +1,149 @@ +import React from "react" +import { View } from "react-native" +import { makeStyles, Text } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +// hooks +import { useI18nContext } from "@app/i18n/i18n-react" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +// components +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" + +type Props = StackScreenProps + +const BankTransfer: React.FC = ({ navigation, route }) => { + const styles = useStyles() + const { bottom } = useSafeAreaInsets() + const { LL } = useI18nContext() + + const { email, amount, wallet } = route.params + const fee = amount * 0.02 + + return ( + + + {LL.BankTransfer.title()} + + + {LL.BankTransfer.desc1({ amount: amount + fee })} + + + {LL.BankTransfer.desc2({ code: "UUM7MJRD" })} + + + {LL.BankTransfer.desc3({ amount: amount, fee: fee })} + + + + {LL.BankTransfer.accountType()} + + Checking + + + + {LL.BankTransfer.destinationBank()} + + Banco Hipotecario + + + + {LL.BankTransfer.accountNumber()} + + 00210312362 + + + + {LL.BankTransfer.typeOfClient()} + + Corporate + + + + {LL.BankTransfer.receiverName()} + + BBW SA de CV + + + + {LL.BankTransfer.email()} + + fiat@blink.sv + + + + {LL.BankTransfer.amount()} + + {`${amount + fee} USD`} + + + + {LL.BankTransfer.uniqueCode()} + + UUM7MJRD + + + + {LL.BankTransfer.fees()} + + {`${fee} USD`} + + + + + {LL.BankTransfer.desc4({ email: "fiat@blink.sv" })} + + + {LL.BankTransfer.desc5()} + + navigation.reset({ index: 0, routes: [{ name: "Primary" }] })} + btnStyle={{ marginBottom: bottom + 20, marginTop: 20 }} + /> + + ) +} + +export default BankTransfer + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + title: { + textAlign: "center", + marginBottom: 30, + }, + desc: { + marginBottom: 15, + }, + bankDetails: { + marginVertical: 20, + }, + fieldContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: colors.grey5, + padding: 20, + borderRadius: 10, + marginBottom: 15, + }, + input: { + borderWidth: 1, + borderColor: colors.grey3, + borderRadius: 12, + padding: 16, + fontSize: 16, + backgroundColor: colors.white, + color: colors.black, + marginTop: 8, + }, + buttonGroup: { + marginTop: 8, + }, +})) diff --git a/app/screens/buy-bitcoin-flow/BuyBitcoin.tsx b/app/screens/buy-bitcoin-flow/BuyBitcoin.tsx new file mode 100644 index 000000000..fc0f7b2f5 --- /dev/null +++ b/app/screens/buy-bitcoin-flow/BuyBitcoin.tsx @@ -0,0 +1,86 @@ +import React from "react" +import { TouchableOpacity, View } from "react-native" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { Icon, Text, makeStyles, useTheme } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { useI18nContext } from "@app/i18n/i18n-react" + +// components +import { Screen } from "@app/components/screen" + +type Props = StackScreenProps + +const BuyBitcoin: React.FC = ({ navigation }) => { + const styles = useStyles() + const { LL } = useI18nContext() + const { colors } = useTheme().theme + + return ( + + + + {LL.TopUpScreen.title()} + + + navigation.navigate("BuyBitcoinDetails", { paymentType: "card" }) + } + > + + + {LL.TopUpScreen.debitCreditCard()} + + {LL.TopUpScreen.debitCreditCardDesc()} + + + + + + navigation.navigate("BuyBitcoinDetails", { paymentType: "bankTransfer" }) + } + > + + + + {LL.TopUpScreen.bankTransfer()} + + {LL.TopUpScreen.bankTransferDesc()} + + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + title: { + textAlign: "center", + marginBottom: 30, + }, + btn: { + flexDirection: "row", + alignItems: "center", + borderRadius: 20, + borderWidth: 1, + borderColor: "#dedede", + marginBottom: 20, + minHeight: 100, + paddingHorizontal: 20, + }, + btnTextWrapper: { + flex: 1, + rowGap: 5, + marginHorizontal: 15, + }, +})) + +export default BuyBitcoin diff --git a/app/screens/buy-bitcoin-flow/BuyBitcoinDetails.tsx b/app/screens/buy-bitcoin-flow/BuyBitcoinDetails.tsx new file mode 100644 index 000000000..be83593e7 --- /dev/null +++ b/app/screens/buy-bitcoin-flow/BuyBitcoinDetails.tsx @@ -0,0 +1,210 @@ +/** + * BuyBitcoinDetails Component + * + * This screen collects payment details before initiating the topup flow. + * Users select: + * 1. Target wallet (USD or BTC) + * 2. Amount to topup + * + * Previously, this screen also collected email address, but that was removed + * to avoid double entry - users now enter email directly on Fygaro's form. + * + * The component supports both card payments (Fygaro) and bank transfers, + * routing to the appropriate flow based on the selected payment type. + */ + +import React, { useState } from "react" +import { View, TextInput, Alert } from "react-native" +import { Text, makeStyles, useTheme } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +// components +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" +import { ButtonGroup } from "@app/components/button-group" + +// hooks +import { useI18nContext } from "@app/i18n/i18n-react" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +// assets +import Cash from "@app/assets/icons/cash.svg" +import Bitcoin from "@app/assets/icons/bitcoin.svg" + +type Props = StackScreenProps + +const BuyBitcoinDetails: React.FC = ({ navigation, route }) => { + const { colors } = useTheme().theme + const { LL } = useI18nContext() + const { bottom } = useSafeAreaInsets() + const styles = useStyles()({ bottom }) + + /** + * Component state: + * - selectedWallet: Which wallet to credit (USD or BTC) + * - amount: Topup amount in USD + * - isLoading: Loading state for navigation + * + * NOTE: Email field was removed to prevent double entry. + * Users enter email on Fygaro's payment form instead. + */ + const [selectedWallet, setSelectedWallet] = useState("USD") + const [amount, setAmount] = useState("") + const [isLoading, setIsLoading] = useState(false) + + /** + * Validates the entered amount. + * Minimum topup amount is $1.00 to prevent micro-transactions + * that would be unprofitable due to processing fees. + */ + const validateAmount = (amount: string): boolean => { + const numAmount = parseFloat(amount) + return !isNaN(numAmount) && numAmount >= 1.0 + } + + /** + * Handles the continue button press. + * + * Validates amount and navigates to the appropriate payment flow: + * - Card payment: Goes to CardPayment (WebView with Fygaro) + * - Bank transfer: Goes to BankTransfer screen + * + * The wallet type and amount are passed to the next screen. + * The wallet type will be included in the webhook metadata + * to ensure the correct wallet is credited. + */ + const handleContinue = async () => { + if (!validateAmount(amount)) { + Alert.alert("Invalid Amount", LL.BuyBitcoinDetails.minimumAmount()) + return + } + + setIsLoading(true) + + try { + if (route.params.paymentType === "bankTransfer") { + navigation.navigate("BankTransfer", { + amount: parseFloat(amount), + wallet: selectedWallet, + }) + } else { + // Card payment flow via Fygaro WebView + navigation.navigate("CardPayment", { + amount: parseFloat(amount), + wallet: selectedWallet, // Will be sent to webhook via metadata + }) + } + } catch (error) { + Alert.alert("Error", "Failed to initiate payment. Please try again.") + } finally { + setIsLoading(false) + } + } + + /** + * Wallet selection buttons configuration. + * + * Users can choose to credit either: + * - USD wallet: Fiat balance for USD transactions + * - BTC wallet: Bitcoin balance (amount converted at current rate) + * + * The selected wallet type is passed through the payment flow + * and included in the webhook metadata to ensure correct crediting. + */ + const walletButtons = [ + { + id: "USD", + text: LL.BuyBitcoinDetails.usdWallet(), + icon: { + selected: , + normal: , + }, + }, + { + id: "BTC", + text: LL.BuyBitcoinDetails.btcWallet(), + icon: { + selected: , + normal: , + }, + }, + ] + + return ( + + + + {route.params.paymentType === "card" + ? LL.BuyBitcoinDetails.title() + : LL.BuyBitcoinDetails.bankTransfer()} + + + + + {LL.BuyBitcoinDetails.wallet()} + + + + + + + {LL.BuyBitcoinDetails.amount()} + + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => (props: { bottom: number }) => ({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + title: { + textAlign: "center" as const, + marginBottom: 30, + }, + fieldContainer: { + marginBottom: 24, + }, + input: { + borderWidth: 1, + borderColor: colors.grey3, + borderRadius: 12, + padding: 16, + fontSize: 16, + backgroundColor: colors.white, + color: colors.black, + marginTop: 8, + }, + buttonGroup: { + marginTop: 8, + }, + primaryButton: { + marginHorizontal: 20, + marginBottom: Math.max(20, props.bottom), + }, +})) + +export default BuyBitcoinDetails diff --git a/app/screens/buy-bitcoin-flow/BuyBitcoinSuccess.tsx b/app/screens/buy-bitcoin-flow/BuyBitcoinSuccess.tsx new file mode 100644 index 000000000..614daba8a --- /dev/null +++ b/app/screens/buy-bitcoin-flow/BuyBitcoinSuccess.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { View } from "react-native" +import { makeStyles, Text, useTheme } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +// components +import { + SuccessIconAnimation, + SuccessTextAnimation, +} from "@app/components/success-animation" +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" +import { GaloyIcon } from "@app/components/atomic/galoy-icon" + +// hooks +import { useI18nContext } from "@app/i18n/i18n-react" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +type Props = StackScreenProps + +const BuyBitcoinSuccess: React.FC = () => { + const styles = useStyles() + const { colors } = useTheme().theme + const { LL } = useI18nContext() + const { bottom } = useSafeAreaInsets() + + const onPressDone = () => {} + + return ( + + + + + + + + USD Wallet Topped Up + + + $200 + + + J$35.11 + + + + + + ) +} + +export default BuyBitcoinSuccess + +const useStyles = makeStyles(() => ({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + successText: { + textAlign: "center", + color: "#fff", + marginBottom: 8, + }, +})) diff --git a/app/screens/buy-bitcoin-flow/BuySellBitcoin.tsx b/app/screens/buy-bitcoin-flow/BuySellBitcoin.tsx new file mode 100644 index 000000000..f2a4c3169 --- /dev/null +++ b/app/screens/buy-bitcoin-flow/BuySellBitcoin.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { TouchableOpacity, View } from "react-native" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { Icon, Text, makeStyles, useTheme } from "@rneui/themed" +import { StackScreenProps } from "@react-navigation/stack" +import { useI18nContext } from "@app/i18n/i18n-react" + +// components +import { Screen } from "@app/components/screen" + +// assets +import ArrowDown from "@app/assets/icons/arrow-down-to-bracket.svg" +import ArrowUp from "@app/assets/icons/arrow-up-from-bracket.svg" + +type Props = StackScreenProps + +const BuySellBitcoin: React.FC = ({ navigation }) => { + const styles = useStyles() + const { LL } = useI18nContext() + const { colors } = useTheme().theme + + const handleTopUp = () => { + navigation.navigate("BuyBitcoin") + } + + const handleSettle = () => { + navigation.navigate("CashoutDetails") + } + + return ( + + + + {LL.TransferScreen.title()} + + + + + {LL.TransferScreen.topUp()} + + {LL.TransferScreen.topUpDesc()} + + + + + + + + {LL.TransferScreen.settle()} + + {LL.TransferScreen.settleDesc()} + + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + title: { + textAlign: "center", + marginBottom: 30, + }, + btn: { + flexDirection: "row", + alignItems: "center", + borderRadius: 20, + borderWidth: 1, + borderColor: "#dedede", + marginBottom: 20, + minHeight: 100, + paddingHorizontal: 20, + }, + btnTextWrapper: { + flex: 1, + rowGap: 5, + marginHorizontal: 15, + }, +})) + +export default BuySellBitcoin diff --git a/app/screens/buy-bitcoin-flow/CardPayment.tsx b/app/screens/buy-bitcoin-flow/CardPayment.tsx new file mode 100644 index 000000000..1104a936b --- /dev/null +++ b/app/screens/buy-bitcoin-flow/CardPayment.tsx @@ -0,0 +1,374 @@ +/** + * CardPayment Component + * + * This component handles the web-based card payment flow for topping up Flash wallets. + * It embeds external payment provider forms (Fygaro, PayPal, Stripe) in a WebView + * and manages the entire payment lifecycle. + * + * Key features: + * - Embeds Fygaro payment form (and other providers) in WebView + * - Prevents iOS zoom issues when users tap input fields + * - Domain whitelisting for security + * - Handles payment success/failure callbacks + * - Desktop user agent spoofing on iOS to prevent mobile-specific issues + */ + +import React, { useState, useRef } from "react" +import { View, ActivityIndicator, Alert, Platform } from "react-native" +import { StackScreenProps } from "@react-navigation/stack" +import { Text, makeStyles, useTheme } from "@rneui/themed" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { WebView } from "react-native-webview" +import { Screen } from "@app/components/screen" +import { PrimaryBtn } from "@app/components/buttons" +import { useI18nContext } from "@app/i18n/i18n-react" +import { useHomeAuthedQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" + +type Props = StackScreenProps + +const CardPayment: React.FC = ({ navigation, route }) => { + const isAuthed = useIsAuthed() + const styles = useStyles() + const { colors } = useTheme().theme + const { LL } = useI18nContext() + const webViewRef = useRef(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(false) + + // Get authenticated user data to extract username for webhook processing + const { data } = useHomeAuthedQuery({ skip: !isAuthed, fetchPolicy: "cache-first" }) + const { amount, wallet } = route.params + const username = data?.me?.username || "user" + + /** + * Build Fygaro payment URL with critical parameters: + * - amount: The payment amount in USD + * - client_reference: Flash username (CRITICAL: used by webhook to identify user) + * + * NOTE: The user will enter their email directly on Fygaro's form to avoid + * double entry. The email is only for Fygaro's records, not for Flash processing. + */ + const paymentUrl = `https://fygaro.com/en/pb/bd4a34c1-3d24-4315-a2b8-627518f70916?amount=${amount}&client_reference=${username}` + + /** + * Domain whitelist for security. + * + * Only allows navigation to trusted payment provider domains. + * This prevents: + * - Phishing attacks via redirect + * - Malicious JavaScript injection + * - Data exfiltration to unauthorized domains + * + * Includes all necessary domains for: + * - Fygaro (primary provider) + * - PayPal (Fygaro uses PayPal for processing) + * - Stripe (future provider support) + */ + const allowedDomains = [ + "fygaro.com", + "www.fygaro.com", + "api.fygaro.com", + "checkout.fygaro.com", + // PayPal domains (required for Fygaro's PayPal integration) + "www.paypal.com", + "checkout.paypal.com", + "paypalobjects.com", + "paypal-activation.com", + "paypal.com", + "paypal-cdn.com", + "paypal-experience.com", + "paypal-dynamic-assets.com", + "paypalcorp.com", + // Stripe domains (for future integration) + "stripe.com", + "checkout.stripe.com", + "js.stripe.com", + "m.stripe.com", + ] + + /** + * Monitors URL changes to detect payment completion. + * + * Payment providers redirect to specific URLs on success/failure: + * - Success: URL contains "success" or "payment_success" + * - Failure: URL contains "error", "failed", or "cancelled" + * + * On success: Navigate to success screen (webhook will handle actual crediting) + * On failure: Show error alert and allow retry + * + * NOTE: The actual account crediting happens via webhook on the backend. + * This frontend handling is just for UX feedback. + */ + const handleNavigationStateChange = ({ url }: { url: string }) => { + if (url.includes("success") || url.includes("payment_success")) { + // Payment succeeded - navigate to success screen + // The webhook will handle the actual wallet credit + navigation.navigate("paymentSuccess", { + amount, + wallet, + transactionId: `txn_${Date.now()}`, // Temporary ID for UI + }) + } else if ( + url.includes("error") || + url.includes("failed") || + url.includes("cancelled") + ) { + // Payment failed - show error and allow retry + Alert.alert("Payment Failed", "Your payment was not completed. Please try again.", [ + { text: "OK", onPress: () => navigation.goBack() }, + ]) + } + } + + /** + * Validates if a URL is from an allowed domain. + * + * Security function that checks URLs against our whitelist. + * Handles: + * - Exact domain matches (paypal.com) + * - Subdomain matches (checkout.paypal.com) + * - Invalid URLs (returns false) + * + * This prevents navigation to untrusted domains. + */ + const isAllowedDomain = (url: string): boolean => { + try { + const domain = new URL(url).hostname + return allowedDomains.some( + (allowed) => + domain === allowed || // Exact match + domain.endsWith(`.${allowed}`) || // Subdomain match + allowed.endsWith(`.${domain}`), // Parent domain match + ) + } catch { + // Invalid URL - deny by default + return false + } + } + + const handleRetry = () => { + setError(false) + setIsLoading(true) + webViewRef.current?.reload() + } + + if (error) { + return ( + + + + {LL.FygaroWebViewScreen.error()} + + + + + ) + } + + return ( + + + {isLoading && ( + + + + {LL.FygaroWebViewScreen.loading()} + + + )} + { + setIsLoading(false) + setError(false) + }} + onError={() => { + setIsLoading(false) + setError(true) + }} + /** + * URL navigation filter for security and iOS compatibility. + * + * This callback determines which URLs the WebView can navigate to. + * Platform-specific logic: + * + * iOS: + * - Allows about:, blob:, data: schemes (prevents warnings) + * - Allows all HTTPS URLs (PayPal requires many domains) + * - Blocks HTTP to enforce encryption + * + * Android: + * - Uses strict domain whitelist + * - More restrictive but more secure + * + * The iOS approach is less restrictive due to PayPal's complex + * redirect flow that uses many subdomains not in our whitelist. + */ + onShouldStartLoadWithRequest={({ url }) => { + // Allow about: URLs (used for iframes) to prevent iOS warnings + if (url.startsWith("about:")) { + return true + } + + // Allow blob: and data: URLs for embedded content + if (url.startsWith("blob:") || url.startsWith("data:")) { + return true + } + + // iOS: Allow HTTPS and internal URLs + // Less restrictive due to PayPal's complex domain requirements + if (Platform.OS === "ios") { + return url.startsWith("https://") || !url.startsWith("http") + } + + // Android: Use domain whitelist for stricter security + return isAllowedDomain(url) + }} + style={styles.webView} + javaScriptEnabled + domStorageEnabled + startInLoadingState + scalesPageToFit={false} + bounces={false} + scrollEnabled + allowsBackForwardNavigationGestures + allowsInlineMediaPlayback + allowsFullscreenVideo={false} + allowFileAccess={false} + allowUniversalAccessFromFileURLs={false} + mixedContentMode="never" + originWhitelist={["https://*", "http://*", "about:*", "data:*", "blob:*"]} + sharedCookiesEnabled + thirdPartyCookiesEnabled + cacheEnabled + incognito={false} + webviewDebuggingEnabled={__DEV__} + automaticallyAdjustContentInsets={false} + contentInsetAdjustmentBehavior="never" + allowsLinkPreview={false} + injectedJavaScriptForMainFrameOnly + /** + * Custom User Agent for iOS to prevent zoom issues. + * + * CRITICAL: This desktop user agent prevents iOS WebView from: + * - Auto-zooming when users tap input fields + * - Showing mobile-specific layouts that break + * - Triggering viewport zoom on focus + * + * Without this, iOS WebView zooms in when users tap the + * payment form fields, creating a poor user experience. + * + * Android doesn't need this workaround. + */ + userAgent={ + Platform.OS === "ios" + ? "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + : undefined + } + dataDetectorTypes="none" + /** + * Pre-content JavaScript injection for early zoom prevention. + * + * Executes BEFORE the page content loads to: + * - Force viewport meta tag with no zoom + * - Set touch-action CSS to prevent pinch zoom + * - Run on multiple events to catch dynamic content + * + * This is the first line of defense against iOS zoom issues. + */ + injectedJavaScriptBeforeContentLoaded={`(function(){const forceViewport=()=>{const content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no';document.querySelectorAll('meta[name="viewport"]').forEach(m=>m.remove());const meta=document.createElement('meta');meta.name='viewport';meta.content=content;if(document.head){document.head.insertBefore(meta,document.head.firstChild)}else{document.documentElement.appendChild(meta)}};forceViewport();document.addEventListener('DOMContentLoaded',forceViewport);window.addEventListener('load',forceViewport);if(document.documentElement){document.documentElement.style.touchAction='pan-x pan-y'}true})();`} + /** + * Post-content JavaScript injection for comprehensive zoom prevention. + * + * This aggressive approach handles: + * 1. Viewport meta tag enforcement + * 2. CSS styles to prevent zoom (16px font size is key) + * 3. Focus event handlers to reset zoom on input focus + * 4. MutationObserver to handle dynamically added inputs + * 5. Double-tap prevention + * + * The 16px font size is critical - iOS auto-zooms on inputs + * with font size less than 16px. + * + * Combined with desktop user agent, this completely prevents + * the iOS zoom issue that occurs when users tap payment form fields. + */ + injectedJavaScript={`(function(){ + // Zoom prevention code only + document.querySelectorAll('meta[name="viewport"]').forEach(m=>m.remove()); + const meta=document.createElement('meta'); + meta.name='viewport'; + meta.content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'; + document.head.insertBefore(meta,document.head.firstChild); + + const style=document.createElement('style'); + style.innerHTML='*{-webkit-text-size-adjust:100%!important;text-size-adjust:100%!important;-webkit-user-select:none!important;user-select:none!important;touch-action:manipulation!important}html,body{touch-action:pan-x pan-y!important;overflow-x:hidden!important}input,textarea,select,button{font-size:16px!important;transform:none!important;zoom:reset!important;-webkit-appearance:none!important}input:focus,textarea:focus,select:focus{font-size:16px!important;zoom:1!important}'; + document.head.appendChild(style); + + const preventZoom=e=>{if(e.target&&(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')){e.target.style.fontSize='16px';e.target.style.transform='scale(1)';e.target.style.zoom='1';const oldMeta=document.querySelector('meta[name="viewport"]');if(oldMeta){oldMeta.content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'}}}; + document.addEventListener('focusin',preventZoom,true); + document.addEventListener('focus',preventZoom,true); + + // Watch for dynamically added inputs and apply zoom prevention + const observer=new MutationObserver(mutations=>{ + mutations.forEach(mutation=>{ + mutation.addedNodes.forEach(node=>{ + if(node.nodeType===1){ + const inputs=node.querySelectorAll?node.querySelectorAll('input, textarea, select'):[]; + inputs.forEach(input=>{ + input.style.fontSize='16px'; + input.addEventListener('focus',preventZoom,true); + }); + } + }) + }) + }); + + if(document.body){observer.observe(document.body,{childList:true,subtree:true})} + + document.querySelectorAll('input, textarea, select').forEach(el=>{el.style.fontSize='16px';el.addEventListener('focus',preventZoom,true)}); + + let lastTouchEnd=0; + document.addEventListener('touchend',e=>{const now=Date.now();if(now-lastTouchEnd<=300){e.preventDefault()}lastTouchEnd=now},{passive:false}); + + true + })();`} + /> + + + ) +} + +const useStyles = makeStyles(() => ({ + container: { flex: 1 }, + centerContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + loadingContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(255, 255, 255, 0.9)", + zIndex: 1, + }, + loadingText: { marginTop: 16, textAlign: "center" }, + errorText: { textAlign: "center", marginBottom: 24 }, + retryButton: { marginTop: 16 }, + webView: { flex: 1 }, +})) + +export default CardPayment diff --git a/app/screens/buy-bitcoin-flow/index.ts b/app/screens/buy-bitcoin-flow/index.ts new file mode 100644 index 000000000..8c16ca65b --- /dev/null +++ b/app/screens/buy-bitcoin-flow/index.ts @@ -0,0 +1,15 @@ +import BuySellBitcoin from "./BuySellBitcoin" +import BuyBitcoin from "./BuyBitcoin" +import BuyBitcoinDetails from "./BuyBitcoinDetails" +import BankTransfer from "./BankTransfer" +import CardPayment from "./CardPayment" +import BuyBitcoinSuccess from "./BuyBitcoinSuccess" + +export { + BuySellBitcoin, + BuyBitcoin, + BuyBitcoinDetails, + BankTransfer, + CardPayment, + BuyBitcoinSuccess, +} diff --git a/app/screens/buy-bitcoin-flow/payment-success-screen.tsx b/app/screens/buy-bitcoin-flow/payment-success-screen.tsx new file mode 100644 index 000000000..d6f1cb23e --- /dev/null +++ b/app/screens/buy-bitcoin-flow/payment-success-screen.tsx @@ -0,0 +1,155 @@ +import React from "react" +import { View } from "react-native" +import { useNavigation, useRoute, RouteProp } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { Text, makeStyles, useTheme } from "@rneui/themed" +import { Screen } from "@app/components/screen" +import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { PrimaryBtn } from "@app/components/buttons" + +type PaymentSuccessScreenProps = { + navigation: StackNavigationProp + route: RouteProp +} + +const PaymentSuccessScreen: React.FC = () => { + const { colors } = useTheme().theme + const { LL } = useI18nContext() + const navigation = useNavigation>() + const route = useRoute>() + const styles = useStyles() + + const { amount, wallet, transactionId } = route.params + + const handleDone = () => { + // Navigate back to home screen + navigation.navigate("Primary") + } + + const handleViewTransaction = () => { + // TODO: Navigate to transaction details + console.log("Navigate to transaction details:", transactionId) + handleDone() + } + + return ( + + + + + + + {LL.PaymentSuccessScreen.title()} + + + + {LL.PaymentSuccessScreen.successMessage()} + + + + + + {LL.PaymentSuccessScreen.amountSent()}: + + + ${amount.toFixed(2)} + + + + + + {LL.PaymentSuccessScreen.depositedTo()}: + + + {wallet} Wallet + + + + + + {LL.PaymentSuccessScreen.transactionId()}: + + + {transactionId} + + + + + + + + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + successContainer: { + alignItems: "center", + width: "100%", + }, + successIcon: { + fontSize: 80, + marginBottom: 20, + textAlign: "center", + }, + title: { + textAlign: "center", + marginBottom: 16, + }, + message: { + textAlign: "center", + marginBottom: 32, + }, + detailsContainer: { + width: "100%", + backgroundColor: colors.grey5, + borderRadius: 16, + padding: 20, + marginBottom: 32, + }, + detailRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }, + detailLabel: { + flex: 1, + }, + detailValue: { + fontWeight: "600", + textAlign: "right", + }, + buttonContainer: { + width: "100%", + gap: 16, + }, + primaryButton: { + marginTop: 8, + }, + secondaryButton: { + marginTop: 8, + }, +})) + +export default PaymentSuccessScreen diff --git a/app/screens/cashout-screen/CashoutConfirmation.tsx b/app/screens/cashout-screen/CashoutConfirmation.tsx index 526f77f8c..3b3ecedbd 100644 --- a/app/screens/cashout-screen/CashoutConfirmation.tsx +++ b/app/screens/cashout-screen/CashoutConfirmation.tsx @@ -11,6 +11,7 @@ import { CashoutCard, CashoutFromWallet } from "@app/components/cashout-flow" // hooks import { useI18nContext } from "@app/i18n/i18n-react" +import { useSafeAreaInsets } from "react-native-safe-area-context" import { useActivityIndicator, useDisplayCurrency } from "@app/hooks" import { useCashoutScreenQuery, useInitiateCashoutMutation } from "@app/graphql/generated" @@ -25,8 +26,9 @@ type Props = StackScreenProps const CashoutConfirmation: React.FC = ({ navigation, route }) => { const styles = useStyles() const { colors } = useTheme().theme + const { bottom } = useSafeAreaInsets() const { LL } = useI18nContext() - const { formatMoneyAmount } = useDisplayCurrency() + const { moneyAmountToDisplayCurrencyString, displayCurrency } = useDisplayCurrency() const { toggleActivityIndicator } = useActivityIndicator() const [errorMsg, setErrorMsg] = useState() @@ -64,15 +66,15 @@ const CashoutConfirmation: React.FC = ({ navigation, route }) => { toggleActivityIndicator(false) } - const formattedSendAmount = formatMoneyAmount({ + const formattedSendAmount = moneyAmountToDisplayCurrencyString({ moneyAmount: toUsdMoneyAmount(send ?? NaN), }) - const formattedReceiveUsdAmount = formatMoneyAmount({ + const formattedReceiveUsdAmount = moneyAmountToDisplayCurrencyString({ moneyAmount: toUsdMoneyAmount(receiveUsd ?? NaN), }) - const formattedFeeAmount = formatMoneyAmount({ + const formattedFeeAmount = moneyAmountToDisplayCurrencyString({ moneyAmount: toUsdMoneyAmount(flashFee ?? NaN), }) @@ -83,11 +85,16 @@ const CashoutConfirmation: React.FC = ({ navigation, route }) => { {LL.Cashout.valid({ time: moment(expiresAt).fromNow(true) })} - + {!!errorMsg && ( @@ -98,7 +105,7 @@ const CashoutConfirmation: React.FC = ({ navigation, route }) => { @@ -107,13 +114,9 @@ const CashoutConfirmation: React.FC = ({ navigation, route }) => { export default CashoutConfirmation -const useStyles = makeStyles(({ colors }) => ({ +const useStyles = makeStyles(() => ({ valid: { alignSelf: "center", marginBottom: 10, }, - buttonContainer: { - marginHorizontal: 20, - marginBottom: 20, - }, })) diff --git a/app/screens/cashout-screen/CashoutDetails.tsx b/app/screens/cashout-screen/CashoutDetails.tsx index d1c419ed6..79ef9a0b1 100644 --- a/app/screens/cashout-screen/CashoutDetails.tsx +++ b/app/screens/cashout-screen/CashoutDetails.tsx @@ -24,12 +24,14 @@ import { import { getUsdWallet } from "@app/graphql/wallets-utils" import { View } from "react-native" import { PrimaryBtn } from "@app/components/buttons" +import { useSafeAreaInsets } from "react-native-safe-area-context" type Props = StackScreenProps const CashoutDetails = ({ navigation }: Props) => { const styles = useStyles() const { colors } = useTheme().theme + const { bottom } = useSafeAreaInsets() const { LL } = useI18nContext() const { zeroDisplayAmount } = useDisplayCurrency() const { convertMoneyAmount } = usePriceConversion() @@ -61,7 +63,7 @@ const CashoutDetails = ({ navigation }: Props) => { toggleActivityIndicator(true) const res = await requestCashout({ variables: { - input: { walletId: usdWallet.id, usdAmount: settlementSendAmount.amount }, + input: { walletId: usdWallet.id, amount: settlementSendAmount.amount }, }, }) console.log("Response: ", res.data?.requestCashout) @@ -116,7 +118,7 @@ const CashoutDetails = ({ navigation }: Props) => { @@ -132,8 +134,4 @@ const useStyles = makeStyles(() => ({ flexDirection: "column", margin: 20, }, - buttonContainer: { - marginHorizontal: 20, - marginBottom: 20, - }, }))