diff --git a/jest/__mocks__/gqlMocks.ts b/jest/__mocks__/gqlMocks.ts index ed7299a3..f8aa50cc 100644 --- a/jest/__mocks__/gqlMocks.ts +++ b/jest/__mocks__/gqlMocks.ts @@ -21,6 +21,7 @@ export const mocks: MockedResponse[] = [ getAssetsForOrgMock({ currency: 'USD', limit: 10, offset: 0 }), getAssetsForOrgMock({ currency: 'USD', limit: 10, offset: 0 }), getAssetsForOrgMock({ assetId: 'satoshi123' }), + getAssetsForOrgMock({ assetId: 'elon123' }), getAssetsForOrgMock({ address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', }), diff --git a/src/components/CountdownTimer/index.tsx b/src/components/CountdownTimer/index.tsx new file mode 100644 index 00000000..cbe01542 --- /dev/null +++ b/src/components/CountdownTimer/index.tsx @@ -0,0 +1,81 @@ +import React, { useContext, useEffect, useState } from 'react'; + +import { Context } from '../../providers/Store'; + +const circumference = Math.PI * 20; + +const CountdownTimer: React.FC = () => { + const [state] = useContext(Context); + + if (!state.expiration) { + return null; + } + + const currentTime = new Date().getTime(); + const progressedTime = currentTime - state.initTime.getTime(); + const totalTime = state.expiration.getTime() - state.initTime.getTime(); + const remainingTime = state.expiration.getTime() - currentTime; + const remainingTimeSeconds = Math.floor(remainingTime / 1000); + const completedPercentage = progressedTime / totalTime; + const [seconds, setSeconds] = useState(remainingTimeSeconds); + const [position, setPosition] = useState(0); + + const countdown = () => { + if (seconds <= 0) { + return; + } + setSeconds((prev) => prev - 1); + }; + + useEffect(() => { + countdown(); + const interval = setInterval(countdown, 1000); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const position = circumference - circumference * completedPercentage; + setPosition(position); + }, [seconds]); + + const hours = Math.floor(seconds / 3600) + .toString() + .padStart(2, '0'); + const minutes = (Math.floor(seconds / 60) % 60).toString().padStart(2, '0'); + const secondsLeft = (seconds % 60).toString().padStart(2, '0'); + + return seconds > 0 ? ( + + + + + + ) : ( + Expired + ); +}; + +type Props = {}; + +export default CountdownTimer; diff --git a/src/components/StepTitle/index.tsx b/src/components/StepTitle/index.tsx new file mode 100644 index 00000000..e2bedbf6 --- /dev/null +++ b/src/components/StepTitle/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import CountdownTimer from '../CountdownTimer'; + +const StepTitle: React.FC = (props) => { + return ( +

+ {props.value} +

+ ); +}; + +type Props = { + testId?: string; + value: string; +}; + +export default StepTitle; diff --git a/src/index.test.tsx b/src/index.test.tsx index b3d4a813..292f1a2a 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -234,7 +234,7 @@ describe('Map3Sdk', () => { }; expect(initFn).not.toThrow(); expect(warnSpy).toBeCalledWith( - 'Warning: networkCode is required when amount is provided. Falling back to asset selection.' + 'Warning: amount is provided but not assetId or address and network. Falling back to undefined amount.' ); }); it('should check valid config.colors.primary', () => { @@ -337,4 +337,54 @@ describe('Map3Sdk', () => { }); expect(initFn).toThrow('options.callbacks.onAddressRequested is required.'); }); + it('should warn if rate is provided but not assetId or address and network', () => { + const warnSpy = jest.spyOn(console, 'warn'); + + const initFn = () => + initMap3Supercharge({ + anonKey: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjb25zb2xlIiwib3JnX2lkIjoiYzljNDczMzYtNWM5MS00MDM0LWIyYTgtMGI1NzA5ZTAwMGI1Iiwicm9sZXMiOlsiYW5vbnltb3VzIl0sImlhdCI6MTY3NTg4ODUwOCwiZXhwIjoxNzA3NDI0NTA4fQ.GzuXjFzSVkE3L-LlhtvpXa3aIi48rvHgMY3hw6lS8KU', + options: { + callbacks: { + onAddressRequested: async () => { + return { address: '0x000000' }; + }, + }, + selection: { + rate: 10_000, + }, + }, + userId: 'test', + }); + + expect(initFn).not.toThrow(); + expect(warnSpy).toBeCalledWith( + 'Warning: rate is provided but not assetId or address and network. Falling back to default rate.' + ); + }); + it('should allow an expiration time in the form of milliseconds or ISO 8601 in the future', () => { + const warnSpy = jest.spyOn(console, 'warn'); + + const initFn = () => + initMap3Supercharge({ + anonKey: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjb25zb2xlIiwib3JnX2lkIjoiYzljNDczMzYtNWM5MS00MDM0LWIyYTgtMGI1NzA5ZTAwMGI1Iiwicm9sZXMiOlsiYW5vbnltb3VzIl0sImlhdCI6MTY3NTg4ODUwOCwiZXhwIjoxNzA3NDI0NTA4fQ.GzuXjFzSVkE3L-LlhtvpXa3aIi48rvHgMY3hw6lS8KU', + options: { + callbacks: { + onAddressRequested: async () => { + return { address: '0x000000' }; + }, + }, + selection: { + expiration: '2021-12-31T23:59:59.999Z', + }, + }, + userId: 'test', + }); + + expect(initFn).not.toThrow(); + expect(warnSpy).toBeCalledWith( + 'Warning: expiration is in the past or invalid. Falling back to default expiration.' + ); + }); }); diff --git a/src/index.tsx b/src/index.tsx index 8fa70e5d..37937e47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -50,9 +50,11 @@ export interface Map3InitConfig { address?: string; amount?: string; assetId?: string; + expiration?: string | number; fiat?: string; networkCode?: string; paymentMethod?: 'binance-pay'; + rate?: number; shortcutAmounts?: number[]; }; style?: { @@ -105,6 +107,11 @@ export class Map3 { config.options.selection.fiat = 'USD'; } + const isAsset = + config.options.selection.assetId || + (config.options.selection.address && + config.options.selection.networkCode); + if (!ISO_4217_TO_SYMBOL[config.options.selection.fiat]) { console.warn( `Warning: fiat ${config.options.selection.fiat} is not supported. Falling back to USD.` @@ -135,16 +142,37 @@ export class Map3 { config.options.selection.address = undefined; } - if ( - config.options.selection.amount && - !config.options.selection.networkCode - ) { + if (config.options.selection.amount && !isAsset) { console.warn( - 'Warning: networkCode is required when amount is provided. Falling back to asset selection.' + 'Warning: amount is provided but not assetId or address and network. Falling back to undefined amount.' ); config.options.selection.amount = undefined; } + if (config.options.selection.rate && !isAsset) { + console.warn( + 'Warning: rate is provided but not assetId or address and network. Falling back to default rate.' + ); + config.options.selection.rate = undefined; + } + + if (config.options.selection.expiration) { + try { + const timeRemainingMs = + new Date(config.options.selection.expiration).getTime() - + new Date().getTime(); + + if (timeRemainingMs < 0) { + throw new Error('Expiration is in the past.'); + } + } catch (e) { + console.warn( + 'Warning: expiration is in the past or invalid. Falling back to default expiration.' + ); + config.options.selection.expiration = undefined; + } + } + if (config.options.style?.appName) { document.title = config.options.style.appName; } @@ -168,7 +196,14 @@ export class Map3 { }); // orange-600 - document.body.style.setProperty('--accent-color', 'rgb(234, 88, 12)'); + const orange600 = 'rgb(234, 88, 12)'; + document.body.style.setProperty('--accent-color', orange600); + document.body.style.setProperty( + '--accent-color-light', + colord(config.options.style?.colors?.accent || orange600) + .lighten(0.35) + .toHex() + ); // theme colors if (config.options.style && config.options.style.colors) { @@ -224,7 +259,6 @@ export class Map3 { primaryColor.mix(shades[shade as keyof typeof shades], 0.5).toHex() ); }); - } else { } } diff --git a/src/preview.tsx b/src/preview.tsx index c19535df..b0b02e3b 100644 --- a/src/preview.tsx +++ b/src/preview.tsx @@ -20,8 +20,10 @@ root.render( }, }, selection: { - assetId: '6b562c23-d79f-4a34-a47f-cc7b28726821', - paymentMethod: 'binance-pay', + amount: '10000', + assetId: '53adbb94-6a68-4eeb-af49-6b6d9e84a1f4', + fiat: 'USD', + rate: 2, }, style: { theme: 'dark', diff --git a/src/providers/Store/index.tsx b/src/providers/Store/index.tsx index 49f6bbc2..d59505bc 100644 --- a/src/providers/Store/index.tsx +++ b/src/providers/Store/index.tsx @@ -63,9 +63,12 @@ type State = { id?: string; width?: string; }; + expiration?: Date; fiat?: string; fiatDisplaySymbol?: string; + initTime: Date; method?: PaymentMethod & { description?: string }; + minStep: number; network?: Network; prebuiltTx: { data?: { @@ -90,6 +93,7 @@ type State = { status: RemoteType; }; providerChainId?: number; + rate?: number; requiredAmount?: string; requiredPaymentMethod?: 'binance-pay' | 'show-address'; shortcutAmounts?: number[]; @@ -217,8 +221,11 @@ const initialState: State = { status: 'idle', }, destinationNetwork: undefined, + expiration: undefined, fiat: undefined, + initTime: new Date(), method: undefined, + minStep: Steps.AssetSelection, network: undefined, prebuiltTx: { data: undefined, @@ -233,6 +240,7 @@ const initialState: State = { status: 'idle', }, providerChainId: undefined, + rate: undefined, shortcutAmounts: [], slug: undefined, step: Steps.AssetSelection, @@ -278,7 +286,8 @@ export const Store: React.FC< PropsWithChildren > = ({ asset, children, network, options, userId }) => { const { callbacks, selection, style } = options || {}; - const { amount, fiat, paymentMethod, shortcutAmounts } = selection || {}; + const { amount, fiat, paymentMethod, rate, shortcutAmounts } = + selection || {}; const { embed, theme } = style || {}; const { handleAuthorizeTransaction, @@ -304,10 +313,32 @@ export const Store: React.FC< requiredAmount = ethers.utils.formatUnits(amount, asset.decimals); } + let expiration; + if (selection?.expiration) { + expiration = new Date(selection.expiration); + } + const requiredPaymentMethod = paymentMethod; const fiatDisplaySymbol = ISO_4217_TO_SYMBOL[fiat || 'USD']; + const rest = { + asset, + embed, + expiration, + fiat, + fiatDisplaySymbol, + minStep: step, + network, + rate, + requiredAmount, + requiredPaymentMethod, + shortcutAmounts, + step, + theme, + userId, + }; + const [state, dispatch] = useReducer( (state: State, action: Action): State => { switch (action.type) { @@ -548,17 +579,7 @@ export const Store: React.FC< case 'RESET_STATE': return { ...initialState, - asset, - embed, - fiat, - fiatDisplaySymbol, - network, - requiredAmount, - requiredPaymentMethod, - shortcutAmounts, - step, - theme, - userId, + ...rest, }; /* istanbul ignore next */ default: @@ -568,17 +589,7 @@ export const Store: React.FC< }, { ...initialState, - asset, - embed, - fiat, - fiatDisplaySymbol, - network, - requiredAmount, - requiredPaymentMethod, - shortcutAmounts, - step, - theme, - userId, + ...rest, } ); diff --git a/src/steps/AssetSelection/index.tsx b/src/steps/AssetSelection/index.tsx index dfdf88a3..28cfb375 100644 --- a/src/steps/AssetSelection/index.tsx +++ b/src/steps/AssetSelection/index.tsx @@ -7,6 +7,7 @@ import ErrorWrapper from '../../components/ErrorWrapper'; import InnerWrapper from '../../components/InnerWrapper'; import ListItem from '../../components/ListItem'; import LoadingWrapper from '../../components/LoadingWrapper'; +import StepTitle from '../../components/StepTitle'; import { Asset, useGetAssetsForOrgQuery, @@ -66,12 +67,7 @@ const AssetSelection: React.FC = () => {
-

- {t('copy.select_asset')} -

+ {assets?.length && assets.length > 6 ? (
= () => { @@ -20,9 +21,7 @@ const ConfirmRequiredAmount: React.FC = () => {
-

- {t('title.confirm_amount')} -

+
diff --git a/src/steps/EnterAmount/index.tsx b/src/steps/EnterAmount/index.tsx index 303c04cb..eb3771b8 100644 --- a/src/steps/EnterAmount/index.tsx +++ b/src/steps/EnterAmount/index.tsx @@ -12,6 +12,7 @@ import BinancePay from '../../components/methods/BinancePay'; import WalletConnect from '../../components/methods/WalletConnect'; import WindowEthereum from '../../components/methods/WindowEthereum'; import StateDescriptionHeader from '../../components/StateDescriptionHeader'; +import StepTitle from '../../components/StepTitle'; import { MIN_CONFIRMATIONS } from '../../constants'; import { useCreateBridgeQuoteMutation, @@ -768,6 +769,7 @@ type Props = {}; const EnterAmount: React.FC = () => { const [state, dispatch] = useContext(Context); const { data, loading } = useGetAssetPriceQuery({ + skip: !!state.rate, variables: { assetId: state.asset?.id, currency: state.fiat, @@ -780,22 +782,15 @@ const EnterAmount: React.FC = () => { return null; } + const price = state.rate || data?.assetPrice?.price || 0; + return (
-

- {t('title.enter_amount')} -

+
- {loading ? ( - - ) : ( - - )} + {loading ? : }
); }; diff --git a/src/steps/NetworkSelection/index.tsx b/src/steps/NetworkSelection/index.tsx index a26a1e9b..cb342b1a 100644 --- a/src/steps/NetworkSelection/index.tsx +++ b/src/steps/NetworkSelection/index.tsx @@ -7,6 +7,7 @@ import InnerWrapper from '../../components/InnerWrapper'; import ListItem from '../../components/ListItem'; import LoadingWrapper from '../../components/LoadingWrapper'; import StateDescriptionHeader from '../../components/StateDescriptionHeader'; +import StepTitle from '../../components/StepTitle'; import { useGetMappedNetworksForAssetQuery } from '../../generated/apollo-gql'; import { Context, Steps } from '../../providers/Store'; @@ -75,12 +76,10 @@ const NetworkSelection: React.FC = () => {
-

- {t('title.select_network')} -

+
diff --git a/src/steps/PaymentMethod/PaymentMethod.test.tsx b/src/steps/PaymentMethod/PaymentMethod.test.tsx index 5081d222..b23246e5 100644 --- a/src/steps/PaymentMethod/PaymentMethod.test.tsx +++ b/src/steps/PaymentMethod/PaymentMethod.test.tsx @@ -82,6 +82,36 @@ describe('Payment Selection', () => { }); }); +describe('Payment Selection', () => { + beforeEach(() => { + render( + {}} + /> + ); + }); + const testingUtils = generateTestingUtils({ providerType: 'MetaMask' }); + beforeAll(() => { + global.window.ethereum = testingUtils.getProvider(); + global.window.ethereum.providers = [testingUtils.getProvider()]; + }); + afterEach(() => { + testingUtils.clearAllMocks(); + }); + it('doesnt allow the user to go back beyond min step', async () => { + const back = await screen.findByLabelText('Back'); + expect(back).toHaveClass('invisible'); + }); +}); + describe('Payment Method Errors', () => { it('renders', () => { render(); diff --git a/src/steps/PaymentMethod/index.tsx b/src/steps/PaymentMethod/index.tsx index 5bf8c7cf..0cfa900f 100644 --- a/src/steps/PaymentMethod/index.tsx +++ b/src/steps/PaymentMethod/index.tsx @@ -9,6 +9,7 @@ import ListItem from '../../components/ListItem'; import LoadingWrapper from '../../components/LoadingWrapper'; import MethodIcon from '../../components/MethodIcon'; import StateDescriptionHeader from '../../components/StateDescriptionHeader'; +import StepTitle from '../../components/StepTitle'; import { PaymentMethod, useGetPaymentMethodsQuery, @@ -238,12 +239,7 @@ const PaymentMethod: React.FC = () => {
-

- Payment Method -

+ {methodsForNetwork?.length && methodsForNetwork.length > 6 ? ( = () => { return (
-

- Pay to Address -

+
{state.depositAddress.status === 'error' && ( = () => {
-

- Switch Chain -

+
diff --git a/src/steps/index.tsx b/src/steps/index.tsx index 3bc9a134..cc533fdc 100644 --- a/src/steps/index.tsx +++ b/src/steps/index.tsx @@ -29,7 +29,7 @@ export const ANIMATION_VARIANTS = { const Map3SdkSteps: React.FC = ({ onClose, plan }) => { const [state, dispatch] = useContext(Context); - const { step, steps } = state; + const { minStep, step, steps } = state; useChainWatcher(); @@ -51,7 +51,9 @@ const Map3SdkSteps: React.FC = ({ onClose, plan }) => {