diff --git a/CHANGELOG.md b/CHANGELOG.md index 8126149e024..6afce0cdcdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: New Kado OTC provider integration. + ## 4.17.2 - fixed: (Zcash/Pirate) Fixed android build issues diff --git a/package.json b/package.json index eb7f5d062c9..4fe0dd77a46 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "ethers": "^5.7.2", "expo": "^48.0.0", "jsrsasign": "^11.1.0", + "p-debounce": "^4.0.0", "paraswap": "^5.2.0", "posthog-js": "^1.88.1", "posthog-react-native": "^2.8.1", diff --git a/src/components/Main.tsx b/src/components/Main.tsx index 58316790381..fd0812d4751 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -14,6 +14,8 @@ import { useAsyncEffect } from '../hooks/useAsyncEffect' import { useMount } from '../hooks/useMount' import { lstrings } from '../locales/strings' import { AddressFormScene } from '../plugins/gui/scenes/AddressFormScene' +import { ConfirmationScene } from '../plugins/gui/scenes/ConfirmationScene' +import { EmailFormScene } from '../plugins/gui/scenes/EmailFormScene' import { FiatPluginEnterAmountScene as FiatPluginEnterAmountSceneComponent } from '../plugins/gui/scenes/FiatPluginEnterAmountScene' import { FiatPluginWebViewComponent } from '../plugins/gui/scenes/FiatPluginWebView' import { InfoDisplayScene } from '../plugins/gui/scenes/InfoDisplayScene' @@ -296,6 +298,21 @@ const EdgeBuyTabScreen = () => { headerRight: () => null }} /> + null, + headerRight: () => null + }} + /> + null + }} + /> ({ + padding: theme.rem(0.5) +})) diff --git a/src/envConfig.ts b/src/envConfig.ts index 5aee164ff03..fd356f8b2b8 100644 --- a/src/envConfig.ts +++ b/src/envConfig.ts @@ -72,6 +72,12 @@ export const asEnvConfig = asObject({ apiKey: asString }) ), + kadoOtc: asOptional( + asObject({ + apiKey: asString, + apiUserEmail: asString + }) + ), moonpay: asOptional(asString), mtpelerin: asOptional(asString), paybis: asOptional( @@ -102,6 +108,7 @@ export const asEnvConfig = asObject({ banxa: undefined, Bitrefill: undefined, kado: undefined, + kadoOtc: undefined, moonpay: undefined, mtpelerin: undefined, paybis: undefined, diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 55653cf1ea5..92a9d24564e 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -1533,6 +1533,7 @@ const strings = { form_field_title_address_line_2: 'Address Line 2 (optional)', form_field_title_address_state_province_region: 'Province', form_field_title_address_zip_postal_code: 'Postal Code/Zip', + form_field_title_email_address: 'Email Address', form_field_title_iban: 'IBAN', form_field_title_swift_bic: 'SWIFT/BIC', @@ -1548,6 +1549,11 @@ const strings = { sepa_transfer_prompt_s: 'Your order %1$s has been submitted!\n\nPlease save the order details below for your records and instruct your bank to make the payment with the information in the Payment Details section.', + otc_enter_email_to_buy: 'Please enter your email to be contacted by one of our exchange partners to coordinate an OTC (Over the Counter) purchase.', + otc_enter_email_to_sell: 'Please enter your email to be contacted by one of our exchange partners to coordinate an OTC (Over the Counter) sale.', + otc_confirmation_title: 'Request Sent', + otc_confirmation_message: 'Thank you! You will be contacted in the next 24 hours to complete your request.', + // #endregion GuiPlugins // #region Light Account diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index 28f5c7f5600..9cc15bfeb68 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1352,6 +1352,7 @@ "form_field_title_address_line_2": "Address Line 2 (optional)", "form_field_title_address_state_province_region": "Province", "form_field_title_address_zip_postal_code": "Postal Code/Zip", + "form_field_title_email_address": "Email Address", "form_field_title_iban": "IBAN", "form_field_title_swift_bic": "SWIFT/BIC", "bank_info_title": "Bank Info", @@ -1364,6 +1365,10 @@ "bank_transfer_reference": "Reference", "sepa_form_title": "Enter Bank Info", "sepa_transfer_prompt_s": "Your order %1$s has been submitted!\n\nPlease save the order details below for your records and instruct your bank to make the payment with the information in the Payment Details section.", + "otc_enter_email_to_buy": "Please enter your email to be contacted by one of our exchange partners to coordinate an OTC (Over the Counter) purchase.", + "otc_enter_email_to_sell": "Please enter your email to be contacted by one of our exchange partners to coordinate an OTC (Over the Counter) sale.", + "otc_confirmation_title": "Request Sent", + "otc_confirmation_message": "Thank you! You will be contacted in the next 24 hours to complete your request.", "backup_account": "Back Up Account", "backup_delete_confirm_message": "Are you sure you want to delete this account without backing up first? You will NOT be able to recover wallets and transactions for this account!", "backup_info_message": "Create a username and password to create a full account and secure your funds. No personal information is required", diff --git a/src/plugins/gui/amountQuotePlugin.ts b/src/plugins/gui/amountQuotePlugin.ts index 7389bf75d27..4b064fcd6af 100644 --- a/src/plugins/gui/amountQuotePlugin.ts +++ b/src/plugins/gui/amountQuotePlugin.ts @@ -19,6 +19,7 @@ import { StateManager } from './hooks/useStateManager' import { BestError, getBestError, getRateFromQuote } from './pluginUtils' import { banxaProvider } from './providers/banxaProvider' import { bityProvider } from './providers/bityProvider' +import { kadoOtcProvider } from './providers/kadoOtcProvider' import { kadoProvider } from './providers/kadoProvider' import { moonpayProvider } from './providers/moonpayProvider' import { mtpelerinProvider } from './providers/mtpelerinProvider' @@ -49,7 +50,7 @@ type InternalFiatPluginEnterAmountParams = FiatPluginEnterAmountParams & { convertValueInternal: (sourceFieldNum: number, value: string, stateManager: StateManager) => Promise } -const providerFactories = [banxaProvider, bityProvider, kadoProvider, moonpayProvider, mtpelerinProvider, paybisProvider, simplexProvider] +const providerFactories = [banxaProvider, bityProvider, kadoProvider, kadoOtcProvider, moonpayProvider, mtpelerinProvider, paybisProvider, simplexProvider] const DEFAULT_FIAT_AMOUNT = '500' const DEFAULT_FIAT_AMOUNT_LIGHT_ACCOUNT = '50' @@ -547,7 +548,7 @@ export const amountQuoteFiatPlugin: FiatPluginFactory = async (params: FiatPlugi // showing the quotes. // TODO: conflict: also defines whether or not to accept a quote from the // provider -export const createPriorityArray = (providerPriority: ProviderPriorityMap): PriorityArray => { +export const createPriorityArray = (providerPriority: ProviderPriorityMap | undefined): PriorityArray => { const priorityArray: PriorityArray = [] if (providerPriority != null) { const temp: Array<{ pluginId: string; priority: number }> = [] diff --git a/src/plugins/gui/fiatPlugin.tsx b/src/plugins/gui/fiatPlugin.tsx index cc068a13124..5346f24a50b 100644 --- a/src/plugins/gui/fiatPlugin.tsx +++ b/src/plugins/gui/fiatPlugin.tsx @@ -121,6 +121,18 @@ export const executePlugin = async (params: { buttonModal: async params => { return await Airship.show(bridge => ) }, + confirmation: async params => { + return await new Promise(resolve => { + maybeNavigateToCorrectTabScene() + navigation.navigate('guiPluginConfirmation', { + title: params.title, + message: params.message, + onClose: async () => { + resolve() + } + }) + }) + }, showToastSpinner, openWebView: async (params): Promise => { maybeNavigateToCorrectTabScene() @@ -170,6 +182,20 @@ export const executePlugin = async (params: { maybeNavigateToCorrectTabScene() navigation.navigate('guiPluginEnterAmount', params) }, + async emailForm(params) { + return await new Promise((resolve, reject) => { + maybeNavigateToCorrectTabScene() + navigation.navigate('guiPluginEmailForm', { + message: params.message, + onSubmit: async (email: string) => { + resolve(email) + }, + onClose: async () => { + resolve(undefined) + } + }) + }) + }, addressForm: async params => { const { countryCode, headerTitle, headerIconUri, onSubmit } = params return await new Promise((resolve, reject) => { diff --git a/src/plugins/gui/fiatPluginTypes.ts b/src/plugins/gui/fiatPluginTypes.ts index d6ab44a1832..a348bf7f1c5 100644 --- a/src/plugins/gui/fiatPluginTypes.ts +++ b/src/plugins/gui/fiatPluginTypes.ts @@ -127,6 +127,7 @@ export type FiatPluginPermissions = Permission[] export interface FiatPluginUi { addressWarnings: (parsedUri: any, currencyCode: string) => Promise buttonModal: (params: Omit, 'bridge'>) => Promise + confirmation: (params: { title: string; message: string }) => Promise showToastSpinner: (message: string, promise: Promise) => Promise openWebView: (params: FiatPluginOpenWebViewParams) => Promise openExternalWebView: (params: FiatPluginOpenExternalWebViewParams) => Promise @@ -134,6 +135,7 @@ export interface FiatPluginUi { showError: (error: unknown) => Promise listModal: (params: FiatPluginListModalParams) => Promise enterAmount: (params: AppParamList['guiPluginEnterAmount']) => void + emailForm: (params: { message?: string }) => Promise addressForm: (params: FiatPluginAddressFormParams) => Promise requestPermission: (permissions: FiatPluginPermissions, displayName: string, mandatory: boolean) => Promise rewardsCardDashboard: (params: RewardsCardDashboardParams) => Promise diff --git a/src/plugins/gui/hooks/useFormFieldState.ts b/src/plugins/gui/hooks/useFormFieldState.ts new file mode 100644 index 00000000000..6785fa42c7e --- /dev/null +++ b/src/plugins/gui/hooks/useFormFieldState.ts @@ -0,0 +1,20 @@ +import { useState } from 'react' + +import { useHandler } from '../../../hooks/useHandler' + +interface FieldState { + onChangeText: (text: string) => void + value: string +} +export const useFormFieldState = (defaultValue: string = ''): FieldState => { + const [value, setValue] = useState(defaultValue) + + const handleChange = useHandler(text => { + setValue(text) + }) + + return { + onChangeText: handleChange, + value + } +} diff --git a/src/plugins/gui/providers/kadoOtcProvider.ts b/src/plugins/gui/providers/kadoOtcProvider.ts new file mode 100644 index 00000000000..7e39e67ed6a --- /dev/null +++ b/src/plugins/gui/providers/kadoOtcProvider.ts @@ -0,0 +1,454 @@ +import { div, lt, mul } from 'biggystring' +import { asArray, asBoolean, asNumber, asObject, asOptional, asString, asValue } from 'cleaners' +import URL from 'url-parse' + +import { ENV } from '../../../env' +import { lstrings } from '../../../locales/strings' +import { FiatDirection, FiatPaymentType } from '../fiatPluginTypes' +import { + FiatProvider, + FiatProviderApproveQuoteParams, + FiatProviderAssetMap, + FiatProviderError, + FiatProviderExactRegions, + FiatProviderFactory, + FiatProviderFactoryParams, + FiatProviderGetQuoteParams, + FiatProviderQuote +} from '../fiatProviderTypes' +import { validateExactRegion } from './common' + +// All OTC trades must at least meet this amount in fiat +const MIN_QUOTE_AMOUNT = '10000' + +const providerId = 'kadoOtc' +const storeId = 'money.kado' +const partnerIcon = 'kado.png' +const pluginDisplayName = 'Kado OTC' +// const providerDisplayName = 'Kado' +// const supportEmail = 'support@kado.money' + +const urls = { + api: { + prod: 'https://api.kado.money', + test: 'https://test-api.kado.money' + }, + widget: { + prod: 'https://app.kado.money', + test: 'https://sandbox--kado.netlify.app' + } +} + +const MODE = ENV.ENABLE_FIAT_SANDBOX ? 'test' : 'prod' + +// https://api.kado.money/v1/ramp/blockchains + +// Maps Edge pluginIds to Kado blockchain.origin values +const PLUGIN_TO_CHAIN_ID_MAP: { [pluginId: string]: string } = { + // stellar: 'stellar', // Needs destination tag support + solana: 'solana', + // ripple: 'ripple', // Needs destination tag support + polygon: 'polygon', + osmosis: 'osmosis', + optimism: 'optimism', + litecoin: 'litecoin', + ethereum: 'ethereum', + avalanche: 'avalanche', + // cosmos: 'cosmos hub', + bitcoin: 'bitcoin' +} + +const CHAIN_ID_TO_PLUGIN_MAP: { [chainId: string]: string } = Object.entries(PLUGIN_TO_CHAIN_ID_MAP).reduce( + (out: { [chainId: string]: string }, [pluginId, chainId]) => { + out[chainId] = pluginId + return out + }, + {} +) + +const SUPPORTED_REGIONS: FiatProviderExactRegions = { + US: { + notStateProvinces: ['FL', 'LA', 'NY', 'TX'] + } +} + +type AllowedPaymentTypes = Record + +const allowedPaymentTypes: AllowedPaymentTypes = { + buy: { + ach: true, + applepay: true, + colombiabank: true, + credit: true, + directtobank: true, + fasterpayments: true, + googlepay: true, + iach: true, + ideal: true, + interac: true, + iobank: true, + mexicobank: true, + payid: true, + paypal: true, + pix: true, + pse: true, + revolut: true, + sepa: true, + spei: true, + turkishbank: true, + wire: true + }, + sell: { + ach: true, + applepay: true, + colombiabank: true, + credit: true, + directtobank: true, + fasterpayments: true, + googlepay: true, + iach: true, + ideal: true, + interac: true, + iobank: true, + mexicobank: true, + payid: true, + paypal: true, + pix: true, + pse: true, + revolut: true, + sepa: true, + spei: true, + turkishbank: true, + wire: true + } +} + +const allowedBuyCurrencyCodes: FiatProviderAssetMap = { providerId, crypto: {}, fiat: {} } +const allowedSellCurrencyCodes: FiatProviderAssetMap = { providerId, crypto: {}, fiat: {} } +const allowedCountryCodes: { [code: string]: boolean } = { US: true } + +/** + * Cleaner for /v1/ramp/blockchains + */ + +const asAssociatedAsset = asObject({ + symbol: asString, + liveOnRamp: asOptional(asBoolean), + address: asOptional(asString), + isNative: asBoolean, + rampProducts: asOptional(asArray(asValue('buy', 'sell'))) +}) + +const asBlockchain = asObject({ + origin: asString, + associatedAssets: asArray(asAssociatedAsset), + liveOnRamp: asBoolean +}) + +// Define the main cleaner +const asBlockchains = asObject({ + success: asBoolean, + data: asObject({ + blockchains: asArray(asBlockchain) + }) +}) + +/** + * Cleaner for /v2/ramp/quote + */ + +// Define cleaners for nested objects and properties +const asAmountCurrency = asObject({ + amount: asNumber, + currency: asString +}) + +const asMinMaxValue = asObject({ + amount: asNumber, + unit: asString +}) + +const asQuote = asObject({ + baseAmount: asAmountCurrency, + totalFee: asAmountCurrency, + receive: asObject({ + amount: asNumber, + unitCount: asNumber + }), + minValue: asMinMaxValue, + maxValue: asMinMaxValue +}) + +// Main cleaner for the JSON structure +const asQuoteResponse = asObject({ + success: asBoolean, + message: asString, + data: asObject({ + quote: asQuote + }) +}) + +const asApiKeys = asObject({ + apiKey: asString, + apiUserEmail: asString +}) + +const asTokenOtherInfo = asObject({ + symbol: asString +}) + +/** + * Cleaner for /v2/public/orders/{orderId} + */ + +interface GetQuoteParams { + transactionType: 'buy' | 'sell' + fiatMethod: 'ach' | 'card' | 'wire' + amount: number + blockchain: string + currency: string + reverse: boolean + asset: string +} + +export const kadoOtcProvider: FiatProviderFactory = { + providerId, + storeId, + makeProvider: async (params: FiatProviderFactoryParams): Promise => { + const { apiKeys } = params + const { apiKey, apiUserEmail } = asApiKeys(apiKeys) + + const authToken = btoa(`${apiUserEmail}/token:${apiKey}`) // base64 encode this + + const instance: FiatProvider = { + providerId, + partnerIcon, + pluginDisplayName, + getSupportedAssets: async ({ direction, paymentTypes, regionCode }): Promise => { + validateExactRegion(providerId, regionCode, SUPPORTED_REGIONS) + // Return nothing if paymentTypes are not supported by this provider + if (!paymentTypes.some(paymentType => allowedPaymentTypes[direction][paymentType] === true)) + throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) + const allowedCurrencyCodes = direction === 'buy' ? allowedBuyCurrencyCodes : allowedSellCurrencyCodes + + if (Object.keys(allowedCurrencyCodes.crypto).length > 0) { + return allowedCurrencyCodes + } + + const response = await fetch(`${urls.api[MODE]}/v1/ramp/blockchains`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Widget-Id': apiKey + } + }) + if (!response.ok) { + const text = await response.text() + throw new Error(`Error fetching kado blockchains: ${text}`) + } + const result = await response.json() + + const blockchains = asBlockchains(result) + if (!blockchains.success) { + throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) + } + + for (const blockchain of blockchains.data.blockchains) { + const { liveOnRamp } = blockchain + if (!liveOnRamp) continue + const pluginId = CHAIN_ID_TO_PLUGIN_MAP[blockchain.origin] + if (pluginId == null) continue + allowedCurrencyCodes.crypto[pluginId] = [] + const tokens = allowedCurrencyCodes.crypto[pluginId] + + for (const asset of blockchain.associatedAssets) { + const { isNative, address } = asset + + if (asset.rampProducts == null || !asset.rampProducts.includes(direction)) continue + if (isNative) { + tokens.push({ tokenId: null, otherInfo: { symbol: asset.symbol } }) + continue + } + + if (address != null && address !== '0x0000000000000000000000000000000000000000') { + let tokenId: string + if (address.startsWith('0x')) { + // For EVM tokens only, lowercase and remove 0x + tokenId = address.toLowerCase().replace('0x', '') + } else { + tokenId = address + } + tokens.push({ tokenId, otherInfo: { symbol: asset.symbol } }) + } + } + } + + return allowedCurrencyCodes + }, + getQuote: async (params: FiatProviderGetQuoteParams): Promise => { + validateExactRegion(providerId, params.regionCode, SUPPORTED_REGIONS) + + const allowedCurrencyCodes = params.direction === 'buy' ? allowedBuyCurrencyCodes : allowedSellCurrencyCodes + + if (!allowedCountryCodes[params.regionCode.countryCode]) + throw new FiatProviderError({ providerId, errorType: 'regionRestricted', displayCurrencyCode: params.displayCurrencyCode }) + + if (!params.paymentTypes.some(paymentType => allowedPaymentTypes[params.direction][paymentType] === true)) + throw new FiatProviderError({ providerId, errorType: 'paymentUnsupported' }) + + const paymentType = params.paymentTypes[0] + + const allowedTokens = allowedCurrencyCodes.crypto[params.pluginId] + const allowedToken = allowedTokens.find(t => t.tokenId === params.tokenId) + if (allowedToken == null) throw new FiatProviderError({ providerId, errorType: 'assetUnsupported' }) + const tokenOtherInfo = asTokenOtherInfo(allowedToken.otherInfo) + + const blockchain = PLUGIN_TO_CHAIN_ID_MAP[params.pluginId] + + // These parameters are only to get a quote exchange rate: + const queryParams: GetQuoteParams = { + // All OTC rates are quoted at an volume amount + amount: 10_000, + asset: tokenOtherInfo.symbol, + blockchain, + currency: 'USD', + fiatMethod: 'wire', + reverse: false, + transactionType: params.direction + } + + const urlObj = new URL(`${urls.api[MODE]}/v2/ramp/quote`, true) + urlObj.set('query', queryParams) + const response = await fetch(urlObj.href, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Widget-Id': apiKey + } + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Error fetching kado quote: ${text}`) + } + + const result = await response.json() + const quote = asQuoteResponse(result).data.quote + + let fiatAmount: string + let cryptoAmount: string + if (params.direction === 'buy') { + if (params.amountType === 'fiat') { + fiatAmount = params.exchangeAmount + const scaleRatio = div(params.exchangeAmount, queryParams.amount, 18) + cryptoAmount = mul(quote.receive.unitCount, scaleRatio) + } else { + cryptoAmount = params.exchangeAmount + const exchangeRate = div(quote.receive.amount, quote.receive.unitCount, 18) + fiatAmount = div(mul(params.exchangeAmount, exchangeRate), 1, 2) + } + } else { + if (params.amountType === 'crypto') { + cryptoAmount = params.exchangeAmount + const scaleRatio = div(params.exchangeAmount, queryParams.amount, 18) + fiatAmount = div(mul(quote.receive.amount, scaleRatio), 1, 2) + } else { + fiatAmount = params.exchangeAmount + const exchangeRate = div(queryParams.amount, quote.receive.amount, 18) + cryptoAmount = mul(params.exchangeAmount, exchangeRate) + } + } + + // Make sure the final quote amount is within the min/max limits + if (lt(fiatAmount, MIN_QUOTE_AMOUNT)) + throw new FiatProviderError({ + providerId, + errorType: 'underLimit', + errorAmount: parseFloat(MIN_QUOTE_AMOUNT), + displayCurrencyCode: 'USD' + }) + + const paymentQuote: FiatProviderQuote = { + providerId, + partnerIcon, + regionCode: params.regionCode, + paymentTypes: params.paymentTypes, + pluginDisplayName, + displayCurrencyCode: params.displayCurrencyCode, + isEstimate: true, + fiatCurrencyCode: params.fiatCurrencyCode, + fiatAmount, + cryptoAmount, + direction: params.direction, + expirationDate: new Date(Date.now() + 60000), + approveQuote: async (approveParams: FiatProviderApproveQuoteParams): Promise => { + const { showUi } = approveParams + + // Do something to showUi to get the username and email + const userEmail = await showUi.emailForm({ + message: params.direction === 'buy' ? lstrings.otc_enter_email_to_buy : lstrings.otc_enter_email_to_sell + }) + + if (userEmail == null) { + // User canceled the form scene (navigated back). + // There is nothing left to do. + return + } + + await submitOrder(userEmail) + + async function submitOrder(userEmail: string): Promise { + const requestBody = { + ticket: { + subject: 'OTC Order Request', + comment: { + body: `Requesting to ${params.direction} ${cryptoAmount} ${tokenOtherInfo.symbol} for ${fiatAmount} USD using ${paymentType}.` + }, + requester: { + // We don't care about their name + // And we don't want to ask for their name to lower friction + name: userEmail, + email: userEmail + } + } + } + + const response = await fetch('https://edgeapp.zendesk.com/api/v2/tickets.json', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${authToken}` + }, + body: JSON.stringify(requestBody) + }) + + if (!response.ok) { + const text = await response.text() + // Exit the form scene + showUi.exitScene() + throw new Error(`Error creating Zendesk ticket: ${text}`) + } + + const result = await response.json() + + console.log('!@!', result) + + await showUi.confirmation({ title: lstrings.otc_confirmation_title, message: lstrings.otc_confirmation_message }) + + // Exit the confirmation scene + showUi.exitScene() + // Exit the form scene + showUi.exitScene() + // Exit the amount quote scene + showUi.exitScene() + } + }, + closeQuote: async (): Promise => {} + } + return paymentQuote + }, + otherMethods: null + } + return instance + } +} diff --git a/src/plugins/gui/scenes/ConfirmationScene.tsx b/src/plugins/gui/scenes/ConfirmationScene.tsx new file mode 100644 index 00000000000..d22995aaa14 --- /dev/null +++ b/src/plugins/gui/scenes/ConfirmationScene.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' + +import { SceneButtons } from '../../../components/buttons/SceneButtons' +import { SceneWrapper } from '../../../components/common/SceneWrapper' +import { SceneContainer } from '../../../components/layout/SceneContainer' +import { Paragraph } from '../../../components/themed/EdgeText' +import { SceneHeaderUi4 } from '../../../components/themed/SceneHeaderUi4' +import { useHandler } from '../../../hooks/useHandler' +import { lstrings } from '../../../locales/strings' +import { BuyTabSceneProps } from '../../../types/routerTypes' + +export interface FiatPluginConfirmationParams { + message: string + onClose: () => void + title: string +} + +interface Props extends BuyTabSceneProps<'guiPluginConfirmation'> {} + +export const ConfirmationScene = React.memo((props: Props) => { + const { route } = props + + const handleDonePress = useHandler(async () => { + route.params.onClose() + }) + + return ( + + + + {route.params.message} + + + + ) +}) diff --git a/src/plugins/gui/scenes/EmailFormScene.tsx b/src/plugins/gui/scenes/EmailFormScene.tsx new file mode 100644 index 00000000000..427a3a9223e --- /dev/null +++ b/src/plugins/gui/scenes/EmailFormScene.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' + +import { SceneButtons } from '../../../components/buttons/SceneButtons' +import { SceneWrapper } from '../../../components/common/SceneWrapper' +import { SceneContainer } from '../../../components/layout/SceneContainer' +import { Paragraph } from '../../../components/themed/EdgeText' +import { SceneHeaderUi4 } from '../../../components/themed/SceneHeaderUi4' +import { useHandler } from '../../../hooks/useHandler' +import { lstrings } from '../../../locales/strings' +import { BuyTabSceneProps } from '../../../types/routerTypes' +import { GuiFormField } from '../components/GuiFormField' + +export interface FiatPluginEmailFormParams { + message?: string + onClose: () => void + onSubmit: (email: string) => Promise +} + +interface Props extends BuyTabSceneProps<'guiPluginEmailForm'> {} + +export const EmailFormScene = React.memo((props: Props) => { + const { navigation, route } = props + const { params } = route + const { onClose, onSubmit } = params + + const [email, setEmail] = React.useState('') + + React.useEffect(() => { + return navigation.addListener('beforeRemove', () => { + onClose() + }) + }, [navigation, onClose]) + + const handleCancelPress = useHandler(() => { + if (navigation.canGoBack()) navigation.goBack() + }) + const handleSubmitPress = useHandler(async () => { + await onSubmit(email) + }) + + return ( + + + + {params.message == null ? null : {params.message}} + + + + + ) +}) diff --git a/src/plugins/gui/scenes/FiatPluginEnterAmountScene.tsx b/src/plugins/gui/scenes/FiatPluginEnterAmountScene.tsx index 594fae1a4e1..de1f095bc3e 100644 --- a/src/plugins/gui/scenes/FiatPluginEnterAmountScene.tsx +++ b/src/plugins/gui/scenes/FiatPluginEnterAmountScene.tsx @@ -1,3 +1,4 @@ +import pDebounce from 'p-debounce' import * as React from 'react' import { useEffect } from 'react' import { Image, Text, TextStyle, View } from 'react-native' @@ -93,11 +94,14 @@ export const FiatPluginEnterAmountScene = React.memo((props: Props) => { const stateManager = useStateManager({ ...defaultEnterAmountState, ...initState }) const { value1, value2, poweredBy, spinner1, spinner2, statusText } = stateManager.state + const convertValueDebounced = React.useMemo(() => { + return pDebounce(convertValue, 500) + }, [convertValue]) useEffect(() => { if (initState?.value1 != null) { stateManager.update({ value2: ' ', spinner2: true }) - convertValue(1, initState?.value1, stateManager) + convertValueDebounced(1, initState?.value1, stateManager) .then(otherValue => { if (typeof otherValue === 'string') { stateManager.update({ value2: otherValue, spinner2: false }) @@ -105,7 +109,7 @@ export const FiatPluginEnterAmountScene = React.memo((props: Props) => { }) .catch(err => showError(err)) } - }, [initState?.value1, convertValue, stateManager]) + }, [initState?.value1, convertValueDebounced, stateManager]) // Handle light account backups initiated from this scene useEffect(() => { @@ -130,7 +134,7 @@ export const FiatPluginEnterAmountScene = React.memo((props: Props) => { lastUsed.current = 1 onChangeText({ fieldNum: 1, value }, stateManager)?.catch(err => showError(err)) stateManager.update({ value1: value, spinner2: true }) - convertValue(1, value, stateManager) + convertValueDebounced(1, value, stateManager) .then(otherValue => { if (typeof otherValue === 'string') { stateManager.update({ value2: otherValue }) @@ -145,7 +149,7 @@ export const FiatPluginEnterAmountScene = React.memo((props: Props) => { lastUsed.current = 2 onChangeText({ fieldNum: 2, value }, stateManager)?.catch(err => showError(err)) stateManager.update({ value2: value, spinner1: true }) - convertValue(2, value, stateManager) + convertValueDebounced(2, value, stateManager) .then(otherValue => { if (typeof otherValue === 'string') { stateManager.update({ value1: otherValue, spinner1: false }) diff --git a/src/types/routerTypes.tsx b/src/types/routerTypes.tsx index 89b1e03cb6f..501248479bd 100644 --- a/src/types/routerTypes.tsx +++ b/src/types/routerTypes.tsx @@ -69,6 +69,8 @@ import type { WcConnectParams } from '../components/scenes/WcConnectScene' import type { WcDisconnectParams } from '../components/scenes/WcDisconnectScene' import type { WebViewSceneParams } from '../components/scenes/WebViewScene' import type { FiatPluginAddressFormParams } from '../plugins/gui/scenes/AddressFormScene' +import { FiatPluginConfirmationParams } from '../plugins/gui/scenes/ConfirmationScene' +import { FiatPluginEmailFormParams } from '../plugins/gui/scenes/EmailFormScene' import type { FiatPluginEnterAmountParams } from '../plugins/gui/scenes/FiatPluginEnterAmountScene' import type { FiatPluginOpenWebViewParams } from '../plugins/gui/scenes/FiatPluginWebView' import type { FiatPluginSepaTransferParams } from '../plugins/gui/scenes/InfoDisplayScene' @@ -96,6 +98,8 @@ export type BuyTabParamList = {} & { pluginViewBuy: PluginViewParams pluginViewSell: PluginViewParams guiPluginAddressForm: FiatPluginAddressFormParams + guiPluginEmailForm: FiatPluginEmailFormParams + guiPluginConfirmation: FiatPluginConfirmationParams guiPluginEnterAmount: FiatPluginEnterAmountParams guiPluginInfoDisplay: FiatPluginSepaTransferParams guiPluginSepaForm: FiatPluginSepaFormParams diff --git a/yarn.lock b/yarn.lock index 0d028fb8520..598617aaf8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15393,6 +15393,11 @@ p-cancelable@^3.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== +p-debounce@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-debounce/-/p-debounce-4.0.0.tgz#348e3f44489baa9435cc7d807f17b3bb2fb16b24" + integrity sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A== + p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"