diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..e7e1bbed --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "extends": [ + "next/core-web-vitals", + "plugin:storybook/recommended" + ], + "plugins": [ + "no-conditional-literals-in-jsx" + ], + "rules": { + "no-conditional-literals-in-jsx/no-conditional-literals-in-jsx": "error", + "no-conditional-literals-in-jsx/no-unwrapped-jsx-text": "error", + "react-hooks/exhaustive-deps": "off", + "react/display-name": "off" + } +} \ No newline at end of file diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 00000000..1ccb286a --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,26 @@ +# .github/workflows/chromatic.yml + +# Workflow name +name: 'Chromatic' + +# Event for the workflow +on: push + +# List of jobs +jobs: + chromatic-deployment: + # Operating System + runs-on: ubuntu-latest + # Job steps + steps: + - uses: actions/checkout@v1 + - name: Install dependencies + # 👇 Install dependencies with the same package manager used in the project (replace it as needed), e.g. yarn, npm, pnpm + run: yarn + # 👇 Adds Chromatic as a step in the workflow + - name: Publish to Chromatic + uses: chromaui/action@v1 + # Chromatic GitHub Action options + with: + # 👇 Chromatic projectToken, refer to the manage page to obtain it. + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/rebase-dev-prod-stage.yml b/.github/workflows/rebase-dev-prod-stage.yml new file mode 100644 index 00000000..f86fda5a --- /dev/null +++ b/.github/workflows/rebase-dev-prod-stage.yml @@ -0,0 +1,39 @@ +name: Dev to dev-prod && dev-stage +on: + push: + branches: [dev] +permissions: + contents: write +jobs: + rebase-dev-prod: + timeout-minutes: 2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set Git config + run: | + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" + - name: Merge dev to dev-prod + run: | + git fetch --unshallow + git checkout dev-prod + git rebase dev + git push + + rebase-dev-stage: + timeout-minutes: 2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set Git config + run: | + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" + - name: Merge dev to dev-stage + run: | + git fetch --unshallow + git checkout dev-stage + git rebase dev + git push + diff --git a/.github/workflows/rebase-main-sandbox.yml b/.github/workflows/rebase-main-sandbox.yml new file mode 100644 index 00000000..2f50b08e --- /dev/null +++ b/.github/workflows/rebase-main-sandbox.yml @@ -0,0 +1,22 @@ +name: Main to main-sandbox +on: + push: + branches: [main] +permissions: + contents: write +jobs: + rebase-main-sandbox: + timeout-minutes: 2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set Git config + run: | + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" + - name: Merge main to main-sandbox + run: | + git fetch --unshallow + git checkout main-sandbox + git rebase main + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b9617e3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +*node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.vercel + +# Moving to yarn +package-lock.json +storybook-static/* +# Sentry Config File +.sentryclirc diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..d4dec5bd --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,32 @@ +import type { StorybookConfig } from "@storybook/nextjs"; + +const config: StorybookConfig = { + stories: [ + "../stories/**/*.mdx", + "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions", + "@storybook/addon-mdx-gfm", + "storybook-addon-mock" + ], + framework: { + name: "@storybook/nextjs", + options: {}, + }, + docs: { + autodocs: "tag", + }, + webpackFinal: async (config) => { + config.module?.rules?.push({ + test: /\.scss$/, + use: ["style-loader", "css-loader", "postcss-loader", "sass-loader"], + }); + + return config; + }, +}; +export default config; diff --git a/.storybook/main.ts.sedbak b/.storybook/main.ts.sedbak new file mode 100644 index 00000000..d4dec5bd --- /dev/null +++ b/.storybook/main.ts.sedbak @@ -0,0 +1,32 @@ +import type { StorybookConfig } from "@storybook/nextjs"; + +const config: StorybookConfig = { + stories: [ + "../stories/**/*.mdx", + "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions", + "@storybook/addon-mdx-gfm", + "storybook-addon-mock" + ], + framework: { + name: "@storybook/nextjs", + options: {}, + }, + docs: { + autodocs: "tag", + }, + webpackFinal: async (config) => { + config.module?.rules?.push({ + test: /\.scss$/, + use: ["style-loader", "css-loader", "postcss-loader", "sass-loader"], + }); + + return config; + }, +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 00000000..014316eb --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,25 @@ +import type { Preview } from "@storybook/react"; +import "../styles/globals.css"; + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, +}; + +const preview: Preview = { + loaders: [ + async () => ({ + settings: await (await fetch(`https://bridge-api.bridge.lux.network/api/settings?version=sandbox`)).json(), + }), + ], + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/.storybook/preview.ts.sedbak b/.storybook/preview.ts.sedbak new file mode 100644 index 00000000..014316eb --- /dev/null +++ b/.storybook/preview.ts.sedbak @@ -0,0 +1,25 @@ +import type { Preview } from "@storybook/react"; +import "../styles/globals.css"; + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, +}; + +const preview: Preview = { + loaders: [ + async () => ({ + settings: await (await fetch(`https://bridge-api.bridge.lux.network/api/settings?version=sandbox`)).json(), + }), + ], + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..237ecfc6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ], + "tasks": [ + { + "type": "npm", + "script": "errors", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "revealProblems": "never" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/launch.json.sedbak b/.vscode/launch.json.sedbak new file mode 100644 index 00000000..237ecfc6 --- /dev/null +++ b/.vscode/launch.json.sedbak @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ], + "tasks": [ + { + "type": "npm", + "script": "errors", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "revealProblems": "never" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/launch.json.sedbak.sedbak b/.vscode/launch.json.sedbak.sedbak new file mode 100644 index 00000000..237ecfc6 --- /dev/null +++ b/.vscode/launch.json.sedbak.sedbak @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ], + "tasks": [ + { + "type": "npm", + "script": "errors", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "revealProblems": "never" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/launch.json.sedbak.sedbak.sedbak b/.vscode/launch.json.sedbak.sedbak.sedbak new file mode 100644 index 00000000..237ecfc6 --- /dev/null +++ b/.vscode/launch.json.sedbak.sedbak.sedbak @@ -0,0 +1,39 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run debug", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ], + "tasks": [ + { + "type": "npm", + "script": "errors", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "revealProblems": "never" + } + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..54b811ce --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# bridge-next +Next generation LUX Bridge. + +## To run locally + +Install `pnpm` [like so](https://pnpm.io/installation) + +The usual scripts for a Next site, using `pnpm` +``` +pnpm install +pnpm dev +``` + +Since "pnpm" is a finger twister, many people alias it to "pn". For example, with `bash`, put `alias pn='pnpm'` in `.bashrc`. diff --git a/app/Models/ApiError.ts b/app/Models/ApiError.ts new file mode 100644 index 00000000..4c862362 --- /dev/null +++ b/app/Models/ApiError.ts @@ -0,0 +1,18 @@ +export type ApiError = { + code: LSAPIKnownErrorCode | string, + message: string; +} + +export enum LSAPIKnownErrorCode { + NOT_FOUND = "NOT_FOUND", + INVALID_CREDENTIALS = "INVALID_CREDENTIALS", + INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", + FUNDS_ON_HOLD = "FUNDS_ON_HOLD_ERROR", + COINBASE_AUTHORIZATION_LIMIT_EXCEEDED = "COINBASE_AUTHORIZATION_LIMIT_EXCEEDED", + COINBASE_INVALID_2FA = "COINBASE_INVALID_2FA", + ACTIVE_SWAP_LIMIT_EXCEEDED = "ACTIVE_SWAP_LIMIT_EXCEEDED", + NETWORK_ACCOUNT_ALREADY_EXISTS = "NETWORK_ACCOUNT_ALREADY_EXISTS", + BLACKLISTED_ADDRESS = "BLACKLISTED_ADDRESS", + INVALID_ADDRESS_ERROR = "INVALID_ADDRESS_ERROR", + UNACTIVATED_ADDRESS_ERROR = "UNACTIVATED_ADDRESS_ERROR" +} \ No newline at end of file diff --git a/app/Models/ApiResponse.ts b/app/Models/ApiResponse.ts new file mode 100644 index 00000000..e45d11ee --- /dev/null +++ b/app/Models/ApiResponse.ts @@ -0,0 +1,13 @@ +import { ApiError } from "./ApiError"; + +export class EmptyApiResponse { + constructor(error?: ApiError) { + this.error = error; + } + + error?: ApiError; +} + +export class ApiResponse extends EmptyApiResponse { + data?: T +} diff --git a/app/Models/Balance.ts b/app/Models/Balance.ts new file mode 100644 index 00000000..b322576c --- /dev/null +++ b/app/Models/Balance.ts @@ -0,0 +1,44 @@ +import { Wallet } from "../stores/walletStore" +import { Currency } from "./Currency" +import { Layer } from "./Layer" + +export type BalanceProps = { + layer: Layer, + address: string +} + +export type GasProps = { + layer: Layer, + currency: Currency, + address?: `0x${string}`, + userDestinationAddress?: string, + wallet?: Wallet +} + +export type Balance = { + network: string, + amount: number, + decimals: number, + isNativeCurrency: boolean, + token: string, + request_time: string, +} + +export type Gas = { + token: string, + gas: number, + gasDetails?: { + gasLimit?: number, + maxFeePerGas?: number, + gasPrice?: number, + maxPriorityFeePerGas?: number + }, + request_time: string +} + +export type BalanceProvider = { + getBalance: ({ layer, address }: BalanceProps) => Promise | undefined | void, + getGas: ({ layer, address, currency, userDestinationAddress, wallet }: GasProps) => Promise | undefined | void, + supportedNetworks: string[], + name: string, +} \ No newline at end of file diff --git a/app/Models/CryptoNetwork.ts b/app/Models/CryptoNetwork.ts new file mode 100644 index 00000000..746ec23b --- /dev/null +++ b/app/Models/CryptoNetwork.ts @@ -0,0 +1,69 @@ +import { LayerStatus } from "./Layer"; + +export enum NetworkType { + EVM = "evm", + Starknet = "starknet", + Solana = "solana", + Cosmos = "cosmos", + StarkEx = "stark_ex", + ZkSyncLite = "zk_sync_lite", + TON = 'ton' +} + + +export class CryptoNetwork { + display_name: string; + internal_name: string; + native_currency: string | null | undefined; + average_completion_time: string; + transaction_explorer_template: string; + account_explorer_template?: string; + status: LayerStatus; + currencies: NetworkCurrency[]; + refuel_amount_in_usd: number; + chain_id: string; + type: NetworkType; + created_date: string; + is_featured: boolean; + nodes: NetworkNode[]; + managed_accounts: ManagedAccount[]; + metadata: Metadata | null | undefined; + img_url?: string +} + +export class NetworkCurrency { + name: string; + asset: string; + status: LayerStatus; + is_deposit_enabled: boolean; + is_withdrawal_enabled: boolean; + is_refuel_enabled: boolean; + max_withdrawal_amount: number; + deposit_fee: number; + withdrawal_fee: number; + //TODO may be plain string + contract_address: `0x${string}` | null | undefined; + decimals: number; + source_base_fee: number; + destination_base_fee: number; +} +export class NetworkNode { + url: string; +} +export class ManagedAccount { + address: `0x${string}`; +} +export class Metadata { + multicall3?: { + address: `0x${string}` + blockCreated: number + } + ensRegistry?: { + address: `0x${string}` + } + ensUniversalResolver?: { + address: `0x${string}` + } + WatchdogContractAddress?: `0x${string}` + L1Network?: string +} \ No newline at end of file diff --git a/app/Models/Currency.ts b/app/Models/Currency.ts new file mode 100644 index 00000000..bdc0229d --- /dev/null +++ b/app/Models/Currency.ts @@ -0,0 +1,6 @@ +export class Currency { + asset: string; + usd_price: number; + precision: number; + img_url?: string +} \ No newline at end of file diff --git a/app/Models/CurrencyExchange.ts b/app/Models/CurrencyExchange.ts new file mode 100644 index 00000000..ce1686a4 --- /dev/null +++ b/app/Models/CurrencyExchange.ts @@ -0,0 +1,7 @@ +export class CurrencyExchange { + exchange_id: string; + fee: number; + is_off_ramp_enabled: boolean; + min_withdrawal_amount: number; + off_ramp_max_amount:number; +} \ No newline at end of file diff --git a/app/Models/Exchange.ts b/app/Models/Exchange.ts new file mode 100644 index 00000000..885b523d --- /dev/null +++ b/app/Models/Exchange.ts @@ -0,0 +1,23 @@ +import { NetworkCurrency } from "./CryptoNetwork"; +import { LayerStatus } from "./Layer"; + +export class Exchange { + display_name: string; + internal_name: string; + authorization_flow: "o_auth2" | "api_credentials" | 'none' + currencies: (ExchangeCurrency & NetworkCurrency)[]; + status: LayerStatus; + type: "cex" | "fiat"; + created_date: string; + is_featured: boolean; + img_url?: string +} + +export class ExchangeCurrency { + asset: string; + withdrawal_fee: number; + min_deposit_amount: number; + network: string; + is_default: boolean; + status: LayerStatus +} diff --git a/app/Models/Layer.ts b/app/Models/Layer.ts new file mode 100644 index 00000000..4dde1b10 --- /dev/null +++ b/app/Models/Layer.ts @@ -0,0 +1,48 @@ +import { CryptoNetwork, Metadata, NetworkType } from "./CryptoNetwork"; + +export type LayerStatus = "active" | "inactive" | 'insufficient_liquidity'; +export type Layer = { + display_name: string; + internal_name: string; + status: LayerStatus; + is_featured: boolean; + created_date: string; +} & LayerData + +type LayerData = ({ + isExchange: true; + assets?: ExchangeAsset[]; + type: "cex" | "fiat", + authorization_flow: "o_auth2" | "api_credentials" | 'none'; +} | { + isExchange: false; + assets: NetworkAsset[]; + native_currency: string | null | undefined; + average_completion_time: string; + chain_id: string | null | undefined; + type: NetworkType, + metadata: Metadata | null | undefined; + nodes: NetworkNodes[]; +}) + +export type BaseL2Asset = { + asset: string; + network_internal_name: string; + network?: CryptoNetwork; + is_default: boolean; + status: LayerStatus; +} + +export type ExchangeAsset = { + withdrawal_fee: number; + min_deposit_amount: number; +} & BaseL2Asset + +export type NetworkAsset = { + contract_address?: `0x${string}` | null | undefined + decimals: number +} & BaseL2Asset + +export type NetworkNodes = { + url: string; +} \ No newline at end of file diff --git a/app/Models/LayerSwapAppSettings.ts b/app/Models/LayerSwapAppSettings.ts new file mode 100644 index 00000000..2fdf6d1e --- /dev/null +++ b/app/Models/LayerSwapAppSettings.ts @@ -0,0 +1,105 @@ +import { CryptoNetwork, NetworkCurrency } from "./CryptoNetwork"; +import { Currency } from "./Currency"; +import { Exchange, ExchangeCurrency } from "./Exchange"; +import { BaseL2Asset, ExchangeAsset, Layer, NetworkAsset } from "./Layer"; +import { BridgeSettings } from "./BridgeSettings"; +import { Partner } from "./Partner"; + +export class BridgeAppSettings extends BridgeSettings { + constructor(settings: BridgeSettings | any) { + super(); + Object.assign(this, BridgeAppSettings.ResolveSettings(settings)) + + this.layers = BridgeAppSettings.ResolveLayers(this.exchanges, this.networks); + } + + layers: Layer[] + + resolveImgSrc = (item: Layer | Currency | Pick | { asset: string } | Partner) => { + + if (!item) { + return "/images/logo_placeholder.png"; + } + + const basePath = new URL(this.discovery.resource_storage_url); + + // Shitty way to check for partner + if ((item as Partner).is_wallet != undefined) { + return (item as Partner)?.logo_url; + } + else if ((item as any)?.internal_name != undefined) { + basePath.pathname = `/bridge/networks/${(item as any)?.internal_name?.toLowerCase()}.png`; + } + else if ((item as any)?.asset != undefined) { + basePath.pathname = `/bridge/currencies/${(item as any)?.asset?.toLowerCase()}.png`; + } + + return basePath.href; + } + + static ResolveSettings(settings: BridgeSettings) { + const basePath = new URL(settings.discovery.resource_storage_url); + + settings.networks = settings.networks.map(n => ({ + ...n, + img_url: `${basePath}bridge/networks/${n?.internal_name?.toLowerCase()}.png` + })) + settings.exchanges = settings.exchanges.map(e => ({ + ...e, + img_url: `${basePath}bridge/networks/${e?.internal_name?.toLowerCase()}.png` + })) + settings.currencies = settings.currencies.map(c => ({ + ...c, + img_url: `${basePath}bridge/networks/${c?.asset?.toLowerCase()}.png` + })) + + return settings + } + + static ResolveLayers(exchanges: Exchange[], networks: CryptoNetwork[]): Layer[] { + const exchangeLayers: Layer[] = exchanges.map((e): Layer => ({ + isExchange: true, + assets: BridgeAppSettings.ResolveExchangeL2Assets(e.currencies, networks), + ...e + })) + const networkLayers: Layer[] = networks.map((n): Layer => + ({ + isExchange: false, + assets: BridgeAppSettings.ResolveNetworkL2Assets(n), + ...n + })) + const result = exchangeLayers.concat(networkLayers) + return result + } + + static ResolveExchangeL2Assets( + currencies: ExchangeCurrency[], + networks: CryptoNetwork[]): ExchangeAsset[] { + return currencies.map(exchangecurrency => { + const network = networks.find(n => n.internal_name === exchangecurrency.network) as CryptoNetwork + const networkCurrencies = network?.currencies.find(nc => nc.asset === exchangecurrency.asset) as NetworkCurrency + const res: ExchangeAsset = { + asset: exchangecurrency.asset, + status: exchangecurrency.status, + is_default: exchangecurrency.is_default, + network_internal_name: exchangecurrency.network, + network: { ...network, currencies: [networkCurrencies] }, + min_deposit_amount: exchangecurrency.min_deposit_amount, + withdrawal_fee: exchangecurrency.withdrawal_fee, + } + return res + }) + } + + static ResolveNetworkL2Assets(network: CryptoNetwork): NetworkAsset[] { + return network?.currencies.map(c => ({ + asset: c.asset, + status: c.status, + is_default: true, + network_internal_name: network?.internal_name, + network: { ...network }, + contract_address: c.contract_address, + decimals: c.decimals + })) + } +} \ No newline at end of file diff --git a/app/Models/LayerSwapAuth.ts b/app/Models/LayerSwapAuth.ts new file mode 100644 index 00000000..b57196a1 --- /dev/null +++ b/app/Models/LayerSwapAuth.ts @@ -0,0 +1,15 @@ +export class AuthGetCodeResponse { + data: { + next: Date, + already_sent: boolean + }; + error: string; +} + +export class AuthConnectResponse { + access_token: string; + expires_in: number; + token_type: string; + refresh_token: string; + scope: string; +} \ No newline at end of file diff --git a/app/Models/LayerSwapSettings.ts b/app/Models/LayerSwapSettings.ts new file mode 100644 index 00000000..d76ea92d --- /dev/null +++ b/app/Models/LayerSwapSettings.ts @@ -0,0 +1,21 @@ +import { CryptoNetwork } from "./CryptoNetwork"; +import { Currency } from "./Currency"; +import { Exchange } from "./Exchange"; + +export class BridgeSettings { + exchanges: Exchange[]; + networks: CryptoNetwork[]; + currencies: Currency[]; + discovery: { + identity_url: string; + resource_storage_url: string; + o_auth_providers: OauthProveider[] + } + validSignatureisPresent?: boolean; +}; + +type OauthProveider = { + provider: string, + oauth_connect_url: string, + oauth_authorize_url: string +} \ No newline at end of file diff --git a/app/Models/Partner.ts b/app/Models/Partner.ts new file mode 100644 index 00000000..8447c209 --- /dev/null +++ b/app/Models/Partner.ts @@ -0,0 +1,7 @@ +export class Partner { + display_name: string; + logo_url: string; + is_wallet: boolean; + id: number; + name: string +} \ No newline at end of file diff --git a/app/Models/QueryParams.ts b/app/Models/QueryParams.ts new file mode 100644 index 00000000..6fb4605a --- /dev/null +++ b/app/Models/QueryParams.ts @@ -0,0 +1,38 @@ + +export class PersistantQueryParams { + from?: string = ""; + to?: string = ""; + lockAddress?: boolean = false; + lockFrom?: boolean = false; + lockTo?: boolean = false; + lockAsset?: boolean = false; + destAddress?: string = ""; + hideRefuel?: boolean = false; + hideAddress?: boolean = false; + hideFrom?: boolean = false; + hideTo?: boolean = false; + asset?: string = ""; + amount?: string = ""; + externalId?: string = "" + signature?: string = ""; + timestamp?: string = ""; + apiKey?: string = ""; + balances?: string = ""; + account?: string = ""; + actionButtonText?: string = ""; + theme?: string = ""; + appName?: string = ""; + + // Obsolate + sourceExchangeName?: string = ""; + destNetwork?: string = ""; + lockNetwork?: boolean = false; + lockExchange?: boolean = false; + addressSource?: string = ""; + +} + + +export class QueryParams extends PersistantQueryParams { + coinbase_redirect?: string = ""; +} \ No newline at end of file diff --git a/app/Models/RangeError.ts b/app/Models/RangeError.ts new file mode 100644 index 00000000..a91ae765 --- /dev/null +++ b/app/Models/RangeError.ts @@ -0,0 +1,4 @@ +export enum SwapFailReasons { + RECEIVED_LESS_THAN_VALID_RANGE = "received_less_than_valid_range", + RECEIVED_MORE_THAN_VALID_RANGE = "received_more_than_valid_range" +} \ No newline at end of file diff --git a/app/Models/SwapStatus.ts b/app/Models/SwapStatus.ts new file mode 100644 index 00000000..6e4a9d74 --- /dev/null +++ b/app/Models/SwapStatus.ts @@ -0,0 +1,12 @@ +export enum SwapStatus { + Created = 'created', + + UserTransferPending= 'user_transfer_pending', + UserTransferDelayed = 'user_transfer_delayed', + LsTransferPending = "ls_transfer_pending", + + Completed = 'completed', + Failed = 'failed', + Expired = "expired", + Cancelled = "cancelled", +} \ No newline at end of file diff --git a/app/Models/Theme.ts b/app/Models/Theme.ts new file mode 100644 index 00000000..f3a8a0af --- /dev/null +++ b/app/Models/Theme.ts @@ -0,0 +1,243 @@ + +export type ThemeData = { + backdrop?: string, + actionButtonText: string, + logo: string, + placeholderText: string, + primary: ThemeColor, + secondary?: ThemeColor +} + +export type ThemeColor = { + DEFAULT: string; + 50: string; + 100: string; + 200: string; + 300: string; + 400: string; + 500: string; + 600: string; + 700: string; + 800: string; + 900: string; + 950?: string; + text: string, + textMuted?: string, +} + +export const THEME_COLORS: { [key: string]: ThemeData } = { + "imxMarketplace": { + backdrop: "0, 121, 133", + actionButtonText: '0, 0, 0', + placeholderText: '140, 152, 192', + logo: '255, 255, 255', + primary: { + DEFAULT: '46, 236, 255', + '50': '230, 253, 255', + '100': '209, 251, 255', + '200': '168, 247, 255', + '300': '128, 243, 255', + '400': '87, 240, 255', + '500': '46, 236, 255', + '600': '0, 232, 255', + '700': '0, 172, 189', + '800': '0, 121, 133', + '900': '0, 70, 77', + 'text': '255, 255, 255', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '17, 29, 54', + '50': '49, 60, 155', + '100': '46, 59, 147', + '200': '35, 42, 112', + '300': '32, 41, 101', + '400': '28, 39, 89', + '500': '22, 37, 70', + '600': '20, 33, 62', + '700': '17, 29, 54', + '800': '15, 25, 47', + '900': '12, 21, 39', + '950': '11, 17, 35', + 'text': '209, 251, 255', + }, + }, + "ea7df14a1597407f9f755f05e25bab42": { + backdrop: "0, 121, 133", + placeholderText: '198, 242, 246', + actionButtonText: '0, 0, 0', + logo: '255, 255, 255', + primary: { + DEFAULT: '128, 226, 235', + '50': '255, 255, 255', + '100': '255, 255, 255', + '200': '234, 250, 252', + '300': '198, 242, 246', + '400': '163, 234, 241', + '500': '128, 226, 235', + '600': '80, 215, 227', + '700': '34, 201, 217', + '800': '26, 156, 168', + '900': '19, 111, 120', + '950': '15, 89, 96', + 'text': '255, 255, 255', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '46, 89, 112', + '50': '193, 217, 230', + '100': '179, 208, 224', + '200': '150, 191, 212', + '300': '121, 173, 200', + '400': '92, 155, 188', + '500': '34, 66, 83', + '600': '16, 35, 49', + '700': '15, 29, 39', + '800': '34, 66, 83', + '900': '22, 43, 54', + '950': '14, 27, 34', + 'text': '209, 251, 255', + } + }, + "light": { + placeholderText: '134, 134, 134', + actionButtonText: '255, 255, 255', + logo: '255, 0, 147', + primary: { + DEFAULT: '228, 37, 117', + '50': '248, 200, 220', + '100': '246, 182, 209', + '200': '241, 146, 186', + '300': '237, 110, 163', + '400': '232, 73, 140', + '500': '228, 37, 117', + '600': '166, 51, 94', + '700': '136, 17, 67', + '800': '147, 8, 99', + '900': '196, 153, 175', + 'text': '17, 17, 17', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '240, 240, 240', + '50': '49, 60, 155', + '100': '46, 59, 147', + '200': '134, 134, 134', + '300': '139, 139, 139', + '400': '177, 177, 177', + '500': '218, 218, 218', + '600': '223, 223, 223', + '700': '240, 240, 240', + '800': '243, 244, 246', + '900': '250, 248, 248', + '950': '255, 255, 255', + 'text': '108, 108, 108', + }, + }, + "default": { + backdrop: "62, 18, 64", + placeholderText: '140, 152, 192', + actionButtonText: '255, 255, 255', + logo: '255, 0, 147', + primary: { + DEFAULT: '228, 37, 117', + '50': '248, 200, 220', + '100': '246, 182, 209', + '200': '241, 146, 186', + '300': '237, 110, 163', + '400': '232, 73, 140', + '500': '228, 37, 117', + '600': '166, 51, 94', + '700': '136, 17, 67', + '800': '147, 8, 99', + '900': '110, 0, 64', + 'text': '255, 255, 255', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '17, 29, 54', + '50': '49, 60, 155', + '100': '46, 59, 147', + '200': '35, 42, 112', + '300': '32, 41, 101', + '400': '28, 39, 89', + '500': '22, 37, 70', + '600': '20, 33, 62', + '700': '17, 29, 54', + '800': '15, 25, 47', + '900': '12, 21, 39', + '950': '11, 17, 35', + 'text': '171, 181, 209', + }, + }, + "evmos": { + placeholderText: '128, 110, 107', + actionButtonText: '255, 255, 255', + logo: '226, 49, 115', + primary: { + DEFAULT: '237, 78, 51', + '50': '248, 200, 220', + '100': '246, 182, 209', + '200': '241, 146, 186', + '300': '237, 110, 163', + '400': '232, 73, 140', + '500': '219, 211, 209', + '600': '166, 51, 94', + '700': '136, 17, 67', + '800': '147, 8, 99', + '900': '237, 78, 51', + 'text': '74, 61, 59', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '239, 239, 239', + '50': '49, 60, 155', + '100': '46, 59, 147', + '200': '134, 134, 134', + '300': '139, 139, 139', + '400': '177, 177, 177', + '500': '227, 227, 227', + '600': '223, 223, 223', + '700': '244, 243, 242', + '800': '248, 247, 247', + '900': '250, 248, 248', + '950': '255, 255, 255', + 'text': '122, 91, 91', + }, + }, + "ton": { + placeholderText: '134, 134, 134', + actionButtonText: '255, 255, 255', + logo: '15, 15, 15', + primary: { + DEFAULT: '51, 144, 236', + '50': '248, 200, 220', + '100': '246, 182, 209', + '200': '241, 146, 186', + '300': '237, 110, 163', + '400': '232, 73, 140', + '500': '45, 148, 229', + '600': '166, 51, 94', + '700': '136, 17, 67', + '800': '45, 148, 229', + '900': '51, 144, 236', + 'text': '15, 15, 15', + 'textMuted': '86, 97, 123', + }, + secondary: { + DEFAULT: '240, 240, 240', + '50': '190, 195, 200', + '100': '199, 201, 206', + '200': '208, 210, 211', + '300': '212, 214, 219', + '400': '220, 222, 226', + '500': '227, 230, 233', + '600': '229, 231, 235', + '700': '241, 243, 245', + '800': '243, 244, 246', + '900': '250, 248, 248', + '950': '255, 255, 255', + 'text': '106, 119, 133', + }, + } +} \ No newline at end of file diff --git a/app/Models/Wizard.ts b/app/Models/Wizard.ts new file mode 100644 index 00000000..37b10c58 --- /dev/null +++ b/app/Models/Wizard.ts @@ -0,0 +1,75 @@ +import { FC } from "react" + +export type FormSteps = "SwapForm" | "Email" | "Code" | "OffRampExchangeOAuth" | "ExchangeOAuth" | "ExchangeApiCredentials" | "SwapConfirmation" + +export type SwapSteps = "Email" | "Code" | "Overview" | "Withdrawal" | "OffRampWithdrawal" | "Processing" | "Success" | "Failed" | "ExternalPayment" +export type LoginSteps = "Email" | "Code" + +export type BaseWizard = { + [key: string]: SwapCreateStep +} + +export type FormWizardSteps = { + [Property in FormSteps]: SwapCreateStep +} +export type SwapWizardSteps = { + [Property in SwapSteps]: SwapCreateStep +} +export type LoginWizardSteps = { + [Property in LoginSteps]: SwapCreateStep +} + +export enum SwapCreateStep { + MainForm = "MainForm", + Email = "Email", + Code = "Code", + PendingSwaps = "PendingSwaps", + AuthorizeCoinbaseWithdrawal = "AuthorizeCoinbaseWithdrawal", + OffRampOAuth = "OffRampOAuth", + ApiKey = "ApiKey", + TwoFactor = "TwoFactor", + ActiveSwapLimit = 'ActiveSwapLimit', + Error = "Error" +} + +export enum SwapWithdrawalStep { + Withdrawal = "Withdrawal", + CoinbaseManualWithdrawal = "CoinbaseManualWithdrawal", + SwapProcessing = 'SwapProcessing', + ProcessingWalletTransaction = "ProcessingWalletTransaction", + Success = "Success", + Failed = "Failed", + Error = "Error", + Delay = "Delay", + OffRampWithdrawal = "OffRampWithdrawal", + WithdrawFromImtblx = "WithdrawFromImtblx", + WithdrawFromStarknet = "WithdrawFromStarknet", + SelectWithdrawalType = "SelectWithdrawalType", + CoinbaseInternalWithdrawal = "CoinbaseInternalWithdrawal", +} + +export enum AuthStep { + Email = "Email", + Code = "Code", + PendingSwaps = 'PendingSwaps' +} + +export type Steps = AuthStep | SwapWithdrawalStep | SwapCreateStep + +export const ExchangeAuthorizationSteps: { [key: string]: SwapCreateStep } = { + "api_credentials": SwapCreateStep.ApiKey, + "o_auth2": SwapCreateStep.AuthorizeCoinbaseWithdrawal +} + +export const OfframpExchangeAuthorizationSteps: { [key: string]: SwapCreateStep } = { + "api_credentials": SwapCreateStep.ApiKey, + "o_auth2": SwapCreateStep.OffRampOAuth +} + +export class WizardStep { + Name: T; + Content: FC; + onBack?: () => void; + onNext?: (data?: any) => Promise; + positionPercent: number; +} \ No newline at end of file diff --git a/app/README.md b/app/README.md new file mode 100644 index 00000000..7f51bf28 --- /dev/null +++ b/app/README.md @@ -0,0 +1,2 @@ +# Bridge App +Up to date repository of Bridge UI App deployed at https://bridge.lux.network/app diff --git a/app/components/AddressIcon.tsx b/app/components/AddressIcon.tsx new file mode 100644 index 00000000..8033848f --- /dev/null +++ b/app/components/AddressIcon.tsx @@ -0,0 +1,23 @@ +import Jazzicon from "@metamask/jazzicon"; +import { FC, useEffect, useRef } from "react"; + +type Props = { + address: string; + size: number; +} +const AddressIcon: FC = ({ address, size }) => { + const ref = useRef(null) + useEffect(() => { + if (address && ref.current) { + ref.current.innerHTML = ""; + const iconElement = Jazzicon(size, parseInt(address.slice(2, 10), 16)) as HTMLElement + if(iconElement){ + iconElement.style.display = 'block' + ref.current.appendChild(iconElement); + } + } + }, [address, size]); + + return
+} +export default AddressIcon diff --git a/app/components/AvatarGroup.tsx b/app/components/AvatarGroup.tsx new file mode 100644 index 00000000..efba3894 --- /dev/null +++ b/app/components/AvatarGroup.tsx @@ -0,0 +1,28 @@ +import Image from 'next/image' +import { FC } from 'react' +type Props = { + imageUrls: string[] +} + +const AvatarGroup: FC = (({ imageUrls }) => { + return ( +
+
+ {imageUrls.map(x => { + return ( + + ) + })} +
+
+ ) +}); + +export default AvatarGroup; \ No newline at end of file diff --git a/app/components/Campaigns/Details/Leaderboard.tsx b/app/components/Campaigns/Details/Leaderboard.tsx new file mode 100644 index 00000000..d98d3ead --- /dev/null +++ b/app/components/Campaigns/Details/Leaderboard.tsx @@ -0,0 +1,200 @@ +import { FC, useState } from "react" +import { useSettingsState } from "../../../context/settings" +import Image from 'next/image' +import { Trophy } from "lucide-react" +import BridgeApiClient, { Campaign, Leaderboard, Reward } from "../../../lib/BridgeApiClient" +import { RewardsComponentLeaderboardSceleton } from "../../Sceletons" +import useSWR from "swr" +import { ApiResponse } from "../../../Models/ApiResponse" +import ClickTooltip from "../../Tooltips/ClickTooltip" +import shortenAddress from "../../utils/ShortenAddress" +import { useAccount } from "wagmi" +import { truncateDecimals } from "../../utils/RoundDecimals" +import AddressIcon from "../../AddressIcon"; +import Modal from "../../modal/modal"; +import Link from "next/link"; + +type Props = { + campaign: Campaign +} +const Component: FC = ({ campaign }) => { + const [openTopModal, setOpenTopModal] = useState(false) + const settings = useSettingsState() + const { address } = useAccount(); + + const handleOpenTopModal = () => { + setOpenTopModal(true) + } + + const apiClient = new BridgeApiClient() + const { data: leaderboardData, isLoading } = useSWR>(`/campaigns/${campaign?.id}/leaderboard`, apiClient.fetcher, { dedupingInterval: 60000 }) + const { data: rewardsData, isLoading: rewardsIsLoading } = useSWR>(`/campaigns/${campaign.id}/rewards/${address}`, apiClient.fetcher, { dedupingInterval: 60000 }) + const leaderboard = leaderboardData?.data + + if (isLoading) { + return + } + + if (!leaderboard) { + //TODO handle + return <> + } + + const rewards = rewardsData?.data + const { resolveImgSrc, networks, currencies } = settings + const network = networks.find(n => n.internal_name === campaign?.network) + const position = rewards?.user_reward.position || NaN + const campaignAsset = currencies.find(c => c?.asset === campaign?.asset) + + const leaderboardRewards = [ + leaderboard.leaderboard_budget * 0.6, + leaderboard.leaderboard_budget * 0.3, + leaderboard.leaderboard_budget * 0.1 + ] + + return
+ {leaderboard?.leaderboard?.length > 0 && +
+

Leaderboard

+ +
} +

Users who earn the most throughout the program will be featured here.

+
+
+ {leaderboard?.leaderboard?.length > 0 ?
+ { + leaderboard?.leaderboard?.filter(u => u.position < 4).map(user => ( +
+
+

{user.position}.

+
+ +
+
+ {user?.address && network?.account_explorer_template && + {user?.position === rewards?.user_reward?.position ? You : shortenAddress(user?.address)} + } +
+

{truncateDecimals(user.amount, campaignAsset?.precision)} {campaign?.asset}

+
+
+
+ { + leaderboard.leaderboard_budget > 0 &&
+ + + +
+ Project Logo +
+

+ {leaderboardRewards[user.position - 1]} {campaign?.asset} +

+
}> +
+
+ +
+ } +
+ )) + + } + { + position >= 4 && address && rewards?.user_reward && +
4 ? "!mt-0 !pt-0" : ""}> + {position > 4 && < div className="text-2xl text-center leading-3 text-secondary-text my-3"> + ... +
} +
+
+

{position}.

+
+ +
+
+ {network?.account_explorer_template && + You + } +
+

{truncateDecimals(rewards.user_reward.total_amount, campaignAsset?.precision)} {campaign?.asset}

+
+
+
+
+
+ } +
+ : +
+ Here will be leaderboard. +
+ } +
+
+ +
+
+
+ { + leaderboard?.leaderboard?.map(user => ( +
+
+

{user.position}.

+
+ +
+
+ {user?.address && network?.account_explorer_template && + {user.position === rewards?.user_reward?.position ? You : shortenAddress(user.address)} + } +
+

{truncateDecimals(user.amount, campaignAsset?.precision)} {campaign?.asset}

+
+
+
+ { + user.position < 4 && leaderboard.leaderboard_budget > 0 && +
+ + + +
+ Address Logo +
+

+ {leaderboardRewards[user.position - 1]} {campaign?.asset} +

+
+ }> +
+
+ +
+ } +
+ )) + } +
+
+ +
+ +} +export default Component \ No newline at end of file diff --git a/app/components/Campaigns/Details/Rewards.tsx b/app/components/Campaigns/Details/Rewards.tsx new file mode 100644 index 00000000..61a773af --- /dev/null +++ b/app/components/Campaigns/Details/Rewards.tsx @@ -0,0 +1,175 @@ + +import { FC } from "react" +import { useSettingsState } from "../../../context/settings" +import Image from 'next/image' +import BackgroundField from "../../backgroundField"; +import { Clock } from "lucide-react" +import BridgeApiClient, { Campaign, Reward, RewardPayout } from "../../../lib/BridgeApiClient" +import { RewardsComponentSceleton } from "../../Sceletons" +import useSWR from "swr" +import { ApiResponse } from "../../../Models/ApiResponse" +import ClickTooltip from "../../Tooltips/ClickTooltip" +import shortenAddress from "../../utils/ShortenAddress" +import { useAccount } from "wagmi" +import { Progress } from "../../ProgressBar"; + +type Props = { + campaign: Campaign +} + +const Rewards: FC = ({ campaign }) => { + const settings = useSettingsState() + const { resolveImgSrc, networks, currencies } = settings + const { address } = useAccount(); + const apiClient = new BridgeApiClient() + + const { data: rewardsData, isLoading: rewardsIsLoading } = useSWR>(`/campaigns/${campaign.id}/rewards/${address}`, apiClient.fetcher, { dedupingInterval: 60000 }) + const { data: payoutsData, isLoading: payoutsIsLoading } = useSWR>(`/campaigns/${campaign.id}/payouts/${address}`, apiClient.fetcher, { dedupingInterval: 60000 }) + + if (rewardsIsLoading || payoutsIsLoading) { + return + } + + const payouts = payoutsData?.data || [] + const totalBudget = campaign.total_budget + + const network = networks.find(n => n.internal_name === campaign.network) + const rewards = rewardsData?.data + const campaignEndDate = new Date(campaign.end_date) + const now = new Date() + const next = rewards?.next_airdrop_date ? new Date(rewards?.next_airdrop_date) : null + + const difference_in_days = next ? + Math.floor(Math.abs(((next.getTime() - now.getTime())) / (1000 * 3600 * 24))) : null + + const difference_in_hours = (next && difference_in_days) ? + Math.round(Math.abs(((next.getTime() - now.getTime())) / (1000 * 3600) - (difference_in_days * 24))) + : null + + const campaignIsEnded = (campaignEndDate.getTime() - now.getTime()) < 0 || campaign.status !== 'active' + + const DistributedAmount = ((campaign.distributed_amount / campaign.total_budget) * 100) + const usdc_price = settings?.currencies?.find(c => c.asset === campaign.asset)?.usd_price + const total_amount = rewards?.user_reward.total_amount + const total_in_usd = (total_amount && usdc_price) ? (usdc_price * total_amount).toFixed(2) : null + + return <> +
+

+ + Onboarding incentives that are earned by transferring to {network?.display_name} + Learn more + +

+
+ {!campaignIsEnded && + Pending Earnings  Next Airdrop} withoutBorder> +
+
+
+ Project Logo +
+

+ {rewards?.user_reward.total_pending_amount} {campaign?.asset} +

+
+
+ +

+ {difference_in_days}d {difference_in_hours}h +

+
+
+
+ } + Total Earnings Current Value} withoutBorder> +
+
+
+ Project Logo +
+

+ {rewards?.user_reward.total_amount} {campaign?.asset} +

+
+

+ ${total_in_usd} +

+
+
+
+ +
+
+ +

{campaign?.asset} pool + +

+ + } withoutBorder> +
+ +
+
{campaign?.distributed_amount.toFixed(0)} / {totalBudget} {campaign?.asset}
+
+
+
+
+ { + payouts.length > 0 && +
+

Payouts

+
+
+
+ + + + + + + + + + {payouts.map((payout) => ( + + + + + + ))} + +
+ Tx Id + + Amount + + Date +
+ {shortenAddress(payout.transaction_id)} + {payout.amount}{new Date(payout.date).toLocaleString()}
+
+
+
+
+ } + +} +export default Rewards \ No newline at end of file diff --git a/app/components/Campaigns/Details/index.tsx b/app/components/Campaigns/Details/index.tsx new file mode 100644 index 00000000..1ad518f0 --- /dev/null +++ b/app/components/Campaigns/Details/index.tsx @@ -0,0 +1,129 @@ +import { useRouter } from "next/router" +import { FC } from "react" +import { useSettingsState } from "../../../context/settings" +import Image from 'next/image' +import { Gift } from "lucide-react" +import BridgeApiClient, { Campaign } from "../../../lib/BridgeApiClient" +import useSWR from "swr" +import { ApiResponse } from "../../../Models/ApiResponse" +import { useAccount } from "wagmi" +import RainbowKit from "../../Swap/Withdraw/Wallet/RainbowKit" +import SubmitButton from "../../buttons/submitButton"; +import WalletIcon from "../../icons/WalletIcon"; +import Link from "next/link"; +import LinkWrapper from "../../LinkWraapper"; +import { Widget } from "../../Widget/Index"; +import Leaderboard from "./Leaderboard" +import { CryptoNetwork } from "../../../Models/CryptoNetwork"; +import Rewards from "./Rewards"; +import SpinIcon from "../../icons/spinIcon" + +function CampaignDetails() { + + const settings = useSettingsState() + const router = useRouter(); + const { resolveImgSrc, networks } = settings + const camapaignName = router.query.campaign?.toString() + + const { isConnected } = useAccount(); + + const apiClient = new BridgeApiClient() + const { data: campaignsData, isLoading } = useSWR>('/campaigns', apiClient.fetcher) + const campaign = campaignsData?.data?.find(c => c.name === camapaignName) + + const network = networks.find(n => n.internal_name === campaign?.network) + + if (isLoading) { + return + } + + if (!campaign) { + return + } + + return ( + + +
+
+
+ {network && Project Logo} +
+

+ {network?.display_name} Rewards +

+
+ { + isConnected ? + + : + + } + +
+
+ <> + { + !isConnected && + + + }> + Connect a wallet + + + + } + +
+ ) +} + +type BriefInformationProps = { + campaign: Campaign, + network?: CryptoNetwork +} +const BriefInformation: FC = ({ campaign, network }) => +

+ You can earn $ + {campaign?.asset} +  tokens by transferring assets to  + {network?.display_name || campaign.network} + . For each transaction, you&ll receive  + {campaign?.percentage} + % of Bridge fee back.  + + Learn more + +

+ +const Loading = () => + +
+ +
+
+
+ +const NotFound = () => + +
+ +

Campaign not found

+ See all campaigns +
+
+
+ +export default CampaignDetails; \ No newline at end of file diff --git a/app/components/Campaigns/index.tsx b/app/components/Campaigns/index.tsx new file mode 100644 index 00000000..f157b0df --- /dev/null +++ b/app/components/Campaigns/index.tsx @@ -0,0 +1,117 @@ +import { Gift } from "lucide-react"; +import { useRouter } from "next/router"; +import { FC, useCallback } from "react"; +import { ApiResponse } from "../../Models/ApiResponse"; +import BridgeApiClient, { Campaign } from "../../lib/BridgeApiClient"; +import SpinIcon from "../icons/spinIcon"; +import useSWR from 'swr' +import { useSettingsState } from "../../context/settings"; +import Image from "next/image"; +import LinkWrapper from "../LinkWraapper"; +import { Layer } from "../../Models/Layer"; +import { Widget } from "../Widget/Index"; + +const Rewards = () => { + + const { layers, resolveImgSrc } = useSettingsState() + const apiClient = new BridgeApiClient() + const { data: campaignsData, isLoading } = useSWR>('/campaigns', apiClient.fetcher) + const campaigns = campaignsData?.data + + const activeCampaigns = campaigns?.filter(IsCampaignActive) || [] + const inactiveCampaigns = campaigns?.filter(c => !IsCampaignActive(c)) || [] + + return ( + + + {!isLoading ? +
+
+

Campaigns

+
+
+ { + activeCampaigns.length > 0 ? + activeCampaigns.map(c => + ) + : +
+ +

There are no active campaigns right now

+
+ } +
+
+
+ { + inactiveCampaigns.length > 0 && +
+

Old campaigns

+
+
+ {inactiveCampaigns.map(c => + )} +
+
+
+ } +
+ : +
+ +
+ } +
+
+ ) +} +type CampaignProps = { + campaign: Campaign, + layers: Layer[], + resolveImgSrc: (item: Layer) => string +} +const CampaignItem: FC = ({ campaign, layers, resolveImgSrc }) => { + + const campaignLayer = layers.find(l => l.internal_name === campaign.network) + const campaignDaysLeft = ((new Date(campaign.end_date).getTime() - new Date().getTime()) / 86400000).toFixed() + const campaignIsActive = IsCampaignActive(campaign) + + return + + + {campaignLayer && Project Logo} + + {campaign?.display_name} + + { + campaignIsActive && + + {campaignDaysLeft} days left + + } + +} + +function IsCampaignActive(campaign: Campaign) { + const now = new Date() + return campaign.status == 'active' && (new Date(campaign?.end_date).getTime() > now.getTime()) +} + +export default Rewards \ No newline at end of file diff --git a/app/components/Carousel.tsx b/app/components/Carousel.tsx new file mode 100644 index 00000000..295cf970 --- /dev/null +++ b/app/components/Carousel.tsx @@ -0,0 +1,84 @@ +import React, { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useState } from "react"; +import { useSwipeable } from "react-swipeable"; + + +interface CarouselItemProps { + children?: JSX.Element | JSX.Element[]; + width: number; +} + +export const CarouselItem: React.FC = ({ children, width }) => { + return ( +
+ {children} +
+ ); +}; + +interface CarouselProps { + children?: JSX.Element | JSX.Element[]; + starAtLast: boolean; + onLast: (value: boolean) => void; + onFirst: (value: boolean) => void; +} + +export type CarouselRef = { + next: () => void; + prev: () => void; + goToLast: () => void; + goToFirst: () => void; +}; + +const Carousel = forwardRef(function Carousel({ onFirst, onLast, children, starAtLast }, ref) { + const [activeIndex, setActiveIndex] = useState(starAtLast ? React.Children.count(children) - 1 : 0); + + const updateIndex = useCallback((newIndex) => { + onFirst(false) + onLast(false) + if (newIndex >= 0 && newIndex <= React.Children.count(children) - 1) { + setActiveIndex(newIndex); + } + if (newIndex >= React.Children.count(children) - 1) + onLast(true) + if (newIndex == 0) + onFirst(true) + }, [children, onFirst, onLast]); + + useImperativeHandle(ref, () => ({ + next: () => { + updateIndex(activeIndex + 1) + }, + prev: () => { + updateIndex(activeIndex - 1); + }, + goToLast: () => { + updateIndex(React.Children.count(children) - 1); + }, + goToFirst: () => { + updateIndex(0); + } + }), [activeIndex, children, updateIndex]); + + const handlers = useSwipeable({ + onSwipedLeft: () => updateIndex(activeIndex + 1), + onSwipedRight: () => updateIndex(activeIndex - 1), + }); + + return ( +
+
+ {children && React.Children.map(children, (child, index) => { + return React.cloneElement(child, { width: "100%" }); + })} +
+
+ ); +}); + +export default Carousel; diff --git a/app/components/ColorSchema.tsx b/app/components/ColorSchema.tsx new file mode 100644 index 00000000..4771d05f --- /dev/null +++ b/app/components/ColorSchema.tsx @@ -0,0 +1,54 @@ +import { FC } from "react"; +import { THEME_COLORS, ThemeData } from "../Models/Theme"; + +type Props = { + themeData?: ThemeData | null +} +const ColorSchema: FC = ({ themeData }) => { + themeData = themeData || THEME_COLORS.default + return ( + <> + {themeData && + + } + + ) +} +export default ColorSchema \ No newline at end of file diff --git a/app/components/Common/AverageCompletionTime.tsx b/app/components/Common/AverageCompletionTime.tsx new file mode 100644 index 00000000..3119123c --- /dev/null +++ b/app/components/Common/AverageCompletionTime.tsx @@ -0,0 +1,24 @@ +import { FC } from "react"; + +type AverageCompletionTimeProps = { + time: string | undefined +} + +const AverageCompletionTime: FC = ({ time }) => { + + if (!time) { + return ~1-2 minutes + } + + const parts = time?.split(":"); + const averageTimeInMinutes = parts && parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10) + parseInt(parts[2]) / 60 + + const hours = Math.floor(averageTimeInMinutes / 60); + const minutes = averageTimeInMinutes % 60; + + if (averageTimeInMinutes > 1 && averageTimeInMinutes < 60) return ~{averageTimeInMinutes.toFixed()} minutes + else if (averageTimeInMinutes >= 60) return ~{hours} {hours > 1 ? 'hours' : 'hour'} {minutes > 0 ? ` ${minutes?.toFixed()} minutes` : ''} + else return ~1-2 minutes +} + +export default AverageCompletionTime \ No newline at end of file diff --git a/app/components/Common/FormattedDate.tsx b/app/components/Common/FormattedDate.tsx new file mode 100644 index 00000000..99d61ba2 --- /dev/null +++ b/app/components/Common/FormattedDate.tsx @@ -0,0 +1,12 @@ +const FormattedDate = ({ date }: { date: string }) => { + const swapDate = new Date(date); + const yyyy = swapDate.getFullYear(); + let mm = swapDate.getMonth() + 1; // Months start at 0! + let dd = swapDate.getDate(); + + if (dd < 10) dd = 0 + dd; + if (mm < 10) mm = 0 + mm; + + return <>{dd + '/' + mm + '/' + yyyy}; +} +export default FormattedDate \ No newline at end of file diff --git a/app/components/Common/ReactPortal.tsx b/app/components/Common/ReactPortal.tsx new file mode 100644 index 00000000..abeebf71 --- /dev/null +++ b/app/components/Common/ReactPortal.tsx @@ -0,0 +1,27 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +type Props = { + wrapperId: string; + children?: React.ReactNode +} + +const ReactPortal: FC = ({ children, wrapperId = "react-portal-wrapper" }) => { + const ref = useRef(null); + const [mounted, setMounted] = useState(false) + + useEffect(() => { + let element = document.getElementById(wrapperId); + if (!element) { + element = document.createElement('div'); + element.setAttribute("id", wrapperId); + document.body.appendChild(element); + } + ref.current = element + setMounted(true) + }, [wrapperId]); + + return ref.current && mounted ? createPortal(children, ref.current) : null; +}; + +export default ReactPortal \ No newline at end of file diff --git a/app/components/ConnectNetwork.tsx b/app/components/ConnectNetwork.tsx new file mode 100644 index 00000000..44df28c0 --- /dev/null +++ b/app/components/ConnectNetwork.tsx @@ -0,0 +1,32 @@ +import SubmitButton from './buttons/submitButton'; +import { Link } from 'lucide-react'; +import { FC } from 'react'; + +type Props = { + NetworkDisplayName: string; + AppURL: string; +} + +const ConnectNetwork: FC = ({ NetworkDisplayName, AppURL }) => { + const connectButtonIcon = + + return ( +
+
+

+

+ {NetworkDisplayName} account with the provided address does not exist. To create one, go to {NetworkDisplayName} and connect your wallet. +

+

+ +
+ window.open(AppURL, '_blank')}> + Connect + +
+
+
+ ) +} + +export default ConnectNetwork; \ No newline at end of file diff --git a/app/components/ConnectedWallets.tsx b/app/components/ConnectedWallets.tsx new file mode 100644 index 00000000..561f5d9e --- /dev/null +++ b/app/components/ConnectedWallets.tsx @@ -0,0 +1,148 @@ +import WalletIcon from "./icons/WalletIcon" +import shortenAddress from "./utils/ShortenAddress" +import useWallet from "../hooks/useWallet" +import ConnectButton from "./buttons/connectButton" +import SubmitButton from "./buttons/submitButton" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./shadcn/dialog" +import { useState } from "react" +import { Plus } from "lucide-react" +import AddressIcon from "./AddressIcon" +import { Wallet } from "../stores/walletStore" + +export const WalletsHeader = () => { + const { wallets } = useWallet() + const [openDialog, setOpenDialog] = useState(false) + + if (wallets.length > 0) { + return ( + <> + + + + ) + } + + return ( + +
+ +
+
+ ) +} + +const WalletsIcons = ({ wallets }: { wallets: Wallet[] }) => { + const firstWallet = wallets[0] + const secondWallet = wallets[1] + + return ( +
+ { + firstWallet?.connector && + + } + { + secondWallet?.connector && + + } + { + wallets.length > 2 && +
+ +{wallets.length - 2} +
+ } +
+ ) +} + +export const WalletsMenu = () => { + const [openDialog, setOpenDialog] = useState(false) + const { wallets } = useWallet() + const wallet = wallets[0] + if (wallets.length > 0) { + return ( + <> + + + + ) + } + + return ( + + } className="bg-primary/20 border-none !text-primary !px-4" type="button" isDisabled={false} isSubmitting={false}> + Connect a wallet + + + ) +} + +const ConnectedWalletsDialog = ({ openDialog, setOpenDialog }: { openDialog: boolean, setOpenDialog: (open: boolean) => void }) => { + const { wallets, disconnectWallet } = useWallet() + + return ( + + + + Wallets + +
+ { + wallets.map((wallet, index) => ( +
+
+ { + wallet.connector && +
+ +
+ } +

{shortenAddress(wallet.address)}

+
+ +
+ )) + } +
+ + setOpenDialog(false)}> +
+ + + Link a new wallet + +
+
+
+
+
+ ) +} diff --git a/app/components/ContactSupport.tsx b/app/components/ContactSupport.tsx new file mode 100644 index 00000000..d32826ef --- /dev/null +++ b/app/components/ContactSupport.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" +import { useIntercom } from "react-use-intercom" +import { useAuthState } from "../context/authContext" + +const ContactSupport: FC<{ children?: React.ReactNode }> = ({ children }) => { + const { email, userId } = useAuthState() + const { boot, show, update } = useIntercom() + const updateWithProps = () => update({ email: email, userId: userId }) + + return { + boot(); + show(); + updateWithProps() + }}> + {children} + +} +export default ContactSupport \ No newline at end of file diff --git a/app/components/DTOs/SwapConfirmationFormValues.ts b/app/components/DTOs/SwapConfirmationFormValues.ts new file mode 100644 index 00000000..9e50b2ce --- /dev/null +++ b/app/components/DTOs/SwapConfirmationFormValues.ts @@ -0,0 +1,3 @@ +export interface SwapConfirmationFormValues { + RightWallet: boolean; +} \ No newline at end of file diff --git a/app/components/DTOs/SwapFormValues.ts b/app/components/DTOs/SwapFormValues.ts new file mode 100644 index 00000000..40b3a4c8 --- /dev/null +++ b/app/components/DTOs/SwapFormValues.ts @@ -0,0 +1,12 @@ +import { Currency } from "../../Models/Currency"; +import { Layer } from "../../Models/Layer"; + + +export type SwapFormValues = { + amount?: string; + destination_address?: string; + currency?: Currency; + refuel?: boolean; + from?: Layer; + to?: Layer; +} diff --git a/app/components/DisclosureComponents/FeeDetails/Campaign.tsx b/app/components/DisclosureComponents/FeeDetails/Campaign.tsx new file mode 100644 index 00000000..a2616080 --- /dev/null +++ b/app/components/DisclosureComponents/FeeDetails/Campaign.tsx @@ -0,0 +1,91 @@ +import { FC } from "react" +import { Currency } from "../../../Models/Currency" +import { Layer } from "../../../Models/Layer" +import BridgeApiClient, { Campaign } from "../../../lib/BridgeApiClient" +import useSWR from "swr" +import { ApiResponse } from "../../../Models/ApiResponse" +import { useSettingsState } from "../../../context/settings" +import { truncateDecimals } from "../../utils/RoundDecimals" +import { motion } from "framer-motion" +import ClickTooltip from "../../Tooltips/ClickTooltip" +import Image from 'next/image'; + +type CampaignProps = { + destination: Layer, + fee: number, + selected_currency: Currency, +} +const Campaign: FC = ({ + destination, + fee, + selected_currency +}) => { + const apiClient = new BridgeApiClient() + const { data: campaignsData } = useSWR>('/campaigns', apiClient.fetcher) + + const now = new Date().getTime() + + const campaign = campaignsData + ?.data + ?.find(c => + c?.network === destination?.internal_name + && c.status == 'active' + && new Date(c?.end_date).getTime() - now > 0) + + if (!campaign) + return <> + + return +} +type CampaignDisplayProps = { + campaign: Campaign, + fee: number, + selected_currency: Currency, +} +const CampaignDisplay: FC = ({ campaign, fee, selected_currency }) => { + const { resolveImgSrc, currencies } = useSettingsState() + const campaignAsset = currencies.find(c => c?.asset === campaign?.asset) + const feeinUsd = fee * selected_currency.usd_price + const reward = truncateDecimals(((feeinUsd * (campaign?.percentage || 0) / 100) / (campaignAsset?.usd_price || 1)), (campaignAsset?.precision || 0)) + + return +
+

Est. {campaignAsset?.asset} Reward

+ The amount of onboarding reward that you’ll earn. Learn more} /> +
+ { + Number(reward) > 0 && +
+ + +
+ Project Logo +
+

+ {reward} {campaignAsset?.asset} +

+
+ } +
+} + +export default Campaign \ No newline at end of file diff --git a/app/components/DisclosureComponents/FeeDetails/DetailedEstimates.tsx b/app/components/DisclosureComponents/FeeDetails/DetailedEstimates.tsx new file mode 100644 index 00000000..3f130205 --- /dev/null +++ b/app/components/DisclosureComponents/FeeDetails/DetailedEstimates.tsx @@ -0,0 +1,97 @@ +import { FC } from "react"; +import { CryptoNetwork } from "../../../Models/CryptoNetwork"; +import { Currency } from "../../../Models/Currency"; +import { Layer } from "../../../Models/Layer"; +import { truncateDecimals } from "../../utils/RoundDecimals"; +import { GetDefaultNetwork, GetNetworkCurrency } from "../../../helpers/settingsHelper"; +import AverageCompletionTime from "../../Common/AverageCompletionTime"; +import { useBalancesState } from "../../../context/balances"; + +type EstimatesProps = { + networks: CryptoNetwork[] + source?: Layer | null, + destination?: Layer | null, + selected_currency?: Currency | null, + currencies: Currency[], + fee: number +} +const DetailedEstimates: FC = ({ + currencies, + source, + destination, + selected_currency, + fee }) => { + + const parsedFee = parseFloat(fee.toFixed(selected_currency?.precision)) + const currencyName = selected_currency?.asset || " " + + return <> +
+ +
+ {parsedFee} {currencyName} +
+
+ { + source + && source?.isExchange === false + && selected_currency && + + } + + +} +type NetworkGasProps = { + network: Layer & { isExchange: false }, + selected_currency: Currency, + currencies: Currency[] +} +const NetworkGas: FC = ({ selected_currency, network, currencies }) => { + + const { gases, isGasLoading } = useBalancesState() + const networkGas = network.internal_name ? + gases?.[network.internal_name]?.find(g => g?.token === selected_currency.asset)?.gas : null + + if (!networkGas) + return <> + + const source_native_currnecy = currencies.find(a => a.asset === network.native_currency) + + const estimatedGas = (networkGas && source_native_currnecy) ? + truncateDecimals(networkGas, source_native_currnecy?.precision) + : truncateDecimals(networkGas, selected_currency?.precision) + + return
+ +
+ {isGasLoading ?
: estimatedGas} {network?.native_currency ?? selected_currency.asset} +
+
+} +type EstimatedArrivalProps = { + destination?: Layer | null, + currency?: Currency | null +} +const EstimatedArrival: FC = ({ currency, destination }) => { + const destinationNetworkCurrency = (destination && currency) ? GetNetworkCurrency(destination, currency.asset) : null + const destinationNetwork = GetDefaultNetwork(destination, currency?.asset) + + return
+ + + { + destinationNetworkCurrency?.status == 'insufficient_liquidity' ? + Up to 2 hours (delayed) + : + + } + +
+} +export default DetailedEstimates \ No newline at end of file diff --git a/app/components/DisclosureComponents/FeeDetails/ReceiveAmounts.tsx b/app/components/DisclosureComponents/FeeDetails/ReceiveAmounts.tsx new file mode 100644 index 00000000..123fae97 --- /dev/null +++ b/app/components/DisclosureComponents/FeeDetails/ReceiveAmounts.tsx @@ -0,0 +1,75 @@ +import { FC } from "react"; +import { Currency } from "../../../Models/Currency"; +import { Layer } from "../../../Models/Layer"; +import { GetDefaultNetwork, GetNetworkCurrency } from "../../../helpers/settingsHelper"; +import { CaluclateRefuelAmount } from "../../../lib/fees"; +import { truncateDecimals } from "../../utils/RoundDecimals"; + +type WillReceiveProps = { + receive_amount?: number; + currency?: Currency | null; + to: Layer | undefined | null; + currencies: Currency[]; + refuel: boolean +} +export const ReceiveAmounts: FC = ({ receive_amount, currency, to, currencies, refuel }) => { + const parsedReceiveAmount = parseFloat(receive_amount?.toFixed(currency?.precision) || "") + const destinationNetworkCurrency = (to && currency) ? GetNetworkCurrency(to, currency.asset) : null + + return <> + + You will receive + +
+ + { + parsedReceiveAmount > 0 ? +
+

+ <>{parsedReceiveAmount} +   + + {destinationNetworkCurrency?.name} + +

+ {refuel && } +
+ : '-' + } +
+
+ +} +type RefuelProps = { + currency?: Currency | null; + to?: Layer | null; + currencies: Currency[]; + refuel: boolean +} +export const Refuel: FC = ({ to, currency, currencies, refuel }) => { + const destination_native_asset = GetDefaultNetwork(to, currency?.asset)?.native_currency + const refuel_native_currency = currencies.find(c => c.asset === destination_native_asset) + const refuelCalculations = CaluclateRefuelAmount({ + allCurrencies: currencies, + refuelEnabled: refuel, + currency, + to + }) + const { refuelAmountInNativeCurrency } = refuelCalculations + const truncated_refuel = truncateDecimals(refuelAmountInNativeCurrency, refuel_native_currency?.precision) + + return <> + { + truncated_refuel > 0 ?

+ <>+ {truncated_refuel} {destination_native_asset} +

+ : null + } + + +} \ No newline at end of file diff --git a/app/components/DisclosureComponents/FeeDetails/index.tsx b/app/components/DisclosureComponents/FeeDetails/index.tsx new file mode 100644 index 00000000..31c785b0 --- /dev/null +++ b/app/components/DisclosureComponents/FeeDetails/index.tsx @@ -0,0 +1,55 @@ + +import { useSettingsState } from '../../../context/settings'; +import { CalculateFee, CalculateReceiveAmount } from '../../../lib/fees'; +import { SwapFormValues } from '../../DTOs/SwapFormValues'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../shadcn/accordion'; +import { ReceiveAmounts } from './ReceiveAmounts'; +import DetailedEstimates from './DetailedEstimates'; +import Campaign from './Campaign'; + +export default function FeeDetails({ values }: { values: SwapFormValues }) { + const { networks, currencies } = useSettingsState() + const { currency, from, to, refuel } = values || {} + + let fee = CalculateFee(values, networks); + let receive_amount = CalculateReceiveAmount(values, networks, currencies); + + return ( + <> +
+ + + + + + + + + + +
+ { + values.to && + values.currency && + + } + + ) +} diff --git a/app/components/ErrorFallback.tsx b/app/components/ErrorFallback.tsx new file mode 100644 index 00000000..533af0df --- /dev/null +++ b/app/components/ErrorFallback.tsx @@ -0,0 +1,76 @@ +import { Home, RefreshCcw } from "lucide-react"; +import React from "react"; +import SubmitButton from "./buttons/submitButton" +import ContactSupport from "./ContactSupport" +import MessageComponent from "./MessageComponent" +import Navbar from "./navbar" +import GoHomeButton from "./utils/GoHome" + +export default function ErrorFallback({ error, resetErrorBoundary }) { + + const extension_error = error.stack.includes("chrome-extension", "app://") + + return ( +
+
+
+ +
+ + + + Unable to complete the request + + +

+ Sorry, but we were unable to complete this request.  + { + extension_error ? + It seems that some of your extensions are preventing the app from running. + : We are informed, and are now investigating the issue. + } + +

+

+ { + extension_error ? + Please disable extensions and try again or open in incognito mode. If the issue keeps happening,  + : + Please try again. If the issue keeps happening,  + } + + contact our support team. +

+
+
+ +
+
+ { + + + + } +
+
+ + resetErrorBoundary() + } + icon={ +
+
+
+
+
+
+
+
+
+ ); +} + diff --git a/app/components/HeaderWithMenu/index.tsx b/app/components/HeaderWithMenu/index.tsx new file mode 100644 index 00000000..31d35926 --- /dev/null +++ b/app/components/HeaderWithMenu/index.tsx @@ -0,0 +1,53 @@ +import { useIntercom } from "react-use-intercom" +import { useAuthState } from "../../context/authContext" +import IconButton from "../buttons/iconButton" +import GoHomeButton from "../utils/GoHome" +import { ArrowLeft } from 'lucide-react' +import ChatIcon from "../icons/ChatIcon" +import dynamic from "next/dynamic" +import BridgeMenu from "../BridgeMenu" + +const WalletsHeader = dynamic(() => import("../ConnectedWallets").then((comp) => comp.WalletsHeader), { + loading: () => <> +}) + +function HeaderWithMenu({ goBack }: { goBack: (() => void) | undefined | null }) { + const { email, userId } = useAuthState() + const { boot, show, update } = useIntercom() + const updateWithProps = () => update({ email: email, userId: userId }) + + return ( +
+ { + goBack && + + }> + + } +
+ +
+ +
+ + { + boot(); + show(); + updateWithProps() + }} + icon={ + + }> + +
+ +
+
+
+ ) +} + +export default HeaderWithMenu \ No newline at end of file diff --git a/app/components/Input/Address.tsx b/app/components/Input/Address.tsx new file mode 100644 index 00000000..1a52eef9 --- /dev/null +++ b/app/components/Input/Address.tsx @@ -0,0 +1,342 @@ +import { useFormikContext } from "formik"; +import { ChangeEvent, FC, forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AddressBookItem } from "../../lib/BridgeApiClient"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import { classNames } from '../utils/classNames' +import { useSwapDataUpdate } from "../../context/swap"; +import { Info } from "lucide-react"; +import KnownInternalNames from "../../lib/knownIds"; +import { useSettingsState } from "../../context/settings"; +import { isValidAddress } from "../../lib/addressValidator"; +import { RadioGroup } from "@headlessui/react"; +import Image from 'next/image'; +import { Partner } from "../../Models/Partner"; +import shortenAddress from "../utils/ShortenAddress"; +import AddressIcon from "../AddressIcon"; +import { GetDefaultNetwork } from "../../helpers/settingsHelper"; +import WalletIcon from "../icons/WalletIcon"; +import useWallet from "../../hooks/useWallet"; + +interface Input extends Omit, 'ref' | 'as' | 'onChange'> { + hideLabel?: boolean; + disabled: boolean; + name: string; + children?: JSX.Element | JSX.Element[]; + ref?: any; + close: () => void, + isPartnerWallet: boolean, + partnerImage?: string, + partner?: Partner, + canFocus?: boolean, + address_book?: AddressBookItem[] +} + +const Address: FC = forwardRef(function Address + ({ name, canFocus, close, address_book, disabled, isPartnerWallet, partnerImage, partner }, ref) { + const { + values, + setFieldValue + } = useFormikContext(); + + const [wrongNetwork, setWrongNetwork] = useState(false) + const inputReference = useRef(null); + const destination = values.to + const asset = values.currency?.asset + const destinationNetwork = GetDefaultNetwork(destination, asset) + const valid_addresses = address_book?.filter(a => (destination?.isExchange ? a.exchanges?.some(e => destination?.internal_name === e) : a.networks?.some(n => destination?.internal_name === n)) && isValidAddress(a.address, destination)) || [] + + const { setDepositeAddressIsfromAccount, setAddressConfirmed } = useSwapDataUpdate() + const placeholder = "Enter your address here" + const [inputValue, setInputValue] = useState(values?.destination_address || "") + const [validInputAddress, setValidInputAddress] = useState('') + const destinationIsStarknet = destination?.internal_name === KnownInternalNames.Networks.StarkNetGoerli + || destination?.internal_name === KnownInternalNames.Networks.StarkNetMainnet + + const { connectWallet, disconnectWallet, getAutofillProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return values?.to && getProvider(values?.to) + }, [values?.to, getProvider]) + + const connectedWallet = provider?.getConnectedWallet() + const settings = useSettingsState() + + useEffect(() => { + if (destination && !destination?.isExchange && isValidAddress(connectedWallet?.address, destination) && !values?.destination_address) { + //TODO move to wallet implementation + if (connectedWallet + && connectedWallet.providerName === 'starknet' + && (connectedWallet.chainId != destinationChainId) + && destination) { + (async () => { + setWrongNetwork(true) + await disconnectWallet(connectedWallet.providerName) + })() + return + } + setInputValue(connectedWallet?.address) + setAddressConfirmed(true) + setFieldValue("destination_address", connectedWallet?.address) + } + }, [connectedWallet?.address, destination?.isExchange, destination]) + + useEffect(() => { + if (canFocus) { + inputReference?.current?.focus() + } + }, [canFocus]) + + useEffect(() => { + values.destination_address && setInputValue(values.destination_address) + }, [values.destination_address]) + + const handleRemoveDepositeAddress = useCallback(async () => { + setDepositeAddressIsfromAccount(false) + setFieldValue("destination_address", '') + setInputValue("") + }, [setDepositeAddressIsfromAccount, setFieldValue, values.to]) + + const handleSelectAddress = useCallback((value: string) => { + setAddressConfirmed(true) + setFieldValue("destination_address", value) + close() + }, [close, setAddressConfirmed, setFieldValue]) + + const inputAddressIsValid = isValidAddress(inputValue, destination) + let errorMessage = ''; + if (inputValue && !isValidAddress(inputValue, destination)) { + errorMessage = `Enter a valid ${values.to?.display_name} address` + } + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value) + setAddressConfirmed(false) + }, []) + + useEffect(() => { + if (inputAddressIsValid) { + setValidInputAddress(inputValue) + } + }, [inputValue, inputAddressIsValid]) + + const handleSetNewAddress = useCallback(() => { + setAddressConfirmed(true) + setFieldValue("destination_address", validInputAddress) + close() + }, [validInputAddress]) + + const destinationAsset = destination?.assets?.find(a => a.asset === asset) + const destinationChainId = destinationAsset?.network?.chain_id + + return (<> +
+
+
+
+ + {isPartnerWallet && partner && ({partner?.display_name})} +
+
+ {isPartnerWallet && +
+ { + partnerImage && + Partner logo + } +
+ } + + { + inputValue && !disabled && + +
+ +
+
+ } +
+ {errorMessage && +
+ {errorMessage} +
+ } + {wrongNetwork && !inputValue && +
+ { + destination?.internal_name === KnownInternalNames.Networks.StarkNetMainnet + ? Please switch to Starknet Mainnet with your wallet and click Autofill again + : Please switch to Starknet Goerli with your wallet and click Autofill again + } +
+ } +
+
+ { + validInputAddress && +
+
+ { + destinationIsStarknet && connectedWallet ? + + : + + } +
+
+
+ {shortenAddress(validInputAddress)} +
+
+
+ Select +
+
+ } + { + !disabled + && !inputValue + && destination + && !destination?.isExchange + && destinationNetwork + && provider + && !connectedWallet && +
{ connectWallet(provider.name) }} className={`min-h-12 text-left cursor-pointer space-x-2 border border-secondary-500 bg-secondary-700/70 flex text-sm rounded-md items-center w-full transform transition duration-200 px-2 py-1.5 hover:border-secondary-500 hover:bg-secondary-700 hover:shadow-xl`}> +
+ +
+
+
+ Autofill from wallet +
+
+ Connect your wallet to fetch the address +
+
+
+ } + { + destination?.isExchange + && !inputAddressIsValid + && values.currency + && destinationNetwork + && +
+
+ + +
+
    +
  • Go to the Deposits page
  • +
  • + Select + + + Project Logo + {values.currency.asset} + + + as asset +
  • +
  • + Select + + + Project Logo + {destinationNetwork?.display_name} + + + as network +
  • +
+
+ } + { + !disabled && valid_addresses?.length > 0 && !inputValue && +
+ + +
+ {valid_addresses?.map((a) => ( + + classNames( + disabled ? ' cursor-not-allowed ' : ' cursor-pointer ', + 'relative flex focus:outline-none mt-2 mb-3 ' + ) + } + > + {({ checked }) => { + const difference_in_days = Math.round(Math.abs(((new Date()).getTime() - new Date(a.date).getTime()) / (1000 * 3600 * 24))) + return ( + +
+ +
+
+
+ {shortenAddress(a.address)} +
+
+ { + difference_in_days === 0 ? + <>Used today + : + (difference_in_days > 1 ? + <>Used {difference_in_days} days ago + : <>Used yesterday) + } +
+
+
+ ) + }} +
+ ))} +
+
+
+ } +
+
+
+ + ) +}); + + +export default Address \ No newline at end of file diff --git a/app/components/Input/Amount.tsx b/app/components/Input/Amount.tsx new file mode 100644 index 00000000..28208561 --- /dev/null +++ b/app/components/Input/Amount.tsx @@ -0,0 +1,46 @@ +import { useFormikContext } from "formik"; +import { forwardRef } from "react"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import NumericInput from "./NumericInput"; +import dynamic from "next/dynamic"; + +const EnhancedAmountField = dynamic(() => import("./EnhancedAmount"), { + loading: () => +
+

Amount

+
+
} + disabled={true} + placeholder='0.01234' + name="amount" + className="rounded-r-none text-primary-text" + > + +}); + +const AmountField = forwardRef(function AmountField(_, ref: any) { + + const { values } = useFormikContext(); + const { from, to } = values + const name = "amount" + + if (!from || !to) + return +
+

Amount

+
+ } + disabled={true} + placeholder='0.01234' + name={name} + className="rounded-r-none text-primary-text" + > +
+ + return +}); + + +export default AmountField \ No newline at end of file diff --git a/app/components/Input/CurrencyFormField.tsx b/app/components/Input/CurrencyFormField.tsx new file mode 100644 index 00000000..227beb4c --- /dev/null +++ b/app/components/Input/CurrencyFormField.tsx @@ -0,0 +1,102 @@ +import { useFormikContext } from "formik"; +import { FC, useCallback, useEffect, useMemo } from "react"; +import { useSettingsState } from "../../context/settings"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import { FilterCurrencies, GetNetworkCurrency } from "../../helpers/settingsHelper"; +import { Currency } from "../../Models/Currency"; +import { SelectMenuItem } from "../Select/Shared/Props/selectMenuItem"; +import PopoverSelectWrapper from "../Select/Popover/PopoverSelectWrapper"; +import CurrencySettings from "../../lib/CurrencySettings"; +import { SortingByOrder } from "../../lib/sorting"; +import { Layer } from "../../Models/Layer"; +import { useBalancesState } from "../../context/balances"; +import { truncateDecimals } from "../utils/RoundDecimals"; +import { useQueryState } from "../../context/query"; +import useWallet from "../../hooks/useWallet"; +import { Balance } from "../../Models/Balance"; + +const CurrencyFormField: FC = () => { + const { + values: { to, currency, from }, + setFieldValue, + } = useFormikContext(); + + const { resolveImgSrc, currencies } = useSettingsState(); + const name = "currency" + const query = useQueryState() + const { balances } = useBalancesState() + const { getAutofillProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return from && getProvider(from) + }, [from, getProvider]) + + const wallet = provider?.getConnectedWallet() + const lockedCurrency = query?.lockAsset ? currencies?.find(c => c?.asset?.toUpperCase() === (query?.asset as string)?.toUpperCase()) : undefined + + const filteredCurrencies = lockedCurrency ? [lockedCurrency] : FilterCurrencies(currencies, from, to) + const currencyMenuItems = from ? GenerateCurrencyMenuItems( + filteredCurrencies, + from, + resolveImgSrc, + lockedCurrency, + balances[wallet?.address || ''] + ) : [] + + useEffect(() => { + const currencyIsAvailable = currency && currencyMenuItems.some(c => c?.baseObject.asset === currency?.asset) + if (currencyIsAvailable) return + + const default_currency = currencyMenuItems.find(c => c.baseObject?.asset?.toUpperCase() === (query?.asset as string)?.toUpperCase()) || currencyMenuItems?.[0] + + if (default_currency) { + setFieldValue(name, default_currency.baseObject) + } + else if (currency) { + setFieldValue(name, null) + } + }, [from, to, currencies, currency, query]) + + const value = currencyMenuItems.find(x => x.id == currency?.asset); + const handleSelect = useCallback((item: SelectMenuItem) => { + setFieldValue(name, item.baseObject, true) + }, [name]) + + return ; +}; + +export function GenerateCurrencyMenuItems(currencies: Currency[], source: Layer, resolveImgSrc: (item: Layer | Currency) => string, lockedCurrency?: Currency, balances?: Balance[]): SelectMenuItem[] { + + let currencyIsAvailable = () => { + if (lockedCurrency) { + return { value: false, disabledReason: CurrencyDisabledReason.LockAssetIsTrue } + } + else { + return { value: true, disabledReason: null } + } + } + + return currencies.map(c => { + const sourceCurrency = GetNetworkCurrency(source, c.asset); + const displayName = lockedCurrency?.asset ?? (source?.isExchange ? sourceCurrency?.asset : sourceCurrency?.name); + const balance = balances?.find(b => b?.token === c?.asset && source.internal_name === b.network) + const formatted_balance_amount = balance ? Number(truncateDecimals(balance?.amount, c.precision)) : '' + + const res: SelectMenuItem = { + baseObject: c, + id: c.asset, + name: displayName || "-", + order: CurrencySettings.KnownSettings[c.asset]?.Order ?? 5, + imgSrc: resolveImgSrc && resolveImgSrc(c), + isAvailable: currencyIsAvailable(), + details: `${formatted_balance_amount}` + }; + return res + }).sort(SortingByOrder); +} + +export enum CurrencyDisabledReason { + LockAssetIsTrue = '', + InsufficientLiquidity = 'Temporarily disabled. Please check later.' +} + +export default CurrencyFormField \ No newline at end of file diff --git a/app/components/Input/EnhancedAmount.tsx b/app/components/Input/EnhancedAmount.tsx new file mode 100644 index 00000000..903175c6 --- /dev/null +++ b/app/components/Input/EnhancedAmount.tsx @@ -0,0 +1,136 @@ +import { useFormikContext } from "formik"; +import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { useSettingsState } from "../../context/settings"; +import { CalculateMaxAllowedAmount, CalculateMinAllowedAmount } from "../../lib/fees"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import CurrencyFormField from "./CurrencyFormField"; +import NumericInput from "./NumericInput"; +import SecondaryButton from "../buttons/secondaryButton"; +import { useQueryState } from "../../context/query"; +import { useBalancesState } from "../../context/balances"; +import { truncateDecimals } from "../utils/RoundDecimals"; +import useWallet from "../../hooks/useWallet"; +import useBalance from "../../hooks/useBalance"; + +const EnhancedAmountField = forwardRef(function EnhancedAmountField(_, ref: any) { + + const { values, setFieldValue } = useFormikContext(); + const { networks, currencies } = useSettingsState() + const query = useQueryState() + const { currency, from, to, amount, destination_address } = values + + const { balances, isBalanceLoading, gases, isGasLoading } = useBalancesState() + const { getAutofillProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return values.from && getProvider(values.from) + }, [values.from, getProvider]) + + const wallet = provider?.getConnectedWallet() + const gasAmount = gases[from?.internal_name || '']?.find(g => g?.token === currency?.asset)?.gas || 0 + const { fetchBalance, fetchGas } = useBalance() + const name = "amount" + const walletBalance = wallet && balances[wallet.address]?.find(b => b?.network === from?.internal_name && b?.token === currency?.asset) + const walletBalanceAmount = walletBalance?.amount && truncateDecimals(walletBalance?.amount, currency?.precision) + + const minAllowedAmount = CalculateMinAllowedAmount(values, networks, currencies); + const maxAllowedAmount = CalculateMaxAllowedAmount(values, query.balances as string, walletBalance?.amount, gasAmount, minAllowedAmount) + const maxAllowedDisplayAmont = truncateDecimals(maxAllowedAmount, currency?.precision) + + const placeholder = (currency && from && to && !isBalanceLoading && !isGasLoading) ? `${minAllowedAmount} - ${maxAllowedDisplayAmont}` : '0.01234' + const step = 1 / Math.pow(10, currency?.precision || 1) + const amountRef = useRef(ref) + + const handleSetMinAmount = () => { + setFieldValue(name, minAllowedAmount) + } + + const handleSetMaxAmount = useCallback(() => { + setFieldValue(name, maxAllowedAmount); + from && fetchBalance(from); + from && currency && fetchGas(from, currency, destination_address || ""); + }, [from, currency, destination_address, maxAllowedAmount]) + + useEffect(() => { + values.from && fetchBalance(values.from) + }, [values.from, values.destination_address, wallet?.address]) + + const contract_address = values.from?.isExchange == false ? values.from.assets.find(a => a.asset === values?.currency?.asset)?.contract_address : null + useEffect(() => { + wallet?.address && values.from && values.currency && fetchGas(values.from, values.currency, values.destination_address || wallet.address) + }, [contract_address, values.from, values.currency, wallet?.address]) + + return (<> + } + disabled={!currency} + placeholder={placeholder} + min={minAllowedAmount} + max={maxAllowedAmount} + step={isNaN(step) ? 0.01 : step} + name={name} + ref={amountRef} + precision={currency?.precision} + className="rounded-r-none text-primary-text" + > + { + from && to && currency ?
+ + MIN + + + MAX + +
+ : <> + } + +
+ ) +}); + + + +type AmountLabelProps = { + detailsAvailable: boolean; + minAllowedAmount: number; + maxAllowedAmount: number; + isBalanceLoading: boolean; + walletBalance?: number +} +const AmountLabel = ({ + detailsAvailable, + minAllowedAmount, + maxAllowedAmount, + isBalanceLoading, + walletBalance, +}: AmountLabelProps) => { + return
+
+

Amount

+ { + detailsAvailable && +
+ (Min: {minAllowedAmount} - Max: {isBalanceLoading ? : {maxAllowedAmount}}) +
+ } +
+ { + walletBalance != undefined && !isNaN(walletBalance) && +
+ Balance: + {isBalanceLoading ? + + : + {walletBalance}} +
+ } +
+} + +export default EnhancedAmountField \ No newline at end of file diff --git a/app/components/Input/NetworkFormField.tsx b/app/components/Input/NetworkFormField.tsx new file mode 100644 index 00000000..41655cce --- /dev/null +++ b/app/components/Input/NetworkFormField.tsx @@ -0,0 +1,153 @@ +import { useFormikContext } from "formik"; +import { forwardRef, useCallback } from "react"; +import { useSettingsState } from "../../context/settings"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import { ISelectMenuItem, SelectMenuItem } from "../Select/Shared/Props/selectMenuItem"; +import { Layer } from "../../Models/Layer"; +import CommandSelectWrapper from "../Select/Command/CommandSelectWrapper"; +import { FilterDestinationLayers, FilterSourceLayers } from "../../helpers/settingsHelper"; +import { Currency } from "../../Models/Currency"; +import ExchangeSettings from "../../lib/ExchangeSettings"; +import { SortingByOrder } from "../../lib/sorting" +import { LayerDisabledReason } from "../Select/Popover/PopoverSelect"; +import NetworkSettings from "../../lib/NetworkSettings"; +import { SelectMenuItemGroup } from "../Select/Command/commandSelect"; +import { useQueryState } from "../../context/query"; + +type SwapDirection = "from" | "to"; +type Props = { + direction: SwapDirection, + label: string, +} +const GROUP_ORDERS = { "Popular": 1, "New": 2, "Fiat": 3, "Networks": 4, "Exchanges": 5, "Other": 10 }; +const getGroupName = (layer: Layer) => { + + if (layer.is_featured) { + return "Popular"; + } + else if (new Date(layer.created_date).getTime() >= (new Date().getTime() - 2629800000)) { + return "New"; + } + else if (!layer.isExchange) { + return "Networks"; + } + else if (layer.type === 'fiat') { + return "Fiat"; + } + else if (layer.type === 'cex') { + return "Exchanges"; + } + else { + return "Other"; + } +} + +const NetworkFormField = forwardRef(function NetworkFormField({ direction, label }: Props, ref: any) { + const { + values, + setFieldValue, + } = useFormikContext(); + const name = direction + const { from, to } = values + const { lockFrom, lockTo, asset, lockAsset } = useQueryState() + const { resolveImgSrc, layers, currencies } = useSettingsState(); + + let placeholder = ""; + let searchHint = ""; + let filteredLayers: Layer[]; + let menuItems: SelectMenuItem[]; + const lockedCurrency = lockAsset ? + currencies?.find(c => c?.asset?.toUpperCase() === (asset as string)?.toUpperCase()) + : null + + let valueGrouper: (values: ISelectMenuItem[]) => SelectMenuItemGroup[]; + + if (direction === "from") { + placeholder = "Source"; + searchHint = "Swap from"; + filteredLayers = FilterSourceLayers(layers, to, lockedCurrency); + menuItems = GenerateMenuItems(filteredLayers, resolveImgSrc, direction, !!(from && lockFrom)); + } + else { + placeholder = "Destination"; + searchHint = "Swap to"; + filteredLayers = FilterDestinationLayers(layers, from, lockedCurrency); + menuItems = GenerateMenuItems(filteredLayers, resolveImgSrc, direction, !!(to && lockTo)); + } + valueGrouper = groupByType + + const value = menuItems.find(x => x.id == (direction === "from" ? from : to)?.internal_name); + const handleSelect = useCallback((item: SelectMenuItem) => { + setFieldValue(name, item.baseObject, true) + }, [name]) + + return (
+ +
+ +
+
) +}); + +function groupByType(values: ISelectMenuItem[]) { + let groups: SelectMenuItemGroup[] = []; + values.forEach((v) => { + let group = groups.find(x => x.name == v.group) || new SelectMenuItemGroup({ name: v.group, items: [] }); + group.items.push(v); + if (!groups.find(x => x.name == v.group)) { + groups.push(group); + } + }); + + groups.forEach(group => { + group.items.sort((a, b) => a.name.localeCompare(b.name)); + }); + + groups.sort((a, b) => { + // Sort put networks first then exchanges + return (GROUP_ORDERS[a.name] || GROUP_ORDERS.Other) - (GROUP_ORDERS[b.name] || GROUP_ORDERS.Other); + }); + + return groups; +} + +function GenerateMenuItems(layers: Layer[], resolveImgSrc: (item: Layer | Currency) => string, direction: SwapDirection, lock: boolean): SelectMenuItem[] { + + let layerIsAvailable = (layer: Layer) => { + if (lock) { + return { value: false, disabledReason: LayerDisabledReason.LockNetworkIsTrue } + } + else { + return { value: true, disabledReason: null } + } + } + + return layers.map(l => { + let orderProp: keyof NetworkSettings | keyof ExchangeSettings = direction == 'from' ? 'OrderInSource' : 'OrderInDestination'; + const order = (l.isExchange ? + ExchangeSettings.KnownSettings[l.internal_name]?.[orderProp] + : NetworkSettings.KnownSettings[l.internal_name]?.[orderProp]) + const res: SelectMenuItem = { + baseObject: l, + id: l.internal_name, + name: l.display_name, + order: order || 100, + imgSrc: resolveImgSrc && resolveImgSrc(l), + isAvailable: layerIsAvailable(l), + group: getGroupName(l) + } + return res; + }).sort(SortingByOrder); +} + +export default NetworkFormField \ No newline at end of file diff --git a/app/components/Input/NumericInput.tsx b/app/components/Input/NumericInput.tsx new file mode 100644 index 00000000..dc175c4f --- /dev/null +++ b/app/components/Input/NumericInput.tsx @@ -0,0 +1,93 @@ +import { useField, useFormikContext } from "formik"; +import { ChangeEvent, FC, forwardRef } from "react"; +import { SwapFormValues } from "../DTOs/SwapFormValues"; +import { classNames } from '../utils/classNames' + +type Input = { + label?: JSX.Element | JSX.Element[] + pattern?: string; + disabled?: boolean; + placeholder: string; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + precision?: number; + step?: number; + name: string; + className?: string; + children?: JSX.Element | JSX.Element[]; + ref?: any; + onChange?: (e: ChangeEvent) => void; +} + +// Use with Formik +const NumericInput: FC = forwardRef( + function NumericInput({ label, pattern, disabled, placeholder, min, max, minLength, maxLength, precision, step, name, className, children, onChange }, ref) { + const { handleChange } = useFormikContext(); + const [field] = useField(name) + + return <> + {label && + + } +
+ ) => { replaceComma(event); limitDecimalPlaces(event, precision) }} + type="text" + step={step} + name={name} + id={name} + ref={ref} + className={classNames( + 'disabled:cursor-not-allowed h-12 leading-4 placeholder:text-primary-text-placeholder bg-secondary-700 focus:ring-primary focus:border-primary flex-grow block w-full min-w-0 rounded-lg font-semibold border-0', + className + )} + onChange={onChange ? onChange : e => { + /^[0-9]*[.,]?[0-9]*$/.test(e.target.value) && handleChange(e); + }} + /> + {children && + + {children} + + } +
+ ; + }); + +function limitDecimalPlaces(e, count) { + if (e.target.value.indexOf('.') == -1) { return; } + if ((e.target.value.length - e.target.value.indexOf('.')) > count) { + e.target.value = ParseFloat(e.target.value, count); + } +} + +function ParseFloat(str, val) { + str = str.toString(); + str = str.slice(0, (str.indexOf(".")) + val + 1); + return Number(str); +} + +function replaceComma(e) { + var val = e.target.value; + if (val.match(/\,/)) { + val = val.replace(/\,/g, '.'); + e.target.value = val; + } +} + +export default NumericInput \ No newline at end of file diff --git a/app/components/LayerswapMenu/Menu.tsx b/app/components/LayerswapMenu/Menu.tsx new file mode 100644 index 00000000..65c7242f --- /dev/null +++ b/app/components/LayerswapMenu/Menu.tsx @@ -0,0 +1,80 @@ +import { ChevronRight, ExternalLink } from "lucide-react" +import LinkWrapper from "../LinkWraapper" +import { ReactNode } from "react" + +const Menu = ({ children }: { children: ReactNode }) => { + return
+ {children} +
+
+} + +const Group = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + return ( +
+
+ {children} +
+
+ ) +} + +const Item = (function Item({ children, pathname, onClick, icon, target = '_self' }: MenuIemProps) { + + return ( + pathname ? + +
+ {icon} +
+

{children}

+ { + target === '_self' ? + + : + + } +
+ : + + ) +}) + +export enum ItemType { + button = 'button', + link = 'link' +} + +type Target = '_blank' | '_self' + +type MenuIemProps = { + children: ReactNode; + pathname?: string; + onClick?: React.MouseEventHandler; + icon: JSX.Element; + target?: Target; +}; + +const Footer = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + return ( +
+ {children} +
+ ) +} + +Menu.Group = Group +Menu.Item = Item +Menu.Footer = Footer + +export default Menu \ No newline at end of file diff --git a/app/components/LayerswapMenu/index.tsx b/app/components/LayerswapMenu/index.tsx new file mode 100644 index 00000000..ebfd6769 --- /dev/null +++ b/app/components/LayerswapMenu/index.tsx @@ -0,0 +1,240 @@ +import { BookOpen, Gift, MenuIcon, Map, Home, LogIn, ScrollText, LibraryIcon, Shield, Users, MessageSquarePlus, UserCircle2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useState } from "react"; +import { useAuthDataUpdate, useAuthState, UserType } from "../../context/authContext"; +import TokenService from "../../lib/TokenService"; +import { useIntercom } from "react-use-intercom"; +import ChatIcon from "../icons/ChatIcon"; +import inIframe from "../utils/inIframe"; +import Modal from "../../components/modal/modal"; +import DiscordLogo from "./../icons/DiscordLogo"; +import GitHubLogo from "./../icons/GitHubLogo"; +import SubstackLogo from "./../icons/SubstackLogo"; +import TwitterLogo from "./../icons/TwitterLogo"; +import Link from "next/link"; +import Popover from "../modal/popover"; +import SendFeedback from "../sendFeedback"; +import IconButton from "../buttons/iconButton"; +import YoutubeLogo from "../icons/YoutubeLogo"; +import { shortenEmail } from '../utils/ShortenAddress'; +import { resolvePersistantQueryParams } from "../../helpers/querryHelper"; +import Menu from "./Menu"; +import dynamic from "next/dynamic"; + +const WalletsMenu = dynamic(() => import("../ConnectedWallets").then((comp) => comp.WalletsMenu), { + loading: () => <> +}) + +export default function BridgeMenu() { + const { email, userType, userId } = useAuthState() + const { setUserType } = useAuthDataUpdate() + const router = useRouter(); + const { boot, show, update } = useIntercom() + const [embedded, setEmbedded] = useState() + const [openTopModal, setOpenTopModal] = useState(false); + const [openFeedbackModal, setOpenFeedbackModal] = useState(false); + + useEffect(() => { + setEmbedded(inIframe()) + }, []) + + const updateWithProps = () => update({ email: email, userId: userId }) + + const handleLogout = useCallback(() => { + TokenService.removeAuthData() + if (router.pathname != '/') { + router.push({ + pathname: '/', + query: resolvePersistantQueryParams(router.query) + }) + } else { + router.reload() + } + setUserType(UserType.NotAuthenticatedUser) + }, [router.query]) + + const navigation = { + social: [ + { + name: 'Twitter', + href: 'https://twitter.com/bridge', + icon: (props) => TwitterLogo(props), + className: 'plausible-event-name=Twitter' + }, + { + name: 'GitHub', + href: 'https://github.com/bridge/bridgeapp', + icon: (props) => GitHubLogo(props), + className: 'plausible-event-name=GitHub' + }, + { + name: 'Discord', + href: 'https://discord.com/invite/KhwYN35sHy', + icon: (props) => DiscordLogo(props), + className: 'plausible-event-name=Discord' + }, + { + name: 'YouTube', + href: 'https://www.youtube.com/@bridgehq', + icon: (props) => YoutubeLogo(props), + className: 'plausible-event-name=Youtube' + }, + { + name: 'Substack', + href: 'https://bridge.substack.com/', + icon: (props) => SubstackLogo(props), + className: 'plausible-event-name=Substack' + }, + { + name: 'Roadmap', + href: 'https://bridge.ducalis.io/roadmap/summary', + icon: (props) => , + className: 'plausible-event-name=Roadmap' + }, + ] + } + + const handleCloseFeedback = () => { + setOpenFeedbackModal(false) + } + + return <> + + { + + <> +
+ + setOpenTopModal(true)} icon={ + + }> + + +
+ Menu}> +
+ + + + + + <> + { + router.pathname != '/' && + } > + Home + + } + + <> + { + router.pathname != '/transactions' && + } > + Transfers + + } + + <> + {!embedded && router.pathname != '/campaigns' && + } > + Campaigns + + } + + + + { + boot(); + show(); + updateWithProps(); + }} target="_blank" icon={} > + Help + + } > + Docs for Users + + } > + Docs for Partners + + + + + } > + Privacy Policy + + } > + Terms of Service + + + + + setOpenFeedbackModal(true)} target="_blank" icon={} > + Suggest a Feature + + } + isNested={true} + show={openFeedbackModal} + header="Suggest a Feature" + setShow={setOpenFeedbackModal} > +
+ +
+
+
+ +
+
+

Media links & suggestions:

+
+ +
+ {navigation.social.map((item, index) => ( + +
+
+ + ))} +
+ { + router.pathname != '/auth' && + + + { + userType == UserType.AuthenticatedUser ? +
+
+
+ +

{email && }

+
+ +
+
+ : + } > + Sign in + + } +
+
+ } +
+
+
+ + } +
+ +} + +const UserEmail = ({ email }: { email: string }) => { + return shortenEmail(email, 22) +} \ No newline at end of file diff --git a/app/components/LinkWraapper.tsx b/app/components/LinkWraapper.tsx new file mode 100644 index 00000000..baa951d4 --- /dev/null +++ b/app/components/LinkWraapper.tsx @@ -0,0 +1,31 @@ +import Link, { LinkProps } from "next/link"; +import { useRouter } from "next/router"; +import { FC } from "react"; +import { resolvePersistantQueryParams } from "../helpers/querryHelper"; + +const LinkWrapper: FC, keyof LinkProps> & LinkProps & { + children?: React.ReactNode; +} & React.RefAttributes> = (props) => { + const router = useRouter(); + const { children } = props + + const pathname = typeof props.href === 'object' ? props.href.pathname : props.href + const query = (typeof props.href === 'object' && typeof props.href.query === 'object' && props.href.query) || {} + + return ( + + {children} + + ) +} + +export default LinkWrapper \ No newline at end of file diff --git a/app/components/LoadingCard.tsx b/app/components/LoadingCard.tsx new file mode 100644 index 00000000..b4fa5c82 --- /dev/null +++ b/app/components/LoadingCard.tsx @@ -0,0 +1,28 @@ +import { FC, useEffect } from "react" +import { useLoadingState } from "../context/loadingContext" + +type Props = { + name: string +} +const LoadingCard: FC = ({ name }) => { + return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+} +export default LoadingCard \ No newline at end of file diff --git a/app/components/MessageComponent.tsx b/app/components/MessageComponent.tsx new file mode 100644 index 00000000..fc0fe0d6 --- /dev/null +++ b/app/components/MessageComponent.tsx @@ -0,0 +1,84 @@ +import CancelIcon from "./icons/CancelIcon"; +import DelayIcon from "./icons/DelayIcon"; +import FailIcon from "./icons/FailIcon"; +import SuccessIcon from "./icons/SuccessIcon"; +type iconStyle = 'red' | 'green' | 'yellow' | 'gray' + +class MessageComponentProps { + children: JSX.Element | JSX.Element[]; + center?: boolean + icon: iconStyle +} + +function constructIcons(icon: iconStyle) { + + let iconStyle: JSX.Element + + switch (icon) { + case 'red': + iconStyle = ; + break; + case 'green': + iconStyle = ; + break; + case 'yellow': + iconStyle = + break + case 'gray': + iconStyle = CancelIcon + break + } + return iconStyle +} + +const MessageComponent = ({ children }) => { + return
+ {children} +
+} + +const Content = ({ children, icon, center }: MessageComponentProps) => { + return ( + center ? +
+
+
+
{constructIcons(icon)}
+ {children} +
+
+
+ : +
+
{constructIcons(icon)}
+ {children} +
+ ) +} + +const Header = ({ children }) => { + return
+ {children} +
+} + +const Description = ({ children }) => { + return
+ {children} +
+} + +const Buttons = ({ children }) => { + return
+ {children} +
+} + +MessageComponent.Content = Content +MessageComponent.Header = Header +MessageComponent.Description = Description +MessageComponent.Buttons = Buttons + +export default MessageComponent + + diff --git a/app/components/NoCookies.tsx b/app/components/NoCookies.tsx new file mode 100644 index 00000000..e81e3052 --- /dev/null +++ b/app/components/NoCookies.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import MessageComponent from "./MessageComponent"; +import Navbar from "./navbar"; +import inIframe from "./utils/inIframe"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +function NoCookies(props) { + const [embedded, setEmbedded] = useState() + + useEffect(() => { + setEmbedded(inIframe()) + }, []) + + return ( +
+
+
+ + + + Sorry + + +
+
+

+ It seems like you’ve either: +

+
    +
  • Disabled cookies
  • +
  • Or using Bridge in a partner’s page in Incognito mode
  • +
+
+

Unforunately, we can’t run in those conditions 🙁

+
+ { + embedded && + + Try on Bridge + + } +
+
+
+
+
+
+ ); +} + +export default NoCookies; diff --git a/app/components/OptionToggle.tsx b/app/components/OptionToggle.tsx new file mode 100644 index 00000000..5b08b026 --- /dev/null +++ b/app/components/OptionToggle.tsx @@ -0,0 +1,73 @@ +import { RadioGroup } from "@headlessui/react"; +import { ArrowRight } from "lucide-react"; +import { FC } from "react"; +import { SwapType } from "../lib/BridgeApiClient"; +import { classNames } from "./utils/classNames"; + +export interface NavRadioOption { + value: string; + isEnabled: boolean; + isHighlighted: boolean; +} + +export interface NavRadioProps { + label: string, + value: string, + items: NavRadioOption[], + setSelected: (value: string) => void, + disabled?: boolean +} + +const OptionToggle: FC = ({ value, items, setSelected, label, disabled }) => { + + const onchange = (item: NavRadioOption) => { + setSelected(item.value) + } + + return ( + i.value === value)} disabled={disabled} onChange={onchange} className="mt-2 w-full my-4"> + {label} +
+ {items.map((option) => ( + + classNames( + option.isEnabled ? 'cursor-pointer focus:outline-none' : 'opacity-25 cursor-not-allowed', + checked + ? 'bg-secondary-500 border-transparent text-primary-text' + : 'bg-transparent border-transparent text-gray-400 hover:text-gray-200', + `border rounded-md p-1 flex items-center justify-center text-sm font-medium sm:flex-1 relative` + ) + } + disabled={!option.isEnabled}> + { + option.value === SwapType.OnRamp && +
+ On-Ramp +
+ } + { + option.value === SwapType.OffRamp && +
+ Off-Ramp +
+ } + { + option.value === SwapType.CrossChain && +
+ + Cross-Chain + + New +
+ + } +
+ ))} +
+
+ ) +} +export default OptionToggle \ No newline at end of file diff --git a/app/components/ProgressBar.tsx b/app/components/ProgressBar.tsx new file mode 100644 index 00000000..2832c5e4 --- /dev/null +++ b/app/components/ProgressBar.tsx @@ -0,0 +1,27 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" +import { classNames } from "./utils/classNames" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/app/components/QRCodeWallet.tsx b/app/components/QRCodeWallet.tsx new file mode 100644 index 00000000..eca3ece1 --- /dev/null +++ b/app/components/QRCodeWallet.tsx @@ -0,0 +1,57 @@ +import { FC, useState } from "react" +import { QRCodeSVG } from "qrcode.react"; +import { classNames } from "./utils/classNames"; +import { QrCode } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "./shadcn/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./shadcn/tooltip"; +import { motion } from "framer-motion"; + +type QRCodeModalProps = { + qrUrl: string; + className?: string + iconSize?: number + iconClassName?: string +} + +const QRCodeModal: FC = ({ qrUrl, className, iconSize, iconClassName }) => { + const qrCode = + + + return ( + <> + + + + +
+
+ +
+
+
+ +

Show QR code

+
+
+
+ + + {qrCode} + + +
+ + ) +} + + +export default QRCodeModal \ No newline at end of file diff --git a/app/components/RainbowKit.tsx b/app/components/RainbowKit.tsx new file mode 100644 index 00000000..9ab77db6 --- /dev/null +++ b/app/components/RainbowKit.tsx @@ -0,0 +1,106 @@ +import "@rainbow-me/rainbowkit/styles.css"; +import { + darkTheme, + connectorsForWallets, + RainbowKitProvider, + DisclaimerComponent, + AvatarComponent +} from '@rainbow-me/rainbowkit'; +const WALLETCONNECT_PROJECT_ID = '28168903b2d30c75e5f7f2d71902581b'; +import { publicProvider } from 'wagmi/providers/public'; +import { walletConnectWallet, rainbowWallet, metaMaskWallet, coinbaseWallet, bitgetWallet, argentWallet } from '@rainbow-me/rainbowkit/wallets'; +import { useSettingsState } from "../context/settings"; +import { Chain, WagmiConfig, configureChains, createConfig } from "wagmi"; +import { NetworkType } from "../Models/CryptoNetwork"; +import resolveChain from "../lib/resolveChain"; +import React from "react"; +import AddressIcon from "./AddressIcon"; +import NoCookies from "./NoCookies"; + +type Props = { + children: JSX.Element | JSX.Element[] +} + +function RainbowKitComponent({ children }: Props) { + const settings = useSettingsState(); + + const isChain = (c: Chain | undefined): c is Chain => c != undefined + const settingsChains = settings?.networks + .sort((a, b) => Number(a.chain_id) - Number(b.chain_id)) + .filter(net => net.type === NetworkType.EVM + && net.nodes?.some(n => n.url?.length > 0)) + .map(resolveChain).filter(isChain) || [] + + const { chains, publicClient } = configureChains( + settingsChains, + [publicProvider()] + ); + + const projectId = WALLETCONNECT_PROJECT_ID; + const connectors = connectorsForWallets([ + { + groupName: 'Popular', + wallets: [ + metaMaskWallet({ projectId, chains }), + walletConnectWallet({ projectId, chains }), + ], + }, + { + groupName: 'Wallets', + wallets: [ + coinbaseWallet({ chains, appName: 'Bridge' }), + argentWallet({ projectId, chains }), + bitgetWallet({ projectId, chains }), + rainbowWallet({ projectId, chains }) + ], + }, + ]); + + const theme = darkTheme({ + accentColor: 'rgb(var(--ls-colors-primary-500))', + accentColorForeground: 'rgb(var(--ls-colors-primary-text))', + borderRadius: 'small', + fontStack: 'system', + overlayBlur: 'small', + }) + + theme.colors.modalBackground = 'rgb(var(--ls-colors-secondary-900))' + theme.colors.modalText = 'rgb(var(--ls-colors-primary-text))' + theme.colors.modalTextSecondary = 'rgb(var(--ls-colors-secondary-text))' + theme.colors.actionButtonBorder = 'rgb(var(--ls-colors-secondary-500))' + theme.colors.actionButtonBorderMobile = 'rgb(var(--ls-colors-secondary-500))' + theme.colors.closeButton = 'rgb(var(--ls-colors-secondary-text))' + theme.colors.closeButtonBackground = 'rgb(var(--ls-colors-secondary-500))' + theme.colors.generalBorder = 'rgb(var(--ls-colors-secondary-500))' + + const wagmiConfig = createConfig({ + autoConnect: true, + connectors, + publicClient, + }) + + const disclaimer: DisclaimerComponent = ({ Text }) => ( + + Thanks for choosing Bridge! + + ); + + const CustomAvatar: AvatarComponent = ({ address, size }) => { + return + }; + + return ( + + + {children} + + + ) +} + +export default RainbowKitComponent diff --git a/app/components/ReserveGasNote.tsx b/app/components/ReserveGasNote.tsx new file mode 100644 index 00000000..4fc4877f --- /dev/null +++ b/app/components/ReserveGasNote.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react" +import { useBalancesState } from "../context/balances" +import useWallet from "../hooks/useWallet" +import WarningMessage from "./WarningMessage" +import { useFormikContext } from "formik" +import { SwapFormValues } from "./DTOs/SwapFormValues" +import { truncateDecimals } from "./utils/RoundDecimals" +import { CalculateMinAllowedAmount } from "../lib/fees" +import { useSettingsState } from "../context/settings" +import { Balance, Gas } from "../Models/Balance" + + +const ReserveGasNote = ({ onSubmit }: { onSubmit: (walletBalance: Balance, networkGas: Gas) => void }) => { + const { + values, + } = useFormikContext(); + const { balances, gases } = useBalancesState() + const settings = useSettingsState() + const { getWithdrawalProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return values.from && getProvider(values.from) + }, [values.from, getProvider]) + + const wallet = provider?.getConnectedWallet() + const minAllowedAmount = CalculateMinAllowedAmount(values, settings.networks, settings.currencies); + + const walletBalance = wallet && balances[wallet.address]?.find(b => b?.network === values?.from?.internal_name && b?.token === values?.currency?.asset) + const networkGas = values.from?.internal_name ? + gases?.[values.from?.internal_name]?.find(g => g?.token === values?.currency?.asset) + : null + + const mightBeAutOfGas = !!(networkGas && walletBalance?.isNativeCurrency && Number(values.amount) + + networkGas?.gas > walletBalance.amount + && walletBalance.amount > minAllowedAmount + ) + const gasToReserveFormatted = mightBeAutOfGas ? truncateDecimals(networkGas?.gas, values?.currency?.precision) : 0 + + return ( + mightBeAutOfGas && gasToReserveFormatted > 0 && + +
+
+ You might not be able to complete the transaction. +
+
onSubmit(walletBalance, networkGas)} className="cursor-pointer border-b border-dotted border-primary-text w-fit hover:text-primary hover:border-primary text-primary-text"> + Reserve {gasToReserveFormatted} {values?.currency?.asset} for gas. +
+
+
+ ) +} + +export default ReserveGasNote \ No newline at end of file diff --git a/app/components/ResizablePanel.tsx b/app/components/ResizablePanel.tsx new file mode 100644 index 00000000..db5d9b96 --- /dev/null +++ b/app/components/ResizablePanel.tsx @@ -0,0 +1,48 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ReactNode } from "react"; +import { useMeasure } from "@uidotdev/usehooks"; + +export default function ResizablePanel({ children, className }: { children: ReactNode, className?: string }) { + let [ref, { height }] = useMeasure(); + + return ( + + + +
+ {children} +
+
+
+
+ ); +} + +const ignoreCircularReferences = () => { + const seen = new WeakSet(); + return (key, value) => { + if (key.startsWith("_")) return; // Don't compare React's internal props. + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return; + seen.add(value); + } + return value; + }; +}; \ No newline at end of file diff --git a/app/components/Sceletons.tsx b/app/components/Sceletons.tsx new file mode 100644 index 00000000..b754ccc5 --- /dev/null +++ b/app/components/Sceletons.tsx @@ -0,0 +1,329 @@ +import { ChevronRight, Clock } from "lucide-react" +import BackgroundField from "./backgroundField" +import { classNames } from "./utils/classNames" + +export const SwapHistoryComponentSceleton = () => { + + return
+
+
+ + + + + + + + + + + + + + + + {[...Array(5)]?.map((item, index) => ( + + + + + + + + + + + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ {index !== 0 ?
: null} +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +} + +export const SwapDetailsComponentSceleton = () => { + return
+
+
+
+
+
+
+ {[...Array(8)]?.map((item, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+} + +export const DocInFrameSceleton = () => { + return
+
+
+
+
+ {[...Array(8)]?.map((item, index) => +
+
+
+
+
+
+
+ )} +
+
+
+
+} + +export const ExchangesComponentSceleton = () => { + + return <> + {[...Array(12)]?.map((item, index) => +
+
+
+
+
+
+
+
+ +
+
+
+ )} + + +} + +export const RewardsComponentSceleton = () => { + return ( +
+
+
+
+
+
+ Pending EarningsNext Airdrop} withoutBorder> +
+
+
+
+
+ +
+
+
+ + Total EarningsCurrent Value} withoutBorder> +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ {[...Array(4)]?.map((user, index) => ( +
+
+
+ )) + } +
+
+
+
+
+ ) +} + +export const RewardsComponentLeaderboardSceleton = () => { + return ( +
+
+
+
+
+
+
+ {[...Array(4)]?.map((user, index) => ( +
+
+
+ )) + } +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/Select/Command/CommandSelectWrapper.tsx b/app/components/Select/Command/CommandSelectWrapper.tsx new file mode 100644 index 00000000..dd2b4a98 --- /dev/null +++ b/app/components/Select/Command/CommandSelectWrapper.tsx @@ -0,0 +1,91 @@ +import { useCallback, useState } from 'react' +import Image from 'next/image' +import {ChevronDown } from 'lucide-react' +import { ISelectMenuItem, SelectMenuItem } from '../Shared/Props/selectMenuItem' +import CommandSelect, { SelectMenuItemGroup } from './commandSelect' + +type CommandSelectWrapperProps = { + setValue: (value: ISelectMenuItem) => void; + values: ISelectMenuItem[]; + value?: ISelectMenuItem; + placeholder: string; + searchHint: string; + disabled: boolean; + valueGrouper: (values: ISelectMenuItem[]) => SelectMenuItemGroup[]; +} + +export default function CommandSelectWrapper({ + setValue, + value, + disabled, + placeholder, + searchHint, + values, + valueGrouper +}: CommandSelectWrapperProps) { + const [showModal, setShowModal] = useState(false) + + function openModal() { + setShowModal(true) + } + + const handleSelect = useCallback((item: SelectMenuItem) => { + setValue(item) + setShowModal(false) + }, []) + + return ( + <> +
+ +
+ + + ) +} diff --git a/app/components/Select/Command/commandSelect.tsx b/app/components/Select/Command/commandSelect.tsx new file mode 100644 index 00000000..04577e08 --- /dev/null +++ b/app/components/Select/Command/commandSelect.tsx @@ -0,0 +1,69 @@ +import { ISelectMenuItem } from '../Shared/Props/selectMenuItem' +import { + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandWrapper +} from '../../shadcn/command' +import React from "react"; +import useWindowDimensions from '../../../hooks/useWindowDimensions'; +import SelectItem from '../Shared/SelectItem'; +import { SelectProps } from '../Shared/Props/SelectProps' +import Modal from '../../modal/modal'; +import { Info } from 'lucide-react'; +import { LayerDisabledReason } from '../Popover/PopoverSelect'; + +export interface CommandSelectProps extends SelectProps { + show: boolean; + setShow: (value: boolean) => void; + searchHint: string; + valueGrouper: (values: ISelectMenuItem[]) => SelectMenuItemGroup[]; +} + +export class SelectMenuItemGroup { + constructor(init?: Partial) { + Object.assign(this, init); + } + + name: string; + items: ISelectMenuItem[]; +} + +export default function CommandSelect({ values, setValue, show, setShow, searchHint, valueGrouper }: CommandSelectProps) { + const { isDesktop } = useWindowDimensions(); + let groups: SelectMenuItemGroup[] = valueGrouper(values); + return ( + + {show ? + + + { + values.some(v => v.isAvailable.value === false && v.isAvailable.disabledReason === LayerDisabledReason.LockNetworkIsTrue) && +
+  You're accessing Bridge from a partner's page. In case you want to transact with other networks, please open bridge.lux.network in a separate tab. +
+ } + + No results found. + {groups.filter(g => g.items?.length > 0).map((group) => { + return ( + + {group.items.map(item => + { + setValue(item) + setShow(false) + }}> + + ) + } + ) + })} + +
+ : <> + } +
+ ) +} \ No newline at end of file diff --git a/app/components/Select/Popover/PopoverSelect.tsx b/app/components/Select/Popover/PopoverSelect.tsx new file mode 100644 index 00000000..b76ff0d3 --- /dev/null +++ b/app/components/Select/Popover/PopoverSelect.tsx @@ -0,0 +1,25 @@ +import { SelectProps } from '../Shared/Props/SelectProps' +import { CommandItem, CommandList, CommandWrapper } from '../../shadcn/command'; +import SelectItem from '../Shared/SelectItem'; + +export default function PopoverSelect({ values, value, setValue }: SelectProps) { + + return ( + + + {values.map(item => + { + setValue(item) + }}> + + ) + } + + + ) +} + +export enum LayerDisabledReason { + LockNetworkIsTrue = '', + InsufficientLiquidity = 'Temporarily disabled. Please check later.' +} \ No newline at end of file diff --git a/app/components/Select/Popover/PopoverSelectWrapper.tsx b/app/components/Select/Popover/PopoverSelectWrapper.tsx new file mode 100644 index 00000000..981b34a3 --- /dev/null +++ b/app/components/Select/Popover/PopoverSelectWrapper.tsx @@ -0,0 +1,88 @@ +import { useCallback, useState } from 'react' +import Image from 'next/image' +import { ChevronDown } from 'lucide-react' +import { ISelectMenuItem, SelectMenuItem } from '../Shared/Props/selectMenuItem' +import { Popover, PopoverContent, PopoverTrigger } from '../../shadcn/popover' +import PopoverSelect from './PopoverSelect' + +type PopoverSelectWrapper = { + setValue: (value: ISelectMenuItem) => void; + values: ISelectMenuItem[]; + value?: ISelectMenuItem; + placeholder?: string; + searchHint?: string; + disabled?: boolean +} + +export default function PopoverSelectWrapper({ + setValue, + value, + values, + disabled +}: PopoverSelectWrapper) { + const [showModal, setShowModal] = useState(false) + + const handleSelect = useCallback((item: SelectMenuItem) => { + setValue(item) + setShowModal(false) + }, []) + + return ( + disabled ? +
+
+ +
+ { + value?.imgSrc && Project Logo + } + +
+ {value?.name} +
+
+
+ : + setShowModal(!showModal)}> + + { + value && +
+ +
+ } +
+ + + +
+ ) +} diff --git a/app/components/Select/Shared/Props/SelectProps.tsx b/app/components/Select/Shared/Props/SelectProps.tsx new file mode 100644 index 00000000..8a96712b --- /dev/null +++ b/app/components/Select/Shared/Props/SelectProps.tsx @@ -0,0 +1,7 @@ +import { ISelectMenuItem } from '../../Shared/Props/selectMenuItem' + +export interface SelectProps { + values: ISelectMenuItem[], + value?: ISelectMenuItem; + setValue: (value: ISelectMenuItem) => void; +} \ No newline at end of file diff --git a/app/components/Select/Shared/Props/selectMenuItem.tsx b/app/components/Select/Shared/Props/selectMenuItem.tsx new file mode 100644 index 00000000..df08d657 --- /dev/null +++ b/app/components/Select/Shared/Props/selectMenuItem.tsx @@ -0,0 +1,41 @@ +import { CurrencyDisabledReason } from "../../../Input/CurrencyFormField"; +import { LayerDisabledReason } from "../../Popover/PopoverSelect"; + +export class SelectMenuItem implements ISelectMenuItem { + id: string; + name: string; + order: number; + imgSrc: string; + isAvailable: { + value: boolean; + disabledReason: LayerDisabledReason | CurrencyDisabledReason | null + }; + group?: string; + details?: string; + baseObject: T; + constructor(baseObject: T, id: string, name: string, order: number, imgSrc: string, group?: string, details?: string) { + this.baseObject = baseObject; + this.id = id; + this.name = name; + this.order = order; + this.imgSrc = imgSrc; + this.group = group; + this.details = details + this.isAvailable = { + value: true, + disabledReason: null + } + } +} + +export interface ISelectMenuItem { + id: string; + name: string; + imgSrc: string; + group?: string; + isAvailable: { + value: boolean; + disabledReason: LayerDisabledReason | CurrencyDisabledReason | null + }; + details?: string; +} \ No newline at end of file diff --git a/app/components/Select/Shared/SelectItem.tsx b/app/components/Select/Shared/SelectItem.tsx new file mode 100644 index 00000000..8ebb9e9f --- /dev/null +++ b/app/components/Select/Shared/SelectItem.tsx @@ -0,0 +1,27 @@ +import { ISelectMenuItem } from "./Props/selectMenuItem"; +import Image from 'next/image' + +export default function SelectItem({ item }: { item: ISelectMenuItem }) { + return (
+
+ {item.imgSrc && Project Logo} +
+
+

+ {item.name} +

+ { + item.details && +

+ {item.details} +

+ } +
+
); +} \ No newline at end of file diff --git a/app/components/SendEmail.tsx b/app/components/SendEmail.tsx new file mode 100644 index 00000000..10791734 --- /dev/null +++ b/app/components/SendEmail.tsx @@ -0,0 +1,208 @@ +import { Disclosure } from '@headlessui/react'; +import { Album, ChevronDown, Mail, ScrollText, User } from 'lucide-react'; +import { Field, Form, Formik, FormikErrors } from 'formik'; +import { FC, useCallback } from 'react' +import toast from 'react-hot-toast'; +import { useAuthDataUpdate, useAuthState } from '../context/authContext'; +import { useTimerState } from '../context/timerContext'; +import TokenService from '../lib/TokenService'; +import BridgeAuthApiClient from '../lib/userAuthApiClient'; +import SubmitButton from './buttons/submitButton'; +import { Widget } from './Widget/Index'; + +type EmailFormValues = { + email: string; +} + +type Props = { + onSend: (email: string) => void; + disclosureLogin?: boolean; +} + +const SendEmail: FC = ({ onSend, disclosureLogin }) => { + const { codeRequested, tempEmail, userType } = useAuthState() + const { setCodeRequested, updateTempEmail } = useAuthDataUpdate(); + const initialValues: EmailFormValues = { email: tempEmail ?? "" }; + const { start: startTimer } = useTimerState() + + const sendEmail = useCallback(async (values: EmailFormValues) => { + try { + const inputEmail = values.email; + + if (inputEmail != tempEmail || !codeRequested) { + + const apiClient = new BridgeAuthApiClient(); + const res = await apiClient.getCodeAsync(inputEmail) + if (res.error) + throw new Error(res.error) + TokenService.setCodeNextTime(res?.data?.next) + setCodeRequested(true); + updateTempEmail(inputEmail) + const next = new Date(res?.data?.next) + const now = new Date() + const miliseconds = next.getTime() - now.getTime() + startTimer(Math.round((res?.data?.already_sent ? 60000 : miliseconds) / 1000)) + } + onSend(inputEmail) + } + catch (error) { + if (error.response?.data?.errors?.length > 0) { + const message = error.response.data.errors.map(e => e.message).join(", ") + toast.error(message) + } + else { + toast.error(error.message) + } + } + }, [tempEmail]) + + function validateEmail(values: EmailFormValues) { + let error: FormikErrors = {}; + if (!values.email) { + error.email = 'Required'; + } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) { + error.email = 'Invalid email address'; + } + return error; + } + + return ( + <> + + {({ isValid, isSubmitting }) => ( +
+ { + disclosureLogin ? +
+ + {({ open }) => ( + <> + +
+

+ Sign in with email +

+
+ +
+
+

+ Securely store your exchange accounts, access your full transfer history and more. +

+
+ +
+
+ + {({ field }) => ( + + )} + +
+
+ + Continue + +
+
+
+ + )} +
+
+ : + + + +
+

+ What's your email? +

+
+
+ + {({ field }) => ( + + )} + +
+
+

+ By signing in you get +

+
    +
  • +
    + +
    +
    +

    History

    +

    + Access your entire transaction history +

    +
    +
  • +
  • +
    + +
    +
    +

    Email updates

    +

    + Get a notification upon transfer completion +

    +
    +
  • +
  • +
    + +
    +
    +

    Dedicated deposit address

    +

    + Get deposit addresses that stay the same and can be whitelisted in CEXes +

    +
    +
  • +
+
+
+ + + Continue + + +
+ } +
+ )} +
+ + ) +} + +export default SendEmail; \ No newline at end of file diff --git a/app/components/SolanaProvider.tsx b/app/components/SolanaProvider.tsx new file mode 100644 index 00000000..70e45cf8 --- /dev/null +++ b/app/components/SolanaProvider.tsx @@ -0,0 +1,48 @@ +import { clusterApiUrl } from "@solana/web3.js"; +import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; +import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; +import { CoinbaseWalletAdapter } from "@solana/wallet-adapter-coinbase"; +import { WalletConnectWalletAdapter } from "@solana/wallet-adapter-walletconnect"; +import { SolflareWalletAdapter } from "@solana/wallet-adapter-solflare"; +import { GlowWalletAdapter } from "@solana/wallet-adapter-glow"; + +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; +import { ReactNode, useMemo } from "react"; +require("@solana/wallet-adapter-react-ui/styles.css"); +const WALLETCONNECT_PROJECT_ID = '28168903b2d30c75e5f7f2d71902581b'; + +function SolanaProvider({ children }: { children: ReactNode }) { + const solNetwork = WalletAdapterNetwork.Mainnet; + const endpoint = useMemo(() => clusterApiUrl(solNetwork), [solNetwork]); + const wallets = useMemo( + () => [ + new PhantomWalletAdapter(), + new CoinbaseWalletAdapter(), + new SolflareWalletAdapter(), + new GlowWalletAdapter(), + new WalletConnectWalletAdapter({ + network: solNetwork, + options: { + projectId: WALLETCONNECT_PROJECT_ID, + }, + }) + ], + [solNetwork] + ); + + return ( + + + + {children} + + + + ); +} + +export default SolanaProvider; \ No newline at end of file diff --git a/app/components/Swap/Form/Form.tsx b/app/components/Swap/Form/Form.tsx new file mode 100644 index 00000000..dc59a5e0 --- /dev/null +++ b/app/components/Swap/Form/Form.tsx @@ -0,0 +1,351 @@ +import { Form, FormikErrors, useFormikContext } from "formik"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import Image from 'next/image'; +import SwapButton from "../../buttons/swapButton"; +import React from "react"; +import NetworkFormField from "../../Input/NetworkFormField"; +import BridgeApiClient, { AddressBookItem } from "../../../lib/BridgeApiClient"; +import { SwapFormValues } from "../../DTOs/SwapFormValues"; +import { Partner } from "../../../Models/Partner"; +import Modal from "../../modal/modal"; +import { useSwapDataState, useSwapDataUpdate } from "../../../context/swap"; +import { useSettingsState } from "../../../context/settings"; +import { isValidAddress } from "../../../lib/addressValidator"; +import { CalculateMinAllowedAmount } from "../../../lib/fees"; +import shortenAddress from "../../utils/ShortenAddress"; +import useSWR from "swr"; +import { ApiResponse } from "../../../Models/ApiResponse"; +import { motion, useCycle } from "framer-motion"; +import ClickTooltip from "../../Tooltips/ClickTooltip"; +import ToggleButton from "../../buttons/toggleButton"; +import { ArrowUpDown, Fuel } from 'lucide-react' +import { useAuthState } from "../../../context/authContext"; +import WarningMessage from "../../WarningMessage"; +import { FilterDestinationLayers, FilterSourceLayers, GetDefaultNetwork, GetNetworkCurrency } from "../../../helpers/settingsHelper"; +import KnownInternalNames from "../../../lib/knownIds"; +import { Widget } from "../../Widget/Index"; +import { classNames } from "../../utils/classNames"; +import GasDetails from "../../gasDetails"; +import { useQueryState } from "../../../context/query"; +import FeeDetails from "../../DisclosureComponents/FeeDetails"; +import AmountField from "../../Input/Amount" +import { Balance, Gas } from "../../../Models/Balance"; +import dynamic from "next/dynamic"; + +type Props = { + isPartnerWallet?: boolean, + partner?: Partner, +} + +const ReserveGasNote = dynamic(() => import("../../ReserveGasNote"), { + loading: () => <>, +}); + +const Address = dynamic(() => import("../../Input/Address"), { + loading: () => <>, +}); + + +const SwapForm: FC = ({ partner, isPartnerWallet }) => { + const { + values, + setValues, + errors, isValid, isSubmitting, setFieldValue + } = useFormikContext(); + + const { to: destination } = values + const settings = useSettingsState(); + const source = values.from + const asset = values.currency?.asset + const { authData } = useAuthState() + + const bridgeApiClient = new BridgeApiClient() + const address_book_endpoint = authData?.access_token ? `/address_book/recent` : null + const { data: address_book } = useSWR>(address_book_endpoint, bridgeApiClient.fetcher, { dedupingInterval: 60000 }) + + const minAllowedAmount = CalculateMinAllowedAmount(values, settings.networks, settings.currencies); + const partnerImage = partner?.logo_url + const { setDepositeAddressIsfromAccount, setAddressConfirmed } = useSwapDataUpdate() + const { depositeAddressIsfromAccount } = useSwapDataState() + const query = useQueryState(); + const [valuesSwapperDisabled, setValuesSwapperDisabled] = useState(false) + const [showAddressModal, setShowAddressModal] = useState(false); + const lockAddress = + (values.destination_address && values.to) + && isValidAddress(values.destination_address, values.to) + && (((query.lockAddress || query.hideAddress) && (query.appName !== "imxMarketplace" || settings.validSignatureisPresent))); + + const actionDisplayName = query?.actionButtonText || "Swap now" + + const handleConfirmToggleChange = (value: boolean) => { + setFieldValue('refuel', value) + } + const depositeAddressIsfromAccountRef = useRef(depositeAddressIsfromAccount); + + useEffect(() => { + depositeAddressIsfromAccountRef.current = depositeAddressIsfromAccount + return () => { (depositeAddressIsfromAccountRef.current = null); return } + }, [depositeAddressIsfromAccount]) + + useEffect(() => { + if (!destination?.isExchange && (!source || !asset || !GetNetworkCurrency(source, asset)?.is_refuel_enabled)) { + handleConfirmToggleChange(false) + } + }, [asset, destination, source]) + + useEffect(() => { + setAddressConfirmed(false) + }, [source]) + + useEffect(() => { + (async () => { + (await import("../../Input/Address")).default + })() + }, [destination]) + + useEffect(() => { + if (!destination?.isExchange && values.refuel && values.amount && Number(values.amount) < minAllowedAmount) { + setFieldValue('amount', minAllowedAmount) + } + }, [values.refuel, destination]) + + const previouslySelectedDestination = useRef(destination); + + //If destination changed to exchange, remove destination_address + useEffect(() => { + if ((previouslySelectedDestination.current && destination?.isExchange != previouslySelectedDestination.current?.isExchange + || (destination?.isExchange && previouslySelectedDestination.current?.isExchange && destination?.internal_name != previouslySelectedDestination.current?.internal_name) + || destination && !isValidAddress(values.destination_address, destination)) && !lockAddress) { + setFieldValue("destination_address", '') + setDepositeAddressIsfromAccount(false) + } + previouslySelectedDestination.current = destination + }, [destination]) + + useEffect(() => { + if (!destination?.isExchange && values.refuel && Number(values.amount) < minAllowedAmount) { + setFieldValue('amount', minAllowedAmount) + } + }, [values.refuel, destination]) + + const valuesSwapper = useCallback(() => { + setValues({ ...values, from: values.to, to: values.from }, true) + }, [values]) + + const [animate, cycle] = useCycle( + { rotate: 0 }, + { rotate: 180 } + ); + + const lockedCurrency = query?.lockAsset ? settings.currencies?.find(c => c?.asset?.toUpperCase() === asset?.toUpperCase()) : null + + useEffect(() => { + + const filteredSourceLayers = FilterSourceLayers(settings.layers, source, lockedCurrency); + const filteredDestinationLayers = FilterDestinationLayers(settings.layers, destination, lockedCurrency); + + const sourceCanBeSwapped = filteredDestinationLayers.some(l => l.internal_name === source?.internal_name) + const destinationCanBeSwapped = filteredSourceLayers.some(l => l.internal_name === destination?.internal_name) + + if (query.lockTo || query.lockFrom || query.hideTo || query.hideFrom) { + setValuesSwapperDisabled(true) + return; + } + if (!(sourceCanBeSwapped || destinationCanBeSwapped)) { + setValuesSwapperDisabled(true) + return; + } + setValuesSwapperDisabled(false) + + }, [source, destination, query, settings, lockedCurrency]) + + const destinationNetwork = GetDefaultNetwork(destination, values?.currency?.asset) + const destination_native_currency = !destination?.isExchange && destinationNetwork?.native_currency + + const averageTimeString = (values?.to?.isExchange === true ? + values?.to?.assets?.find(a => a?.asset === values?.currency?.asset && a?.is_default)?.network?.average_completion_time + : values?.to?.average_completion_time) + || '' + const parts = averageTimeString?.split(":"); + const averageTimeInMinutes = parts && parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10) + parseInt(parts[2]) / 60 + + const hideAddress = query?.hideAddress + && query?.to + && query?.destAddress + && (query?.lockTo || query?.hideTo) + && isValidAddress(query?.destAddress as string, destination) + + const handleReserveGas = useCallback((walletBalance: Balance, networkGas: Gas) => { + if (walletBalance && networkGas) + setFieldValue('amount', walletBalance?.amount - networkGas?.gas) + }, [values.amount]) + + return <> + +
+ +
+ {!(query?.hideFrom && values?.from) &&
+ +
} + {!query?.hideFrom && !query?.hideTo && + } + {!(query?.hideTo && values?.to) &&
+ +
} +
+
+ +
+ { + !hideAddress ? +
+ + setShowAddressModal(true)} + partnerImage={partnerImage} + values={values} /> + +
setShowAddressModal(false)} + disabled={lockAddress || (!values.to || !values.from)} + name={"destination_address"} + partnerImage={partnerImage} + isPartnerWallet={!!isPartnerWallet} + partner={partner} + address_book={address_book?.data} + /> + +
+ : <> + } +
+ { + destination && asset && !destination.isExchange && GetNetworkCurrency(destination, asset)?.is_refuel_enabled && !query?.hideRefuel && +
+
+ +
+

+ Need gas? + +

+

+ Get {destination_native_currency} to pay fees in {values.to?.display_name} +

+
+
+ +
+ } + + { + //TODO refactor + destination && asset && GetNetworkCurrency(destination, asset)?.status == 'insufficient_liquidity' && + + We're experiencing delays for transfers of {values?.currency?.asset} to {values?.to?.display_name}. Estimated arrival time can take up to 2 hours. + + } + { + destination && asset && GetNetworkCurrency(destination, asset)?.status !== 'insufficient_liquidity' && destination?.internal_name === KnownInternalNames.Networks.StarkNetMainnet && averageTimeInMinutes > 30 && + + {destination?.display_name} network congestion. Transactions can take up to 1 hour. + + } + { + values.amount && + handleReserveGas(walletBalance, networkGas)} /> + } +
+
+ + + {ActionText(errors, actionDisplayName as string)} + + +
+
+ { + process.env.NEXT_PUBLIC_SHOW_GAS_DETAILS === 'true' + && values.from + && values.currency && + + } + +} + +function ActionText(errors: FormikErrors, actionDisplayName: string): string { + return errors.from?.toString() + || errors.to?.toString() + || errors.amount + || errors.destination_address + || (actionDisplayName) +} + +const TruncatedAdrress = ({ address }: { address: string }) => { + const shortAddress = shortenAddress(address) + return
{shortAddress}
+} + +type AddressButtonProps = { + openAddressModal: () => void; + isPartnerWallet: boolean; + values: SwapFormValues; + partnerImage?: string; + disabled: boolean; +} +const AddressButton: FC = ({ openAddressModal, isPartnerWallet, values, partnerImage, disabled }) => { + const destination = values?.to + return +} + + + + +export default SwapForm diff --git a/app/components/Swap/Form/index.tsx b/app/components/Swap/Form/index.tsx new file mode 100644 index 00000000..1e6d65cf --- /dev/null +++ b/app/components/Swap/Form/index.tsx @@ -0,0 +1,250 @@ +import { Formik, FormikProps } from "formik"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSettingsState } from "../../../context/settings"; +import { SwapFormValues } from "../../DTOs/SwapFormValues"; +import { useSwapDataState, useSwapDataUpdate } from "../../../context/swap"; +import React from "react"; +import ConnectNetwork from "../../ConnectNetwork"; +import toast from "react-hot-toast"; +import MainStepValidation from "../../../lib/mainStepValidator"; +import { generateSwapInitialValues, generateSwapInitialValuesFromSwap } from "../../../lib/generateSwapInitialValues"; +import BridgeApiClient from "../../../lib/BridgeApiClient"; +import Modal from "../../modal/modal"; +import SwapForm from "./Form"; +import { useRouter } from "next/router"; +import useSWR from "swr"; +import { ApiResponse } from "../../../Models/ApiResponse"; +import { Partner } from "../../../Models/Partner"; +import { UserType, useAuthDataUpdate } from "../../../context/authContext"; +import { ApiError, LSAPIKnownErrorCode } from "../../../Models/ApiError"; +import { resolvePersistantQueryParams } from "../../../helpers/querryHelper"; +import { useQueryState } from "../../../context/query"; +import TokenService from "../../../lib/TokenService"; +import BridgeAuthApiClient from "../../../lib/userAuthApiClient"; +import StatusIcon from "../../SwapHistory/StatusIcons"; +import Image from 'next/image'; +import { ChevronRight } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import dynamic from "next/dynamic"; +import ResizablePanel from "../../ResizablePanel"; + +type NetworkToConnect = { + DisplayName: string; + AppURL: string; +} +const SwapDetails = dynamic(() => import(".."), { + loading: () =>
+
+
+
+
+
+
+
+
+}) + +export default function Form() { + const formikRef = useRef>(null); + const [showConnectNetworkModal, setShowConnectNetworkModal] = useState(false); + const [showSwapModal, setShowSwapModal] = useState(false); + const [networkToConnect, setNetworkToConnect] = useState(); + const router = useRouter(); + const { updateAuthData, setUserType } = useAuthDataUpdate() + + const settings = useSettingsState(); + const query = useQueryState() + const { createSwap, setSwapId } = useSwapDataUpdate() + + const bridgeApiClient = new BridgeApiClient() + const { data: partnerData } = useSWR>(query?.appName && `/apps?name=${query?.appName}`, bridgeApiClient.fetcher) + const partner = query?.appName && partnerData?.data?.name?.toLowerCase() === (query?.appName as string)?.toLowerCase() ? partnerData?.data : undefined + + const { swap } = useSwapDataState() + + useEffect(() => { + if (swap) { + const initialValues = generateSwapInitialValuesFromSwap(swap, settings) + formikRef?.current?.resetForm({ values: initialValues }) + formikRef?.current?.validateForm(initialValues) + } + }, [swap]) + + const handleSubmit = useCallback(async (values: SwapFormValues) => { + try { + const accessToken = TokenService.getAuthData()?.access_token + if (!accessToken) { + try { + var apiClient = new BridgeAuthApiClient(); + const res = await apiClient.guestConnectAsync() + updateAuthData(res) + setUserType(UserType.GuestUser) + } + catch (error) { + toast.error(error.response?.data?.error || error.message) + return; + } + } + const swapId = await createSwap(values, query, partner); + + if (swapId) { + setSwapId(swapId) + var swapURL = window.location.protocol + "//" + + window.location.host + `/swap/${swapId}`; + const params = resolvePersistantQueryParams(router.query) + if (params && Object.keys(params).length) { + const search = new URLSearchParams(params as any); + if (search) + swapURL += `?${search}` + } + window.history.replaceState({ ...window.history.state, as: swapURL, url: swapURL }, '', swapURL); + setShowSwapModal(true) + } + } + catch (error) { + const data: ApiError = error?.response?.data?.error + if (data?.code === LSAPIKnownErrorCode.BLACKLISTED_ADDRESS) { + toast.error("You can't transfer to that address. Please double check.") + } + else if (data?.code === LSAPIKnownErrorCode.INVALID_ADDRESS_ERROR) { + toast.error(`Enter a valid ${values.to?.display_name} address`) + } + else if (data?.code === LSAPIKnownErrorCode.UNACTIVATED_ADDRESS_ERROR && values.to) { + setNetworkToConnect({ + DisplayName: values.to?.display_name, + AppURL: data.message + }) + setShowConnectNetworkModal(true); + } + else { + toast.error(error.message) + } + } + }, [createSwap, query, partner, router, updateAuthData, setUserType, swap]) + + const destAddress: string = query?.destAddress as string; + + const isPartnerAddress = partner && destAddress; + + const isPartnerWallet = isPartnerAddress && partner?.is_wallet; + + const initialValues: SwapFormValues = swap ? generateSwapInitialValuesFromSwap(swap, settings) + : generateSwapInitialValues(settings, query) + const initiallyValidation = MainStepValidation({ settings, query })(initialValues) + const initiallyInValid = Object.values(initiallyValidation)?.filter(v => v).length > 0 + + return <> +
+ + { + swap && + !showSwapModal && + setShowSwapModal(true)} /> + } + +
+ + {networkToConnect && } + + + + + + + + + + +} +const textMotion = { + rest: { + color: "grey", + x: 0, + transition: { + duration: 0.4, + type: "tween", + ease: "easeIn" + } + }, + hover: { + color: "blue", + x: 30, + transition: { + duration: 0.4, + type: "tween", + ease: "easeOut" + } + } +}; + +const PendingSwap = ({ onClick }: { onClick: () => void }) => { + const { swap } = useSwapDataState() + const { source_exchange: source_exchange_internal_name, + destination_network: destination_network_internal_name, + source_network: source_network_internal_name, + destination_exchange: destination_exchange_internal_name, + } = swap || {} + + const settings = useSettingsState() + + if (!swap) + return <> + + const { exchanges, networks, resolveImgSrc } = settings + const source = source_exchange_internal_name ? exchanges.find(e => e.internal_name === source_exchange_internal_name) : networks.find(e => e.internal_name === source_network_internal_name) + const destination_exchange = destination_exchange_internal_name && exchanges.find(e => e.internal_name === destination_exchange_internal_name) + const destination = destination_exchange_internal_name ? destination_exchange : networks.find(n => n.internal_name === destination_network_internal_name) + + return + + +
+ + {swap && } + +
+ {source && + From Logo + } +
+ +
+ {destination && + To Logo + } +
+
+ +
+
+
+} \ No newline at end of file diff --git a/app/components/Swap/NotFound.tsx b/app/components/Swap/NotFound.tsx new file mode 100644 index 00000000..8572063b --- /dev/null +++ b/app/components/Swap/NotFound.tsx @@ -0,0 +1,72 @@ +import { FC, useCallback, useEffect } from "react"; +import MessageComponent from "../MessageComponent"; +import SubmitButton, { DoubleLineText } from "../buttons/submitButton"; +import GoHomeButton from "../utils/GoHome"; +import { useAuthState } from "../../context/authContext"; +import { useIntercom } from "react-use-intercom"; +import { TrackEvent } from '../../pages/_document'; +import { Home, MessageSquare } from "lucide-react"; +import { useRouter } from "next/router"; + +const NotFound: FC = () => { + + const { email, userId } = useAuthState() + const { boot, show, update } = useIntercom() + const { query } = useRouter() + const updateWithProps = () => update({ email: email, userId: userId, customAttributes: { swapId: query?.swapId } }) + + useEffect(() => { + plausible(TrackEvent.SwapFailed) + }, []) + + const startIntercom = useCallback(() => { + boot(); + show(); + updateWithProps() + }, [boot, show, updateWithProps]) + + return + + + Swap not found + + + +

+ Your funds are safe, but there seems to be an issue with the swap. +

+

+ Please contact our support team and we’ll help you fix this. +

+
+
+
+ + +
+
+ +
+
+ + + +
+
+
+
+
+} +export default NotFound \ No newline at end of file diff --git a/app/components/Swap/Step.tsx b/app/components/Swap/Step.tsx new file mode 100644 index 00000000..6ad476f6 --- /dev/null +++ b/app/components/Swap/Step.tsx @@ -0,0 +1,64 @@ +import { Check, X } from "lucide-react"; +import { classNames } from "../utils/classNames"; +import { Gauge } from "../gauge"; +import { ProgressStatus, StatusStep } from "./Withdraw/Processing/types"; + +function renderStepIcon(step: StatusStep) { + switch (step.status) { + case ProgressStatus.Complete: + return ( + + + ); + + case ProgressStatus.Current: + return ( + + + + ); + + case ProgressStatus.Failed: + return ( + + + ); + case ProgressStatus.Delayed: + return ( + + + ) + + default: + return ( + + + ); + } +} + +function Step({ step, isLastStep }: { step: StatusStep, isLastStep: boolean }) { + return ( +
  • +
    + {!isLastStep && ( + +
  • + ); +} + +export default Step; \ No newline at end of file diff --git a/app/components/Swap/StepsComponent.tsx b/app/components/Swap/StepsComponent.tsx new file mode 100644 index 00000000..49cd28e8 --- /dev/null +++ b/app/components/Swap/StepsComponent.tsx @@ -0,0 +1,14 @@ +import Step from "./Step"; +import { StatusStep } from "./Withdraw/Processing/types"; + +export default function Steps({ steps }: { steps: StatusStep[] }) { + return ( + + ); +} \ No newline at end of file diff --git a/app/components/Swap/Summary/Summary.tsx b/app/components/Swap/Summary/Summary.tsx new file mode 100644 index 00000000..4e9ea305 --- /dev/null +++ b/app/components/Swap/Summary/Summary.tsx @@ -0,0 +1,151 @@ +import Image from "next/image"; +import { Fuel } from "lucide-react"; +import { FC, useMemo } from "react"; +import { Currency } from "../../../Models/Currency"; +import { Layer } from "../../../Models/Layer"; +import { useSettingsState } from "../../../context/settings"; +import { truncateDecimals } from "../../utils/RoundDecimals"; +import shortenAddress, { shortenEmail } from "../../utils/ShortenAddress"; +import BridgeApiClient from "../../../lib/BridgeApiClient"; +import { ApiResponse } from "../../../Models/ApiResponse"; +import { Partner } from "../../../Models/Partner"; +import useSWR from 'swr' +import KnownInternalNames from "../../../lib/knownIds"; +import useWallet from "../../../hooks/useWallet"; +import { useQueryState } from "../../../context/query"; +import { useSwapDataState } from "../../../context/swap"; + +type SwapInfoProps = { + currency: Currency, + source: Layer, + destination: Layer; + requestedAmount: number; + receiveAmount?: number; + destinationAddress: string; + hasRefuel?: boolean; + refuelAmount?: number; + fee?: number, + exchange_account_connected: boolean; + exchange_account_name?: string; +} + +const Summary: FC = ({ currency, source: from, destination: to, requestedAmount, receiveAmount, destinationAddress, hasRefuel, refuelAmount, fee, exchange_account_connected, exchange_account_name }) => { + const { resolveImgSrc, currencies, networks } = useSettingsState() + const { getWithdrawalProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return from && getProvider(from) + }, [from, getProvider]) + + const wallet = provider?.getConnectedWallet() + + const { selectedAssetNetwork } = useSwapDataState() + + const { + hideFrom, + hideTo, + account, + appName, + hideAddress + } = useQueryState() + + const bridgeApiClient = new BridgeApiClient() + const { data: partnerData } = useSWR>(appName && `/apps?name=${appName}`, bridgeApiClient.fetcher) + const partner = partnerData?.data + + const source = hideFrom ? partner : from + const destination = hideTo ? partner : to + + const requestedAmountInUsd = (currency?.usd_price * requestedAmount).toFixed(2) + const receiveAmountInUsd = receiveAmount ? (currency?.usd_price * receiveAmount).toFixed(2) : undefined + const nativeCurrency = refuelAmount && to?.isExchange === false ? + currencies.find(c => c.asset === to?.native_currency) : null + + const truncatedRefuelAmount = (hasRefuel && refuelAmount) ? + truncateDecimals(refuelAmount, nativeCurrency?.precision) : null + const refuelAmountInUsd = ((nativeCurrency?.usd_price || 1) * (truncatedRefuelAmount || 0)).toFixed(2) + + let sourceAccountAddress = "" + if (hideFrom && account) { + sourceAccountAddress = shortenAddress(account); + } + else if (wallet && !from?.isExchange) { + sourceAccountAddress = shortenAddress(wallet.address); + } + else if (from?.internal_name === KnownInternalNames.Exchanges.Coinbase && exchange_account_connected) { + sourceAccountAddress = shortenEmail(exchange_account_name, 10); + } + else if (from?.isExchange) { + sourceAccountAddress = "Exchange" + } + else { + sourceAccountAddress = "Network" + } + + const destAddress = (hideAddress && hideTo && account) ? account : destinationAddress + const sourceCurrencyName = selectedAssetNetwork?.network?.currencies.find(c => c.asset === currency.asset)?.name || currency?.asset + const destCurrencyName = networks?.find(n => n.internal_name === to?.internal_name)?.currencies?.find(c => c?.asset === currency?.asset)?.name || currency?.asset + + return ( +
    +
    +
    +
    + {source && {source.display_name}} +
    +

    {source?.display_name}

    + { + sourceAccountAddress && +

    {sourceAccountAddress}

    + } +
    +
    +
    +

    {truncateDecimals(requestedAmount, currency.precision)} {sourceCurrencyName}

    +

    ${requestedAmountInUsd}

    +
    +
    +
    +
    + {destination && {destination.display_name}} +
    +

    {destination?.display_name}

    +

    {shortenAddress(destAddress as string)}

    +
    +
    + { + fee != undefined && receiveAmount != undefined && fee >= 0 ? +
    +

    {truncateDecimals(receiveAmount, currency.precision)} {destCurrencyName}

    +

    ${receiveAmountInUsd}

    +
    + : +
    +
    +
    +
    + } +
    + { + refuelAmount && +
    +
    + + +

    Refuel

    +
    +
    +

    {truncatedRefuelAmount} {nativeCurrency?.asset}

    +

    ${refuelAmountInUsd}

    +
    +
    + } +
    +
    + ) +} + + + +export default Summary \ No newline at end of file diff --git a/app/components/Swap/Summary/index.tsx b/app/components/Swap/Summary/index.tsx new file mode 100644 index 00000000..46e8aaa6 --- /dev/null +++ b/app/components/Swap/Summary/index.tsx @@ -0,0 +1,103 @@ +import { FC } from "react" +import useSWR from 'swr' +import { useSettingsState } from "../../../context/settings" +import { useSwapDataState } from "../../../context/swap" +import Summary from "./Summary" +import { ApiResponse } from "../../../Models/ApiResponse" +import BridgeApiClient, { DepositType, Fee, TransactionType, WithdrawType } from "../../../lib/BridgeApiClient" +import { GetDefaultNetwork } from "../../../helpers/settingsHelper" +import useWalletTransferOptions from "../../../hooks/useWalletTransferOptions" + +const SwapSummary: FC = () => { + const { layers, currencies, networks } = useSettingsState() + const { swap, withdrawType, selectedAssetNetwork } = useSwapDataState() + const { + source_network: source_network_internal_name, + source_exchange: source_exchange_internal_name, + destination_exchange: destination_exchange_internal_name, + destination_network: destination_network_internal_name, + source_network_asset, + destination_network_asset, + } = swap || {} + + const { canDoSweepless, isContractWallet } = useWalletTransferOptions() + + const params = { + source: selectedAssetNetwork?.network?.internal_name, + destination: destination_exchange_internal_name ?? destination_network_internal_name, + source_asset: source_network_asset, + destination_asset: destination_network_asset, + refuel: swap?.has_refuel + } + + const apiClient = new BridgeApiClient() + const { data: feeData } = useSWR>([params], selectedAssetNetwork ? ([params]) => apiClient.GetFee(params) : null, { dedupingInterval: 60000 }) + + const source_layer = layers.find(n => n.internal_name === (source_exchange_internal_name ?? source_network_internal_name)) + const asset = source_layer?.assets?.find(currency => currency?.asset === destination_network_asset) + const currency = currencies?.find(c => c.asset === asset?.asset) + const destination_layer = layers?.find(l => l.internal_name === (destination_exchange_internal_name ?? destination_network_internal_name)) + + if (!swap || !source_layer || !currency || !destination_layer) { + return <> + } + + const swapInputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Input) + const swapOutputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Output) + const swapRefuelTransaction = swap?.transactions?.find(t => t.type === TransactionType.Refuel) + + let fee: number | undefined + let min_amount: number | undefined + + const walletTransferFee = feeData?.data?.find(f => f?.deposit_type === DepositType.Wallet) + const manualTransferFee = feeData?.data?.find(f => f?.deposit_type === DepositType.Manual) + + if (isContractWallet?.ready) { + if (withdrawType === WithdrawType.Wallet && canDoSweepless) { + fee = walletTransferFee?.fee_amount; + min_amount = walletTransferFee?.min_amount; + } else { + fee = manualTransferFee?.fee_amount; + min_amount = manualTransferFee?.min_amount; + } + } + + if (swap?.fee && swapOutputTransaction) { + fee = swap?.fee + } + + const requested_amount = (swapInputTransaction?.amount ?? + (Number(min_amount) > Number(swap.requested_amount) ? min_amount : swap.requested_amount)) || undefined + + const destinationNetworkNativeAsset = currencies?.find(c => c.asset == networks.find(n => n.internal_name === destination_layer?.internal_name)?.native_currency); + const destinationNetwork = GetDefaultNetwork(destination_layer, currency?.asset) + const refuel_amount_in_usd = Number(destinationNetwork?.refuel_amount_in_usd) + const native_usd_price = Number(destinationNetworkNativeAsset?.usd_price) + const currency_usd_price = Number(currency?.usd_price) + + const refuelAmountInNativeCurrency = swap?.has_refuel + ? ((swapRefuelTransaction?.amount ?? + (refuel_amount_in_usd / native_usd_price))) : undefined; + + const refuelAmountInSelectedCurrency = swap?.has_refuel && + (refuel_amount_in_usd / currency_usd_price); + + const receive_amount = fee != undefined ? (swapOutputTransaction?.amount + ?? (Number(requested_amount) - fee - Number(refuelAmountInSelectedCurrency))) + : undefined + + return +} +export default SwapSummary \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Coinbase/Authorize.tsx b/app/components/Swap/Withdraw/Coinbase/Authorize.tsx new file mode 100644 index 00000000..df333034 --- /dev/null +++ b/app/components/Swap/Withdraw/Coinbase/Authorize.tsx @@ -0,0 +1,195 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast'; +import { useSettingsState } from '../../../../context/settings'; +import { useSwapDataState } from '../../../../context/swap'; +import { useInterval } from '../../../../hooks/useInterval'; +import { CalculateMinimalAuthorizeAmount } from '../../../../lib/fees'; +import { parseJwt } from '../../../../lib/jwtParser'; +import BridgeApiClient, { WithdrawType } from '../../../../lib/BridgeApiClient'; +import { OpenLink } from '../../../../lib/openLink'; +import TokenService from '../../../../lib/TokenService'; +import SubmitButton from '../../../buttons/submitButton'; +import Carousel, { CarouselItem, CarouselRef } from '../../../Carousel'; +import { FirstScreen, FourthScreen, LastScreen, SecondScreen, ThirdScreen } from './ConnectGuideScreens'; +import KnownInternalNames from '../../../../lib/knownIds'; +import { Layer } from '../../../../Models/Layer'; +import { ArrowLeft } from 'lucide-react'; +import IconButton from '../../../buttons/iconButton'; +import { motion } from 'framer-motion'; +import { useCoinbaseStore } from './CoinbaseStore'; +import { useRouter } from 'next/router'; +import { Widget } from '../../../Widget/Index'; + +type Props = { + onAuthorized: () => void, + onDoNotConnect: () => void, + stickyFooter: boolean, + hideHeader?: boolean, +} + +const Authorize: FC = ({ onAuthorized, stickyFooter, onDoNotConnect, hideHeader }) => { + const { swap } = useSwapDataState() + const { layers, currencies, discovery } = useSettingsState() + const router = useRouter() + let alreadyFamiliar = useCoinbaseStore((state) => state.alreadyFamiliar); + let toggleAlreadyFamiliar = useCoinbaseStore((state) => state.toggleAlreadyFamiliar); + const [carouselFinished, setCarouselFinished] = useState(alreadyFamiliar) + + const [authWindow, setAuthWindow] = useState() + const [firstScreen, setFirstScreen] = useState(true) + + const carouselRef = useRef(null) + const exchange_internal_name = swap?.source_exchange + const asset_name = swap?.source_network_asset + + const exchange = layers.find(e => e.isExchange && e.internal_name?.toLowerCase() === exchange_internal_name?.toLowerCase()) as Layer & { isExchange: true } + const currency = currencies?.find(c => asset_name?.toLocaleUpperCase() === c.asset?.toLocaleUpperCase()) + + const oauthProviders = discovery?.o_auth_providers + const coinbaseOauthProvider = oauthProviders?.find(p => p.provider === KnownInternalNames.Exchanges.Coinbase) + const { oauth_authorize_url } = coinbaseOauthProvider || {} + + const minimalAuthorizeAmount = currency?.usd_price ? + CalculateMinimalAuthorizeAmount(currency?.usd_price, Number(swap?.requested_amount)) : null + + const checkShouldStartPolling = useCallback(() => { + let authWindowHref: string | undefined = "" + try { + authWindowHref = authWindow?.location?.href + } + catch (e) { + //throws error when accessing href TODO research safe way + } + if (authWindowHref && authWindowHref?.indexOf(window.location.origin) !== -1) { + authWindow?.close() + onAuthorized() + } + }, [authWindow]) + + useInterval( + checkShouldStartPolling, + authWindow && !authWindow.closed ? 1000 : null, + ) + + const handleConnect = useCallback(() => { + try { + if (!swap) + return + if (!carouselFinished && !alreadyFamiliar) { + carouselRef?.current?.next() + return; + } + const access_token = TokenService.getAuthData()?.access_token + if (!access_token) { + //TODO handle not authenticated + return + } + const { sub } = parseJwt(access_token) || {} + const encoded = btoa(JSON.stringify({ SwapId: swap?.id, UserId: sub, RedirectUrl: `${window.location.origin}/salon` })) + const authWindow = OpenLink({ link: oauth_authorize_url + encoded, query: router.query, swapId: swap.id }) + setAuthWindow(authWindow) + } + catch (e) { + toast.error(e.message) + } + }, [carouselFinished, alreadyFamiliar, swap?.id, oauth_authorize_url, router.query]) + + const handlePrev = useCallback(() => { + carouselRef?.current?.prev() + return; + }, []) + + const exchange_name = exchange?.display_name + + const onCarouselLast = (value) => { + setCarouselFinished(value) + } + + const handleToggleChange = (e) => { + if (e.target.checked) { + carouselRef?.current?.goToLast(); + } else { + carouselRef?.current?.goToFirst(); + } + toggleAlreadyFamiliar(); + } + + return ( + <> + + { + !hideHeader ? +

    + Please connect your {exchange_name} account +

    + : <> + } + { +
    + {swap && + + + + + + + + + + + + + + + + } +
    + } +
    + +
    + { +
    + + +
    + } + { +
    + {(!firstScreen && !alreadyFamiliar) && + + } /> + + } + + { + carouselFinished ? "Connect" : "Next" + } + +
    + } +
    +

    + Even after authorization Bridge can't initiate a withdrawal without your explicit confirmation.  + Learn more

    +
    +
    +
    + + ) +} + +export default Authorize; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Coinbase/Coinbase2FA.tsx b/app/components/Swap/Withdraw/Coinbase/Coinbase2FA.tsx new file mode 100644 index 00000000..daa52416 --- /dev/null +++ b/app/components/Swap/Withdraw/Coinbase/Coinbase2FA.tsx @@ -0,0 +1,218 @@ +import { Info, ScanFace } from 'lucide-react'; +import { Form, Formik, FormikErrors, FormikProps } from 'formik'; +import { FC, useCallback, useRef, useState } from 'react' +import toast from 'react-hot-toast'; +import { useSwapDataState } from '../../../../context/swap'; +import { useTimerState } from '../../../../context/timerContext'; +import BridgeApiClient from '../../../../lib/BridgeApiClient'; +import { ApiError, LSAPIKnownErrorCode } from '../../../../Models/ApiError'; +import SubmitButton from '../../../buttons/submitButton'; +import SpinIcon from '../../../icons/spinIcon'; +import NumericInput from '../../../Input/NumericInput'; +import MessageComponent from '../../../MessageComponent'; +import Modal from '../../../modal/modal'; +import TimerWithContext from '../../../TimerComponent'; +import { Widget } from '../../../Widget/Index'; + +const TIMER_SECONDS = 120 + +interface CodeFormValues { + Code: string +} + +type Props = { + onSuccess: (swapId: string) => Promise + footerStickiness?: boolean +} + +//TODO email code is almost identical create reusable component for email and two factor code verification +const Coinbase2FA: FC = ({ onSuccess, footerStickiness = true }) => { + const initialValues: CodeFormValues = { Code: '' } + const { swap } = useSwapDataState() + const [loading, setLoading] = useState(false) + const [showInsufficientFundsModal, setShowInsufficientFundsModal] = useState(false) + const [showFundsOnHoldModal, setShowFundsOnHoldModal] = useState(false) + + const { start: startTimer } = useTimerState() + + const formikRef = useRef>(null); + + const handleSubmit = useCallback(async (values: CodeFormValues) => { + if (!swap || !swap.source_exchange) + return + setLoading(true) + try { + const bridgeApiClient = new BridgeApiClient() + await bridgeApiClient.WithdrawFromExchange(swap.id, swap.source_exchange, values.Code) + await onSuccess(swap.id) + } + catch (error) { + const data: ApiError = error?.response?.data?.error + + if (!data) { + toast.error(error.message) + return + } + else if (data.code === LSAPIKnownErrorCode.INSUFFICIENT_FUNDS) { + setShowInsufficientFundsModal(true) + } + else if (data.code === LSAPIKnownErrorCode.FUNDS_ON_HOLD) { + setShowFundsOnHoldModal(true) + } + else { + toast.error(data.message) + } + } + setLoading(false) + }, [swap]) + + const handleResendTwoFACode = useCallback(async () => { + if (!swap || !swap.source_exchange) + return + setLoading(true) + try { + formikRef.current?.setFieldValue("Code", ""); + const bridgeApiClient = new BridgeApiClient() + await bridgeApiClient.WithdrawFromExchange(swap.id, swap.source_exchange) + } catch (error) { + const data: ApiError = error?.response?.data?.error + + if (!data) { + toast.error(error.message) + return + } + if (data.code === LSAPIKnownErrorCode.COINBASE_INVALID_2FA) { + startTimer(TIMER_SECONDS) + return + } + else { + toast.error(data.message) + } + } + finally { + setLoading(false) + } + }, [swap]) + return <> + + + + + Transfer failed + + + This transfer can't be processed because you don't have enough available funds on Coinbase. + + + + { + window.open("https://www.coinbase.com/", "_blank") + }}> + Check Coinbase + + + + + + + + + Transfer failed + + + This transfer can't be processed because your funds might be on hold on Coinbase. This usually happens when you want to cash out immediately after completeing a purchare or adding cash. + + + + { + window.open("https://help.coinbase.com/en/coinbase/trading-and-funding/sending-or-receiving-cryptocurrency/available-balance-faq", "_blank") + }}> + Learn More + + + + + { + const errors: FormikErrors = {}; + if (!/^[0-9]*$/.test(values.Code)) { + errors.Code = "Value should be numeric"; + } + else if (values.Code.length != 7 && values.Code.length != 6) { + errors.Code = `The length should be 6 or 7 instead of ${values.Code.length}`; + } + return errors; + }} + onSubmit={handleSubmit} + > + {({ isValid, isSubmitting, errors, handleChange }) => ( +
    + +
    + +
    +

    + Coinbase 2FA +

    +

    + Please enter the 2 step verification code of your Coinbase account. +

    +
    +
    + { + /^[0-9]*$/.test(e.target.value) && handleChange(e) + }} + className="leading-none h-12 text-2xl pl-5 text-primary-text focus:ring-primary text-center focus:border-primary border-secondary-500 block + placeholder:text-2xl placeholder:text-center tracking-widest placeholder:font-normal placeholder:opacity-50 bg-secondary-700 w-full font-semibold rounded-md placeholder-primary-text" + /> +
    + + ( + + Resend in + + {remainingTime} + + + )}> + {!loading ? + Resend code + + : } + + +
    +
    + + +
    +
      +
    • your authenticator app (Google, Microsoft, or other), or
    • +
    • text messages of the phone number associated with your Coinbase account
    • +
    +
    +
    +
    + +
    + + Confirm + +
    +
    +
    + )} +
    + ; +} + +export default Coinbase2FA; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Coinbase/CoinbaseStore.ts b/app/components/Swap/Withdraw/Coinbase/CoinbaseStore.ts new file mode 100644 index 00000000..58249dc8 --- /dev/null +++ b/app/components/Swap/Withdraw/Coinbase/CoinbaseStore.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +interface CoinbaseState { + alreadyFamiliar: boolean + toggleAlreadyFamiliar: () => void +} + +export const useCoinbaseStore = create()(persist((set) => ({ + alreadyFamiliar: false, + toggleAlreadyFamiliar: () => set((state) => ({ alreadyFamiliar: !state.alreadyFamiliar })), +}), + { + name: 'coinbase-config-storage' + })) \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Coinbase/ConnectGuideScreens.tsx b/app/components/Swap/Withdraw/Coinbase/ConnectGuideScreens.tsx new file mode 100644 index 00000000..2ebb2420 --- /dev/null +++ b/app/components/Swap/Withdraw/Coinbase/ConnectGuideScreens.tsx @@ -0,0 +1,652 @@ +export const FirstScreen = ({ exchange_name }) => { + return
    +
    .01 After this guide you'll be redirected to {exchange_name}
    +
    + +
    +
    +} + +export const SecondScreen = () => { + return
    +
    .02 When in Coinbase, click Change this amount
    +
    + +
    +
    +} + +export const ThirdScreen = ({ minimalAuthorizeAmount }) => { + return
    +
    .03 Change the existing 1.0 value to {minimalAuthorizeAmount} and click Save
    +
    + +
    +
    +} + +export const FourthScreen = ({ minimalAuthorizeAmount }) => { + return
    +
    .04 Make sure that the amount is {minimalAuthorizeAmount} and click Authorize
    +
    + +
    +
    +} + +export const LastScreen = ({ minimalAuthorizeAmount, number }: { minimalAuthorizeAmount: number, number?: boolean }) => { + return ( +
    +
    + {number && + .05 + } +  Make sure to change the allowed amount to  + {minimalAuthorizeAmount} +
    +
    + +
    +
    + ); +} + +export const FirstScreenImage = () => { + return + + + + + + + Sign In + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} + +export const SecondScreenImage = () => { + return + + + + + + + + + Debit money from your account + This app will be able to send 1 USD per month + on your behalf. + Change this amount + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} + +export const ThirdScreenImage = ({ minimalAuthorizeAmount }) => { + return + + + + + + + Save + + + + + + + + + + {minimalAuthorizeAmount} + / per month + + + + + + + + + + + + + + + + + Save + + + + + + + + + + {minimalAuthorizeAmount} + / per month + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} + +export const FourthScreenImage = ({ minimalAuthorizeAmount }) => { + return + + + + + + Authorize + + + + Debit money from your account + This app will be able to send + {minimalAuthorizeAmount} USD + per month on your behalf. + Change this amount + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} + +export const LastScreenImage = ({ minimalAuthorizeAmount }) => { + return + + + + + + Debit money from your account + This app will be able to send 1 USD per month + on your behalf. + Change this amount + + + + + + + + + + + + + + + + Save + + + + + + {minimalAuthorizeAmount} + / per month + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +} \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Coinbase/index.tsx b/app/components/Swap/Withdraw/Coinbase/index.tsx new file mode 100644 index 00000000..4f84394c --- /dev/null +++ b/app/components/Swap/Withdraw/Coinbase/index.tsx @@ -0,0 +1,148 @@ +import { FC, useCallback, useState } from 'react' +import SubmitButton from '../../../buttons/submitButton'; +import Modal from '../../../modal/modal'; +import Authorize from './Authorize'; +import Coinbase2FA from './Coinbase2FA'; +import { ArrowLeftRight, Link } from 'lucide-react'; +import { useSwapDataState, useSwapDataUpdate } from '../../../../context/swap'; +import BridgeApiClient, { PublishedSwapTransactionStatus } from '../../../../lib/BridgeApiClient'; +import { LSAPIKnownErrorCode } from '../../../../Models/ApiError'; +import toast from 'react-hot-toast'; +import { useSettingsState } from '../../../../context/settings'; +import { TimerProvider, useTimerState } from '../../../../context/timerContext'; +import { useSwapTransactionStore } from '../../../../stores/swapTransactionStore'; +const TIMER_SECONDS = 120 + +const Coinbase: FC = () => { + return + + +} + +const TransferElements: FC = () => { + const { swap, codeRequested } = useSwapDataState() + const { setCodeRequested, mutateSwap } = useSwapDataUpdate() + const { networks } = useSettingsState() + const { + destination_network: destination_network_internal_name, + } = swap || {} + const { start: startTimer } = useTimerState() + const { setSwapTransaction } = useSwapTransactionStore(); + + const [showCoinbaseConnectModal, setShowCoinbaseConnectModal] = useState(false) + const [openCoinbase2FA, setOpenCoinbase2FA] = useState(false) + + const [loading, setLoading] = useState(false) + + const destination_network = networks.find(n => n.internal_name === destination_network_internal_name) + + const handleTransfer = useCallback(async () => { + if (!swap || !swap.source_exchange) + return + setLoading(true) + if (codeRequested) + setOpenCoinbase2FA(true) + else { + try { + const bridgeApiClient = new BridgeApiClient() + await bridgeApiClient.WithdrawFromExchange(swap.id, swap.source_exchange) + } + catch (e) { + if (e?.response?.data?.error?.code === LSAPIKnownErrorCode.COINBASE_INVALID_2FA) { + startTimer(TIMER_SECONDS) + setCodeRequested(true) + setOpenCoinbase2FA(true) + } + else if (e?.response?.data?.error?.code === LSAPIKnownErrorCode.INVALID_CREDENTIALS || e?.response?.data?.error?.code === LSAPIKnownErrorCode.COINBASE_AUTHORIZATION_LIMIT_EXCEEDED) { + setCodeRequested(false) + alert("You have not authorized enough to be able to complete the transfer. Please authorize again.") + } + else if (e?.response?.data?.error?.message) { + toast(e?.response?.data?.error?.message) + } + else if (e?.message) + toast(e.message) + } + } + setLoading(false) + }, [swap, destination_network, codeRequested]) + + const openConnect = () => { + setShowCoinbaseConnectModal(true) + } + + const handleSuccess = useCallback(async (swapId: string) => { + setOpenCoinbase2FA(false) + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Completed, "_") + }, []) + + const handleAuthorized = async () => { + setLoading(true); + setShowCoinbaseConnectModal(false) + await mutateSwap() + setLoading(false); + } + + return ( + <> + + setShowCoinbaseConnectModal(false)} + onAuthorized={handleAuthorized} + stickyFooter={false} + /> + + + + +
    +
    +
    + { + swap?.exchange_account_connected ? + + : + + } +
    +
    +
    + + ) +} + + +export default Coinbase; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/External.tsx b/app/components/Swap/Withdraw/External.tsx new file mode 100644 index 00000000..60bb4ffa --- /dev/null +++ b/app/components/Swap/Withdraw/External.tsx @@ -0,0 +1,31 @@ +import { FC } from 'react' +import { Widget } from '../../Widget/Index'; + + +const External: FC = () => { + + + return ( + +
    +
    + Withdrawal pending +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + The withdrawal has been initiated, please don't close this screen. +

    +
    +
    +
    + ) +} + +export default External; diff --git a/app/components/Swap/Withdraw/Failed.tsx b/app/components/Swap/Withdraw/Failed.tsx new file mode 100644 index 00000000..65c55389 --- /dev/null +++ b/app/components/Swap/Withdraw/Failed.tsx @@ -0,0 +1,92 @@ +import { FC, useCallback, useEffect } from 'react' +import { useSwapDataState } from '../../../context/swap'; +import { useIntercom } from 'react-use-intercom'; +import { useAuthState } from '../../../context/authContext'; +import { SwapStatus } from '../../../Models/SwapStatus'; +import { SwapItem } from '../../../lib/BridgeApiClient'; +import { TrackEvent } from '../../../pages/_document'; +import QuestionIcon from '../../icons/Question'; +import Link from 'next/link'; + +const Failed: FC = () => { + const { swap } = useSwapDataState() + const { email, userId } = useAuthState() + const { boot, show, update } = useIntercom() + const updateWithProps = () => update({ email: email, userId: userId, customAttributes: { swapId: swap?.id } }) + + useEffect(() => { + window.plausible && plausible(TrackEvent.SwapFailed) + }, []) + + const startIntercom = useCallback(() => { + boot(); + show(); + updateWithProps() + }, [boot, show, updateWithProps]) + + return ( + <> +
    +
    + + + +
    +
    + { + swap?.status == SwapStatus.Cancelled && + + } + { + swap?.status == SwapStatus.Expired && + + } + { + swap?.status == SwapStatus.UserTransferDelayed && + + } +
    +
    + + + ) +} +type Props = { + swap: SwapItem + onGetHelp: () => void +} + +const Expired = ({ onGetHelp }: Props) => { + return ( +
    + The transfer wasn't completed during the allocated timeframe. + If you've already sent crypto for this swap, your funds are safe, onGetHelp()}>please contact our support. +
    + ) +} +const Delay: FC = () => { + return ( +
    +

    This usually means that the exchange needs additional verification. + Learn More

    +
      +
    • Check your email for details from Coinbase
    • +
    • Check your Coinbase account's transfer history
    • +
    +
    + ) +} + +const Canceled = ({ onGetHelp }: Props) => { + return ( +
    +

    The transaction was cancelled by your request. + If you've already sent crypto for this swap, your funds are safe, onGetHelp()}> please contact our support. +

    +
    + ) +} + +export default Failed; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/FiatTransfer.tsx b/app/components/Swap/Withdraw/FiatTransfer.tsx new file mode 100644 index 00000000..f3163e10 --- /dev/null +++ b/app/components/Swap/Withdraw/FiatTransfer.tsx @@ -0,0 +1,185 @@ +import { Context, FC, createContext, useContext, useEffect, useRef, useState } from "react"; +import { useSwapDataState } from "../../../context/swap"; +import { StripeOnramp, loadStripeOnramp } from "@stripe/crypto"; +import { PublishedSwapTransactionStatus } from "../../../lib/BridgeApiClient"; +import { useSwapTransactionStore } from "../../../stores/swapTransactionStore"; +import inIframe from "../../utils/inIframe"; +import SubmitButton from "../../buttons/submitButton"; +import { ExternalLink } from "lucide-react"; + +type ContextState = { + onramp: StripeOnramp | null +} +const FiatTransfer: FC = () => { + const { swap } = useSwapDataState() + const stripeSessionId = swap?.fiat_session_id + const secret = process.env.NEXT_PUBLIC_STRIPE_SECRET || "" + const stripeOnrampPromise = loadStripeOnramp(secret); + + const [embedded, setEmbedded] = useState() + + useEffect(() => { + setEmbedded(inIframe()) + }, []) + + return
    + { + embedded ? + window.open(swap?.fiat_redirect_url, '_blank')} icon={} isDisabled={!swap} isSubmitting={false}> + Continue in Stripe + + : + + {stripeSessionId && } + + } +
    +} + +const CryptoElementsContext = createContext(null); + +export const CryptoElements: FC<{ stripeOnramp: Promise, children?: React.ReactNode }> = ({ + stripeOnramp, + children +}) => { + const [ctx, setContext] = useState<{ onramp: StripeOnramp | null }>(() => ({ onramp: null })); + useEffect(() => { + let isMounted = true; + + Promise.resolve(stripeOnramp).then((onramp) => { + if (onramp && isMounted) { + setContext((ctx) => (ctx.onramp ? ctx : { onramp })); + } + }); + + return () => { + isMounted = false; + }; + }, [stripeOnramp]); + + return ( + + {children} + + ); +}; + +// React hook to get StripeOnramp from context +export const useStripeOnramp = () => { + const context = useContext(CryptoElementsContext as Context); + return context?.onramp; +}; +type OnrampElementProps = { + clientSecret: string, + swapId: string, +} +// React element to render Onramp UI +export const OnrampElement: FC = ({ + clientSecret, + swapId, +}) => { + const stripeOnramp = useStripeOnramp(); + const onrampElementRef = useRef(null); + const [loading, setLoading] = useState(false) + const { setSwapTransaction } = useSwapTransactionStore(); + + useEffect(() => { + const containerRef = onrampElementRef.current; + if (containerRef) { + containerRef.innerHTML = ''; + if (clientSecret && stripeOnramp && swapId) { + setLoading(true) + const session = stripeOnramp + .createSession({ + clientSecret, + appearance: { + theme: "dark" + }, + }) + .mount(containerRef) + const eventListener = async (e) => { + let transactionStatus: PublishedSwapTransactionStatus + if (e.payload.session.status === "fulfillment_complete") + transactionStatus = PublishedSwapTransactionStatus.Completed + else if (e.payload.session.status === "fulfillment_processing") + transactionStatus = PublishedSwapTransactionStatus.Pending + else { + // TODO handle + return + } + await setSwapTransaction(swapId, PublishedSwapTransactionStatus.Completed, e.payload.session.id); + } + + session.addEventListener("onramp_session_updated", eventListener) + session.addEventListener("onramp_ui_loaded", () => setLoading(false)) + } + } + + }, [clientSecret, stripeOnramp, swapId]); + + return
    + { + loading && + + } +
    +
    ; +}; + + +const Skeleton: FC = () => { + return
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +} + +export default FiatTransfer \ No newline at end of file diff --git a/app/components/Swap/Withdraw/ManualTransfer.tsx b/app/components/Swap/Withdraw/ManualTransfer.tsx new file mode 100644 index 00000000..5a0ca05c --- /dev/null +++ b/app/components/Swap/Withdraw/ManualTransfer.tsx @@ -0,0 +1,275 @@ +import { FC, useCallback } from "react" +import useSWR from "swr" +import { ArrowLeftRight } from "lucide-react" +import Image from 'next/image'; +import { ApiResponse } from "../../../Models/ApiResponse"; +import { useSettingsState } from "../../../context/settings"; +import { useSwapDataState, useSwapDataUpdate } from "../../../context/swap"; +import KnownInternalNames from "../../../lib/knownIds"; +import BackgroundField from "../../backgroundField"; +import BridgeApiClient, { DepositAddress, DepositAddressSource, DepositType, Fee } from "../../../lib/BridgeApiClient"; +import SubmitButton from "../../buttons/submitButton"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "../../shadcn/select"; +import { BaseL2Asset } from "../../../Models/Layer"; +import shortenAddress from "../../utils/ShortenAddress"; +import { isValidAddress } from "../../../lib/addressValidator"; +import { useSwapDepositHintClicked } from "../../../stores/swapTransactionStore"; + +const ManualTransfer: FC = () => { + const { swap } = useSwapDataState() + const hintsStore = useSwapDepositHintClicked() + const hintClicked = hintsStore.swapTransactions[swap?.id || ""] + const { + source_network: source_network_internal_name } = swap || {} + + const bridgeApiClient = new BridgeApiClient() + const { + data: generatedDeposit, + isLoading + } = useSWR>(`/deposit_addresses/${source_network_internal_name}?source=${DepositAddressSource.UserGenerated}`, + bridgeApiClient.fetcher, + { + dedupingInterval: 60000, + shouldRetryOnError: false + } + ) + + let generatedDepositAddress = generatedDeposit?.data?.address + let shouldGenerateAddress = !generatedDepositAddress && hintClicked + + const handleCloseNote = useCallback(async () => { + if (swap) + hintsStore.setSwapDepositHintClicked(swap?.id) + }, [swap, hintsStore]) + + return ( +
    +
    +
    +
    +

    + About manual transfers +

    +

    + Transfer assets to Bridge’s deposit address to complete the swap. +

    +
    +
    + + OK + +
    +
    + +
    +
    + ) + +} + +const TransferInvoice: FC<{ address?: string, shouldGenerateAddress: boolean }> = ({ address: existingDepositAddress, shouldGenerateAddress }) => { + + const { layers, resolveImgSrc } = useSettingsState() + const { swap, selectedAssetNetwork } = useSwapDataState() + const { setSelectedAssetNetwork } = useSwapDataUpdate() + const { + source_network: source_network_internal_name, + source_exchange: source_exchange_internal_name, + destination_network: destination_network_internal_name, + destination_network_asset, + source_network_asset + } = swap || {} + + const source_exchange = layers.find(n => n.internal_name === source_exchange_internal_name) + + const asset = selectedAssetNetwork?.network?.currencies.find(c => c.asset == destination_network_asset) + + const bridgeApiClient = new BridgeApiClient() + const generateDepositParams = shouldGenerateAddress ? [selectedAssetNetwork?.network_internal_name ?? null] : null + + const { + data: generatedDeposit + } = useSWR>(generateDepositParams, ([network]) => bridgeApiClient.GenerateDepositAddress(network), { dedupingInterval: 60000 }) + + const feeParams = { + source: selectedAssetNetwork?.network?.internal_name, + destination: destination_network_internal_name, + source_asset: source_network_asset, + destination_asset: destination_network_asset, + refuel: swap?.has_refuel + } + + const { data: feeData } = useSWR>([feeParams], ([params]) => bridgeApiClient.GetFee(params), { dedupingInterval: 60000 }) + const manualTransferFee = feeData?.data?.find(f => f?.deposit_type === DepositType.Manual) + + const requested_amount = Number(manualTransferFee?.min_amount) > Number(swap?.requested_amount) ? manualTransferFee?.min_amount : swap?.requested_amount + const depositAddress = existingDepositAddress || generatedDeposit?.data?.address + + const handleChangeSelectedNetwork = useCallback((n: BaseL2Asset) => { + setSelectedAssetNetwork(n) + }, []) + + return
    + {source_exchange &&
    + +
    + } +
    + +
    + { + depositAddress ? +

    + {depositAddress} +

    + : +
    + } + { + (source_network_internal_name === KnownInternalNames.Networks.LoopringMainnet || source_network_internal_name === KnownInternalNames.Networks.LoopringGoerli) && +
    +

    + This address might not be activated. You can ignore it. +

    +
    + } +
    + +
    + { + (source_network_internal_name === KnownInternalNames.Networks.LoopringMainnet || source_network_internal_name === KnownInternalNames.Networks.LoopringGoerli) && +
    +
    + +
    + +

    + To Another Loopring L2 Account +

    +
    +
    +
    + +

    + EOA Wallet +

    +
    +
    + } + +
    + +

    + {requested_amount} +

    +
    + +
    +
    + { + asset && + From Logo + } +
    +
    + + {asset?.name} + + {asset?.contract_address && isValidAddress(asset.contract_address, selectedAssetNetwork?.network) && + + {shortenAddress(asset?.contract_address)} + + } +
    +
    +
    +
    +
    +} + +const ExchangeNetworkPicker: FC<{ onChange: (network: BaseL2Asset) => void }> = ({ onChange }) => { + const { layers, resolveImgSrc } = useSettingsState() + const { swap } = useSwapDataState() + const { + source_exchange: source_exchange_internal_name, + destination_network, + source_network_asset } = swap || {} + const source_exchange = layers.find(n => n.internal_name === source_exchange_internal_name) + + const exchangeAssets = source_exchange?.assets?.filter(a => a.asset === source_network_asset && a.network_internal_name !== destination_network && a.network?.status !== "inactive") + const defaultSourceNetwork = exchangeAssets?.find(sn => sn.is_default) || exchangeAssets?.[0] + + const handleChangeSelectedNetwork = useCallback((n: string) => { + const network = exchangeAssets?.find(network => network?.network_internal_name === n) + if (network) + onChange(network) + }, [exchangeAssets]) + + return
    + Network: + {exchangeAssets?.length === 1 ? +
    + chainLogo + {defaultSourceNetwork?.network?.display_name} +
    + : + + } +
    +} + + +const Sceleton = () => { + return
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +} + + + +export default ManualTransfer \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Processing/Processing.tsx b/app/components/Swap/Withdraw/Processing/Processing.tsx new file mode 100644 index 00000000..6bba0286 --- /dev/null +++ b/app/components/Swap/Withdraw/Processing/Processing.tsx @@ -0,0 +1,325 @@ +import { ExternalLink } from 'lucide-react'; +import { FC } from 'react' +import { Widget } from '../../../Widget/Index'; +import shortenAddress from '../../../utils/ShortenAddress'; +import Steps from '../../StepsComponent'; +import SwapSummary from '../../Summary'; +import { GetNetworkCurrency } from '../../../../helpers/settingsHelper'; +import AverageCompletionTime from '../../../Common/AverageCompletionTime'; +import { SwapItem, TransactionStatus, TransactionType } from '../../../../lib/BridgeApiClient'; +import { truncateDecimals } from '../../../utils/RoundDecimals'; +import { BridgeAppSettings } from '../../../../Models/BridgeAppSettings'; +import { SwapStatus } from '../../../../Models/SwapStatus'; +import { SwapFailReasons } from '../../../../Models/RangeError'; +import { Gauge } from '../../../gauge'; +import Failed from '../Failed'; +import { Progress, ProgressStates, ProgressStatus, StatusStep } from './types'; +import { useSwapTransactionStore } from '../../../../stores/swapTransactionStore'; + +type Props = { + settings: BridgeAppSettings; + swap: SwapItem; +} + +const Processing: FC = ({ settings, swap }) => { + + const swapStatus = swap.status; + const storedWalletTransactions = useSwapTransactionStore() + + const source_network = settings.networks?.find(e => e.internal_name === swap.source_network) + const destination_network = settings.networks?.find(e => e.internal_name === swap.destination_network) + const destination_layer = settings.layers?.find(e => e.internal_name === swap.destination_network) + + const input_tx_explorer = source_network?.transaction_explorer_template + const output_tx_explorer = destination_network?.transaction_explorer_template + + const destinationNetworkCurrency = destination_layer ? GetNetworkCurrency(destination_layer, swap?.destination_network_asset) : null + + const swapInputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Input) + const storedWalletTransaction = storedWalletTransactions.swapTransactions?.[swap?.id] + + const transactionHash = swapInputTransaction?.transaction_id || storedWalletTransaction?.hash + + + const swapOutputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Output) + const swapRefuelTransaction = swap?.transactions?.find(t => t.type === TransactionType.Refuel) + + const nativeCurrency = destination_layer?.isExchange === false ? settings?.currencies?.find(c => c.asset === destination_layer?.native_currency) : null + const truncatedRefuelAmount = swapRefuelTransaction?.amount ? truncateDecimals(swapRefuelTransaction?.amount, nativeCurrency?.precision) : null + + const progressStatuses = getProgressStatuses(swap, swapStatus) + const stepStatuses = progressStatuses.stepStatuses; + + const outputPendingDetails =
    + Estimated arrival after confirmation: +
    + { + destinationNetworkCurrency?.status == 'insufficient_liquidity' ? + Up to 2 hours (delayed) + : + + } +
    +
    + + const progressStates: ProgressStates = { + "input_transfer": { + upcoming: { + name: 'Waiting for your transfer', + description: null + }, + current: { + name: 'Processing your deposit', + description:
    + + Waiting for confirmations + {swapInputTransaction && swapInputTransaction?.confirmations && ( + + {swapInputTransaction?.confirmations >= swapInputTransaction?.max_confirmations + ? swapInputTransaction?.max_confirmations + : swapInputTransaction?.confirmations} + /{swapInputTransaction?.max_confirmations} + + )} + +
    + }, + complete: { + name: `Your deposit is confirmed`, + description:
    + Transaction: + +
    + }, + failed: { + name: `The transfer failed`, + description:
    + Error: +
    + {swap?.fail_reason == SwapFailReasons.RECEIVED_MORE_THAN_VALID_RANGE ? + "Your deposit is higher than the max limit. We'll review and approve your transaction in up to 2 hours." + : + swap?.fail_reason == SwapFailReasons.RECEIVED_LESS_THAN_VALID_RANGE ? + "Your deposit is lower than the minimum required amount. Unfortunately, we can't process the transaction. Please contact support to check if you're eligible for a refund." + : + "Something went wrong while processing the transfer. Please contact support" + } +
    +
    + }, + delayed: { + name: `This transfer is being delayed by Coinbase`, + description: null + } + }, + "output_transfer": { + upcoming: { + name: `Sending ${destinationNetworkCurrency?.name} to your address`, + description: null + }, + current: { + name: `Sending ${destinationNetworkCurrency?.name} to your address`, + description: null + }, + complete: { + name: `${swapOutputTransaction?.amount} ${swap?.destination_network_asset} was sent to your address`, + description: swapOutputTransaction ? : null, + }, + failed: { + name: swap?.fail_reason == SwapFailReasons.RECEIVED_MORE_THAN_VALID_RANGE ? `The transfer is on hold` : "The transfer has failed", + description:
    +
    + {swap?.fail_reason == SwapFailReasons.RECEIVED_MORE_THAN_VALID_RANGE ? + "Your deposit is higher than the max limit. We'll review and approve your transaction in up to 2 hours." + : + swap?.fail_reason == SwapFailReasons.RECEIVED_LESS_THAN_VALID_RANGE ? + "Your deposit is lower than the minimum required amount. Unfortunately, we can't process the transaction. Please contact support to check if you're eligible for a refund." + : + "Something went wrong while processing the transfer. Please contact support" + } +
    +
    + }, + delayed: { + name: `This swap is being delayed by Coinbase`, + description: null + } + }, + "refuel": { + upcoming: { + name: `Sending ${nativeCurrency?.asset} to your address`, + description: null + }, + current: { + name: `Sending ${nativeCurrency?.asset} to your address`, + description: null + }, + complete: { + name: `${truncatedRefuelAmount} ${nativeCurrency?.asset} was sent to your address`, + description:
    + Transaction: +
    + {swapRefuelTransaction && <> + {shortenAddress(swapRefuelTransaction?.transaction_id)} + + } +
    +
    + }, + delayed: { + name: `This transfers is being delayed`, + description: null + } + } + } + + const allSteps: StatusStep[] = [ + { + name: progressStates.input_transfer?.[stepStatuses?.input_transfer]?.name, + status: stepStatuses.input_transfer, + description: progressStates?.input_transfer?.[stepStatuses?.input_transfer]?.description, + index: 1 + }, + { + name: progressStates.output_transfer?.[stepStatuses?.output_transfer]?.name, + status: stepStatuses.output_transfer, + description: progressStates?.output_transfer?.[stepStatuses?.output_transfer]?.description, + index: 2 + }, + { + name: progressStates.refuel?.[stepStatuses?.refuel]?.name, + status: stepStatuses.refuel, + description: progressStates.refuel?.[stepStatuses?.refuel]?.description, + index: 3 + } + ] + + let currentSteps = allSteps.filter((s) => s.status && s.status != ProgressStatus.Removed); + let stepsProgressPercentage = currentSteps.filter(x => x.status == ProgressStatus.Complete).length / currentSteps.length * 100; + + if (!swap) return <> + return ( + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + + {progressStatuses.generalStatus.title} + + + {progressStatuses.generalStatus.subTitle ?? outputPendingDetails} + +
    +
    +
    + { + swap?.status != SwapStatus.Cancelled && swap?.status != SwapStatus.Expired && currentSteps.find(x => x.status != null) && +
    + +
    + } + { + ([SwapStatus.Expired, SwapStatus.Cancelled, SwapStatus.UserTransferDelayed].includes(swap?.status)) && + + } +
    +
    +
    +
    +
    + ) +} + + + + +const getProgressStatuses = (swap: SwapItem, swapStatus: SwapStatus): { stepStatuses: { [key in Progress]: ProgressStatus }, generalStatus: { title: string, subTitle: string | null } } => { + let generalTitle = "Transfer in progress"; + let subtitle: string | null = ""; + //TODO might need to check stored wallet transaction statuses + const swapInputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Input) + + const swapOutputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Output); + const swapRefuelTransaction = swap?.transactions?.find(t => t.type === TransactionType.Refuel); + let inputIsCompleted = swapInputTransaction?.status == TransactionStatus.Completed && swapInputTransaction.confirmations >= swapInputTransaction.max_confirmations; + if (!inputIsCompleted) { + // Magic case, shows estimated time + subtitle = null + } + let input_transfer = inputIsCompleted ? ProgressStatus.Complete : ProgressStatus.Current; + + let output_transfer = + (!swapOutputTransaction && inputIsCompleted) || swapOutputTransaction?.status == TransactionStatus.Pending ? ProgressStatus.Current + : swapOutputTransaction?.status == TransactionStatus.Initiated || swapOutputTransaction?.status == TransactionStatus.Completed ? ProgressStatus.Complete + : ProgressStatus.Upcoming; + + let refuel_transfer = + (swap.has_refuel && !swapRefuelTransaction) ? ProgressStatus.Upcoming + : swapRefuelTransaction?.status == TransactionStatus.Pending ? ProgressStatus.Current + : swapRefuelTransaction?.status == TransactionStatus.Initiated || swapRefuelTransaction?.status == TransactionStatus.Completed ? ProgressStatus.Complete + : ProgressStatus.Removed; + + if (swapStatus === SwapStatus.Failed) { + output_transfer = output_transfer == ProgressStatus.Complete ? ProgressStatus.Complete : ProgressStatus.Failed; + refuel_transfer = refuel_transfer !== ProgressStatus.Complete ? ProgressStatus.Removed : refuel_transfer; + generalTitle = swap?.fail_reason == SwapFailReasons.RECEIVED_MORE_THAN_VALID_RANGE ? "Transfer on hold" : "Transfer failed"; + subtitle = "View instructions below" + } + + if (swapStatus === SwapStatus.UserTransferDelayed) { + input_transfer = ProgressStatus.Removed; + output_transfer = ProgressStatus.Removed; + refuel_transfer = ProgressStatus.Removed; + generalTitle = "Transfer delayed" + subtitle = "View instructions below" + } + + if (swapStatus == SwapStatus.Completed) { + generalTitle = "Transfer completed" + subtitle = "Thanks for using Bridge" + } + if (swapStatus == SwapStatus.Cancelled) { + generalTitle = "Transfer cancelled" + subtitle = "..." + } + if (swapStatus == SwapStatus.Expired) { + generalTitle = "Transfer expired" + subtitle = "..." + } + return { + stepStatuses: { + "input_transfer": input_transfer, + "output_transfer": output_transfer, + "refuel": refuel_transfer, + }, + generalStatus: { + title: generalTitle, + subTitle: subtitle + } + }; + +} + +export default Processing; diff --git a/app/components/Swap/Withdraw/Processing/index.tsx b/app/components/Swap/Withdraw/Processing/index.tsx new file mode 100644 index 00000000..efa6b1ea --- /dev/null +++ b/app/components/Swap/Withdraw/Processing/index.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' +import { useSwapDataState } from '../../../../context/swap'; +import { useSettingsState } from '../../../../context/settings'; +import Processing from './Processing'; + +const Component: FC = () => { + + const { swap } = useSwapDataState() + const settings = useSettingsState() + + return ( + <> + {swap && } + + + ) +} + +export default Component; diff --git a/app/components/Swap/Withdraw/Processing/types.ts b/app/components/Swap/Withdraw/Processing/types.ts new file mode 100644 index 00000000..83682535 --- /dev/null +++ b/app/components/Swap/Withdraw/Processing/types.ts @@ -0,0 +1,28 @@ + +export type ProgressStates = { + [key in Progress]?: { + [key in ProgressStatus]?: { + name?: string; + description?: JSX.Element | string | null | undefined + } + } +} +export enum Progress { + InputTransfer = 'input_transfer', + Refuel = 'refuel', + OutputTransfer = 'output_transfer' +} +export enum ProgressStatus { + Upcoming = 'upcoming', + Current = 'current', + Complete = 'complete', + Failed = 'failed', + Delayed = 'delayed', + Removed = 'removed', +} +export type StatusStep = { + name?: string; + status: ProgressStatus; + description?: | JSX.Element | string | null; + index?: number; +} \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Success.tsx b/app/components/Swap/Withdraw/Success.tsx new file mode 100644 index 00000000..749d6290 --- /dev/null +++ b/app/components/Swap/Withdraw/Success.tsx @@ -0,0 +1,73 @@ +import { ExternalLink } from 'lucide-react'; +import { Home } from 'lucide-react'; +import { FC, useCallback } from 'react' +import { useSettingsState } from '../../../context/settings'; +import { useSwapDataState } from '../../../context/swap'; +import MessageComponent from '../../MessageComponent'; +import { Widget } from '../../Widget/Index'; +import SubmitButton, { DoubleLineText } from '../../buttons/submitButton'; +import GoHomeButton from '../../utils/GoHome'; +import { TransactionType } from '../../../lib/BridgeApiClient'; +import AppSettings from '../../../lib/AppSettings'; +import { useRouter } from 'next/router'; +import { useQueryState } from '../../../context/query'; + +const Success: FC = () => { + const { networks } = useSettingsState() + const { swap } = useSwapDataState() + const router = useRouter() + const { externalId } = useQueryState() + const { destination_network: destination_network_internal_name } = swap || {} + const destination_network = networks.find(n => n.internal_name === destination_network_internal_name) + const transaction_explorer_template = destination_network?.transaction_explorer_template + const swapOutputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Output) + + const handleViewInExplorer = useCallback(() => { + if (!transaction_explorer_template) + return + window.open(`${AppSettings.ExplorerURl}/${swapOutputTransaction?.transaction_id}`, '_blank') + }, [transaction_explorer_template]) + + return ( + <> + + +
    + {!externalId && + ((transaction_explorer_template && swapOutputTransaction?.transaction_id) ? + <> +
    + }> + + +
    + + : +
    + + + +
    ) + } + { + externalId && transaction_explorer_template && swapOutputTransaction?.transaction_id && +
    + }> + View in explorer + +
    + } +
    +
    +
    + + ) +} + +export default Success; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/ImtblxWalletWithdrawStep.tsx b/app/components/Swap/Withdraw/Wallet/ImtblxWalletWithdrawStep.tsx new file mode 100644 index 00000000..95219328 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/ImtblxWalletWithdrawStep.tsx @@ -0,0 +1,104 @@ +import { Link, ArrowLeftRight } from 'lucide-react'; +import { FC, useCallback, useMemo, useState } from 'react' +import SubmitButton from '../../../buttons/submitButton'; +import { useSwapDataState } from '../../../../context/swap'; +import toast from 'react-hot-toast'; +import { PublishedSwapTransactionStatus } from '../../../../lib/BridgeApiClient'; +import { useSettingsState } from '../../../../context/settings'; +import WarningMessage from '../../../WarningMessage'; +import GuideLink from '../../../guideLink'; +import useWallet from '../../../../hooks/useWallet'; +import { useSwapTransactionStore } from '../../../../stores/swapTransactionStore'; + +type Props = { + depositAddress?: string +} + +const ImtblxWalletWithdrawStep: FC = ({ depositAddress }) => { + const [loading, setLoading] = useState(false) + const [transferDone, setTransferDone] = useState() + const { swap } = useSwapDataState() + const { networks, layers } = useSettingsState() + const { setSwapTransaction } = useSwapTransactionStore(); + + const { source_network: source_network_internal_name } = swap || {} + const source_network = networks.find(n => n.internal_name === source_network_internal_name) + const source_layer = layers.find(n => n.internal_name === source_network_internal_name) + const { getWithdrawalProvider: getProvider } = useWallet() + const provider = useMemo(() => { + return source_layer && getProvider(source_layer) + }, [source_layer, getProvider]) + + const imxAccount = provider?.getConnectedWallet() + + const handleConnect = useCallback(async () => { + if (!provider) + throw new Error(`No provider from ${source_layer?.internal_name}`) + if (source_layer?.isExchange === true) + throw new Error(`Source is exchange`) + + setLoading(true) + await provider?.connectWallet(source_layer?.chain_id) + setLoading(false) + }, [provider, source_layer]) + + const handleTransfer = useCallback(async () => { + if (!source_network || !swap || !depositAddress) + return + setLoading(true) + try { + const ImtblClient = (await import('../../../../lib/imtbl')).default; + const imtblClient = new ImtblClient(source_network?.internal_name) + const source_currency = source_network.currencies.find(c => c.asset.toLocaleUpperCase() === swap.source_network_asset.toLocaleUpperCase()) + if (!source_currency) { + throw new Error("No source currency could be found"); + } + const res = await imtblClient.Transfer(swap, source_currency, depositAddress) + const transactionRes = res?.result?.[0] + if (!transactionRes) + toast('Transfer failed or terminated') + else if (transactionRes.status == "error") { + toast(transactionRes.message) + } + else if (transactionRes.status == "success") { + setSwapTransaction(swap.id, PublishedSwapTransactionStatus.Completed, transactionRes.txId.toString()); + setTransferDone(true) + } + } + catch (e) { + if (e?.message) + toast(e.message) + } + setLoading(false) + }, [imxAccount, swap, source_network, depositAddress]) + + return ( + <> +
    +
    + + + Learn how to send from + + + + { + !imxAccount && + + } + { + imxAccount && + + } +
    +
    + + ) +} + + +export default ImtblxWalletWithdrawStep; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/RainbowKit.tsx b/app/components/Swap/Withdraw/Wallet/RainbowKit.tsx new file mode 100644 index 00000000..b98bb700 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/RainbowKit.tsx @@ -0,0 +1,60 @@ +import '@rainbow-me/rainbowkit/styles.css'; +import { FC } from 'react'; +import { + ConnectButton, +} from '@rainbow-me/rainbowkit'; + +const RainbowKit: FC<{ children?: React.ReactNode }> = ({ children }) => { + return ( + + {({ + account, + chain, + openChainModal, + openConnectModal, + mounted, + }) => { + const ready = mounted; + const connected = + ready && + account && + chain + return ( +
    + {(() => { + if (!connected) { + return ( + + {children} + + ); + } + + if (chain.unsupported) { + return ( + + ); + } + return ( + <> + ); + })()} +
    + ); + }} +
    + ) +} + +export default RainbowKit; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/StarknetWalletWithdraw.tsx b/app/components/Swap/Withdraw/Wallet/StarknetWalletWithdraw.tsx new file mode 100644 index 00000000..d72217b5 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/StarknetWalletWithdraw.tsx @@ -0,0 +1,212 @@ +import { Link, ArrowLeftRight } from 'lucide-react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import SubmitButton from '../../../buttons/submitButton'; +import { useSwapDataState } from '../../../../context/swap'; +import toast from 'react-hot-toast'; +import { PublishedSwapTransactionStatus } from '../../../../lib/BridgeApiClient'; +import { useSettingsState } from '../../../../context/settings'; +import WarningMessage from '../../../WarningMessage'; +import { Contract, BigNumberish, cairo } from 'starknet'; +import Erc20Abi from "../../../../lib/abis/ERC20.json" +import WatchDogAbi from "../../../../lib/abis/LSWATCHDOG.json" +import { useAuthState } from '../../../../context/authContext'; +import KnownInternalNames from '../../../../lib/knownIds'; +import { parseUnits } from 'viem' +import useWallet from '../../../../hooks/useWallet'; +import { useSwapTransactionStore } from '../../../../stores/swapTransactionStore'; + +type Props = { + depositAddress?: string; + amount?: number +} + +function getUint256CalldataFromBN(bn: BigNumberish) { + return { ...cairo.uint256(bn) } +} + +export function parseInputAmountToUint256(input: string, decimals: number = 18) { + return getUint256CalldataFromBN(parseUnits(input, decimals).toString()) +} + +const StarknetWalletWithdrawStep: FC = ({ depositAddress, amount }) => { + + const [loading, setLoading] = useState(false) + const [transferDone, setTransferDone] = useState() + const { getWithdrawalProvider: getProvider } = useWallet() + const [isWrongNetwork, setIsWrongNetwork] = useState() + + const { userId } = useAuthState() + const { swap } = useSwapDataState() + const { networks, layers } = useSettingsState() + + const { setSwapTransaction } = useSwapTransactionStore(); + const { source_network: source_network_internal_name } = swap || {} + const source_network = networks.find(n => n.internal_name === source_network_internal_name) + const source_layer = layers.find(n => n.internal_name === source_network_internal_name) + const sourceCurrency = source_network?.currencies.find(c => c.asset?.toLowerCase() === swap?.source_network_asset?.toLowerCase()) + const sourceChainId = source_network?.chain_id + + const provider = useMemo(() => { + return source_layer && getProvider(source_layer) + }, [source_layer, getProvider]) + + const wallet = provider?.getConnectedWallet() + + const handleConnect = useCallback(async () => { + if (!provider) + throw new Error(`No provider from ${source_layer?.internal_name}`) + if (source_layer?.isExchange === true) + throw new Error(`Source is exchange`) + + setLoading(true) + try { + await provider.connectWallet(source_layer?.chain_id) + } + catch (e) { + toast(e.message) + } + setLoading(false) + }, [source_layer, provider]) + + useEffect(() => { + const connectedChainId = wallet?.chainId + if (source_layer && connectedChainId && connectedChainId !== sourceChainId && provider) { + (async () => { + setIsWrongNetwork(true) + await provider.disconnectWallet() + })() + } else if (source_layer && connectedChainId && connectedChainId === sourceChainId) { + setIsWrongNetwork(false) + } + }, [wallet, source_layer, sourceChainId, provider]) + + const handleTransfer = useCallback(async () => { + if (!swap || !sourceCurrency) { + return + } + setLoading(true) + try { + if (!wallet) { + throw Error("starknet wallet not connected") + } + if (!sourceCurrency.contract_address) { + throw Error("starknet contract_address is not defined") + } + if (!source_network?.metadata?.WatchdogContractAddress) { + throw Error("WatchdogContractAddress is not defined on network metadata") + } + if (!amount) { + throw Error("amount is not defined for starknet transfer") + } + if (!depositAddress) { + throw Error("depositAddress is not defined for starknet transfer") + } + const erc20Contract = new Contract( + Erc20Abi, + sourceCurrency.contract_address, + wallet.metadata?.starknetAccount?.account, + ) + + const watchDogContract = new Contract( + WatchDogAbi, + source_network.metadata.WatchdogContractAddress, + wallet.metadata?.starknetAccount?.account + ) + + const call = erc20Contract.populate( + "transfer", + [ + depositAddress, + parseInputAmountToUint256(amount.toString(), sourceCurrency?.decimals) + ] + ); + + const watch = watchDogContract.populate( + "watch", + [swap.sequence_number], + ); + + try { + const { transaction_hash: transferTxHash } = (await wallet?.metadata?.starknetAccount?.account?.execute([call, watch]) || {}); + if (transferTxHash) { + setSwapTransaction(swap.id, PublishedSwapTransactionStatus.Completed, transferTxHash); + setTransferDone(true) + } + else { + toast('Transfer failed or terminated') + } + } + catch (e) { + toast(e.message) + } + } + catch (e) { + if (e?.message) + toast(e.message) + } + setLoading(false) + }, [wallet, swap, source_network, depositAddress, userId, sourceCurrency]) + + return ( + <> +
    +
    + { + + isWrongNetwork && + + + { + source_network_internal_name === KnownInternalNames.Networks.StarkNetMainnet + ? Please switch to Starknet Mainnet with your wallet and click Connect again + : Please switch to Starknet Goerli with your wallet and click Connect again + } + + + } + { + !wallet && +
    + +
    + } + { + wallet + && depositAddress + && !isWrongNetwork + &&
    + +
    + } +
    +
    + + ) +} + + +export default StarknetWalletWithdrawStep; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferErc20.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferErc20.tsx new file mode 100644 index 00000000..4b7c18c9 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferErc20.tsx @@ -0,0 +1,154 @@ +import { FC, useCallback, useEffect, useState } from "react"; +import { + useAccount, + useContractWrite, + usePrepareContractWrite, + useWaitForTransaction, + useNetwork, + erc20ABI, +} from "wagmi"; +import { PublishedSwapTransactionStatus } from "../../../../../lib/BridgeApiClient"; +import WalletIcon from "../../../../icons/WalletIcon"; +import { encodeFunctionData, http, parseUnits, createWalletClient, publicActions } from 'viem' +import TransactionMessage from "./transactionMessage"; +import { BaseTransferButtonProps } from "./sharedTypes"; +import { ButtonWrapper } from "./buttons"; +import { useSwapTransactionStore } from "../../../../../stores/swapTransactionStore"; +import useWalletTransferOptions from "../../../../../hooks/useWalletTransferOptions"; +import { SendTransactionData } from "../../../../../lib/telegram"; + +type TransferERC20ButtonProps = BaseTransferButtonProps & { + tokenContractAddress: `0x${string}`, + tokenDecimals: number, +} +const TransferErc20Button: FC = ({ + depositAddress, + amount, + tokenContractAddress, + tokenDecimals, + savedTransactionHash, + swapId, + sequenceNumber, + userDestinationAddress, +}) => { + const [applyingTransaction, setApplyingTransaction] = useState(!!savedTransactionHash) + const { address } = useAccount(); + const [buttonClicked, setButtonClicked] = useState(false) + const [estimatedGas, setEstimatedGas] = useState() + const { setSwapTransaction } = useSwapTransactionStore(); + const { canDoSweepless, isContractWallet } = useWalletTransferOptions() + + const contractWritePrepare = usePrepareContractWrite({ + enabled: !!depositAddress && isContractWallet?.ready, + address: tokenContractAddress, + abi: erc20ABI, + functionName: 'transfer', + gas: estimatedGas, + args: depositAddress ? [depositAddress, parseUnits(amount.toString(), tokenDecimals)] : undefined, + }); + + let encodedData = depositAddress && contractWritePrepare?.config?.request + && encodeFunctionData({ + ...contractWritePrepare?.config?.request, + }); + + if (encodedData && canDoSweepless && address !== userDestinationAddress) { + encodedData = encodedData ? `${encodedData}${sequenceNumber}` as `0x${string}` : encodedData; + } + + const tx = { + ...contractWritePrepare?.config, + request: { + ...contractWritePrepare?.config?.request, + data: encodedData + } + } + const { chain } = useNetwork(); + const publicClient = createWalletClient({ + account: address, + chain: chain, + transport: http(), + }).extend(publicActions); + + useEffect(() => { + (async () => { + if (encodedData && address) { + const estimate = await publicClient.estimateGas({ + data: encodedData, + to: tokenContractAddress, + account: address, + }) + setEstimatedGas(estimate) + } + })() + }, [address, encodedData, depositAddress, amount, tokenDecimals, tx]) + + const contractWrite = useContractWrite(tx) + useEffect(() => { + try { + if (contractWrite?.data?.hash) { + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Pending, contractWrite?.data?.hash); + if (!!isContractWallet?.isContract) + SendTransactionData(swapId, contractWrite?.data?.hash) + } + } + catch (e) { + //TODO log to logger + console.error(e.message) + } + }, [contractWrite?.data?.hash, swapId, isContractWallet?.isContract]) + + const clickHandler = useCallback(() => { + setButtonClicked(true) + contractWrite?.write && contractWrite?.write() + }, [contractWrite]) + + const waitForTransaction = useWaitForTransaction({ + hash: contractWrite?.data?.hash || savedTransactionHash, + onSuccess: async (trxRcpt) => { + setApplyingTransaction(true) + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Completed, trxRcpt.transactionHash); + setApplyingTransaction(false) + }, + onError: async (err) => { + if (contractWrite?.data?.hash) + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Error, contractWrite.data.hash, err.message); + } + }) + + const isError = [ + contractWritePrepare, + waitForTransaction, + contractWrite + ].find(d => d.isError) + + const isLoading = [ + waitForTransaction, + contractWrite + ].find(d => d.isLoading) + + return <> + { + buttonClicked && + + } + { + !isLoading && + } + > + {(isError && buttonClicked) ? Try again + : Send from wallet} + + } + +} + +export default TransferErc20Button \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferNativeToken.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferNativeToken.tsx new file mode 100644 index 00000000..1efcf862 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/TransferNativeToken.tsx @@ -0,0 +1,180 @@ +import { FC, useCallback, useEffect, useState } from "react"; +import { + useAccount, + usePrepareSendTransaction, + useSendTransaction, + useWaitForTransaction, + useNetwork, +} from "wagmi"; +import { parseEther, createPublicClient, http } from 'viem' +import SubmitButton from "../../../../buttons/submitButton"; +import { PublishedSwapTransactionStatus } from "../../../../../lib/BridgeApiClient"; +import WalletIcon from "../../../../icons/WalletIcon"; +import Modal from '../../../../modal/modal'; +import MessageComponent from "../../../../MessageComponent"; +import { BaseTransferButtonProps } from "./sharedTypes"; +import TransactionMessage from "./transactionMessage"; +import { ButtonWrapper } from "./buttons"; +import { useSwapTransactionStore } from "../../../../../stores/swapTransactionStore"; +import useWalletTransferOptions from "../../../../../hooks/useWalletTransferOptions"; +import { SendTransactionData } from "../../../../../lib/telegram"; + +type TransferNativeTokenButtonProps = BaseTransferButtonProps & { + chainId: number, +} + +const TransferNativeTokenButton: FC = ({ + depositAddress, + chainId, + amount, + savedTransactionHash, + swapId, + userDestinationAddress, + sequenceNumber, +}) => { + const [applyingTransaction, setApplyingTransaction] = useState(!!savedTransactionHash) + const [buttonClicked, setButtonClicked] = useState(false) + const [openChangeAmount, setOpenChangeAmount] = useState(false) + const [estimatedGas, setEstimatedGas] = useState() + const { address } = useAccount(); + const { setSwapTransaction } = useSwapTransactionStore(); + const { canDoSweepless, isContractWallet } = useWalletTransferOptions() + + const sendTransactionPrepare = usePrepareSendTransaction({ + enabled: !!depositAddress && isContractWallet?.ready, + to: depositAddress, + value: amount ? parseEther(amount.toString()) : undefined, + chainId: chainId, + }) + const encodedData: `0x${string}` = (canDoSweepless && address !== userDestinationAddress) ? `0x${sequenceNumber}` : "0x" + + const tx = { + to: depositAddress, + value: amount ? parseEther(amount?.toString()) : undefined, + gas: estimatedGas, + data: encodedData + } + + const transaction = useSendTransaction(tx) + + const { chain } = useNetwork(); + + const publicClient = createPublicClient({ + chain: chain, + transport: http() + }) + + useEffect(() => { + (async () => { + if (address && depositAddress) { + const gasEstimate = await publicClient.estimateGas({ + account: address, + to: depositAddress, + data: encodedData, + }) + setEstimatedGas(gasEstimate) + } + })() + }, [address, encodedData, depositAddress, amount]) + + useEffect(() => { + try { + if (transaction?.data?.hash) { + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Pending, transaction?.data?.hash) + if (!!isContractWallet?.isContract) + SendTransactionData(swapId, transaction?.data?.hash) + } + } + catch (e) { + //TODO log to logger + console.error(e.message) + } + }, [transaction?.data?.hash, swapId, isContractWallet?.isContract]) + + const waitForTransaction = useWaitForTransaction({ + hash: transaction?.data?.hash || savedTransactionHash, + onSuccess: async (trxRcpt) => { + setApplyingTransaction(true) + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Completed, trxRcpt.transactionHash); + setApplyingTransaction(false) + }, + onError: async (err) => { + if (transaction?.data?.hash) + setSwapTransaction(swapId, PublishedSwapTransactionStatus.Error, transaction?.data?.hash, err.message); + } + }) + + const clickHandler = useCallback(async () => { + setButtonClicked(true) + return transaction?.sendTransaction && transaction?.sendTransaction() + }, [transaction, estimatedGas]) + + const isError = [ + sendTransactionPrepare, + transaction, + waitForTransaction + ].find(d => d.isError) + + const isLoading = [ + transaction, + waitForTransaction + ].find(d => d.isLoading) + + return <> + { + buttonClicked && + + } + { + !isLoading && + } + > + {(isError && buttonClicked) ? Try again + : Send from wallet} + + } + + +
    +
    + Insufficient funds for gas +
    +
    + This transfer can't be processed because you don't have enough gas. +
    +
    +
    + You have requested swap with {amount} +
    + +
    +
    + { setOpenChangeAmount(false); clickHandler() }} text_align='left' isDisabled={false} isSubmitting={false} buttonStyle='filled' > + Transfer + +
    +
    + setOpenChangeAmount(false)} button_align='right' text_align='left' isDisabled={false} isSubmitting={false} buttonStyle='outline' > + Cancel + +
    +
    +
    +
    +
    + +} + +export default TransferNativeTokenButton \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/buttons.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/buttons.tsx new file mode 100644 index 00000000..b114ec09 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/buttons.tsx @@ -0,0 +1,111 @@ +import { FC, ReactNode, useCallback, useMemo } from "react"; +import { + useSwitchNetwork, +} from "wagmi"; +import WalletIcon from "../../../../icons/WalletIcon"; +import WalletMessage from "./message"; +import { ActionData } from "./sharedTypes"; +import SubmitButton from "../../../../buttons/submitButton"; +import useWallet from "../../../../../hooks/useWallet"; +import { useSwapDataState } from "../../../../../context/swap"; +import { useSettingsState } from "../../../../../context/settings"; + +export const ConnectWalletButton: FC = () => { + const { swap } = useSwapDataState() + const { layers } = useSettingsState() + const { getWithdrawalProvider: getProvider } = useWallet() + const source_layer = layers.find(l => l.internal_name === swap?.source_network) + const provider = useMemo(() => { + return source_layer && getProvider(source_layer) + }, [source_layer, getProvider]) + + const clickHandler = useCallback(() => { + if (!provider) + throw new Error(`No provider from ${source_layer?.internal_name}`) + + return provider.connectWallet(provider?.name) + }, [provider]) + + return } + > + Connect wallet + +} + +export const ChangeNetworkMessage: FC<{ data: ActionData, network: string }> = ({ data, network }) => { + if (data.isLoading) { + return + } + else if (data.isError) { + return + } +} + +export const ChangeNetworkButton: FC<{ chainId: number, network: string }> = ({ chainId, network }) => { + + const networkChange = useSwitchNetwork({ + chainId: chainId, + }); + + const clickHandler = useCallback(() => { + return networkChange?.switchNetwork && networkChange?.switchNetwork() + }, [networkChange]) + + return <> + { + + } + { + !networkChange.isLoading && + } + > + { + networkChange.isError ? Try again + : Send from wallet + } + + } + +} + +type ButtonWrapperProps = { + icon?: ReactNode, + clcikHandler: () => void, + disabled?: boolean, + children: ReactNode +} +export const ButtonWrapper: FC = ({ + icon, + clcikHandler, + disabled, + children +}) => { + return
    +
    + + {children} + +
    +
    +} \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/index.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/index.tsx new file mode 100644 index 00000000..ff88fbc4 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/index.tsx @@ -0,0 +1,99 @@ +import { FC, useEffect, useState } from "react"; +import { + useAccount, + useSwitchNetwork, + useNetwork, +} from "wagmi"; +import { PublishedSwapTransactions } from "../../../../../lib/BridgeApiClient"; +import TransferNativeTokenButton from "./TransferNativeToken"; +import { ChangeNetworkButton, ConnectWalletButton } from "./buttons"; +import TransferErc20Button from "./TransferErc20"; + + +type Props = { + sequenceNumber: number, + chainId: number, + depositAddress?: `0x${string}`, + tokenContractAddress?: `0x${string}` | null, + userDestinationAddress: `0x${string}`, + amount: number, + tokenDecimals: number, + networkDisplayName: string, + swapId: string; +} + +const TransferFromWallet: FC = ({ networkDisplayName, + chainId, + depositAddress, + userDestinationAddress, + amount, + tokenContractAddress, + tokenDecimals, + sequenceNumber, + swapId, +}) => { + const { isConnected } = useAccount(); + const networkChange = useSwitchNetwork({ + chainId: chainId, + }); + + const { chain: activeChain } = useNetwork(); + + const [savedTransactionHash, setSavedTransactionHash] = useState() + + useEffect(() => { + if (activeChain?.id === chainId) + networkChange.reset() + }, [activeChain, chainId]) + + useEffect(() => { + try { + const data: PublishedSwapTransactions = JSON.parse(localStorage.getItem('swapTransactions') || "{}") + const hash = data?.[swapId]?.hash + if (hash) + setSavedTransactionHash(hash) + } + catch (e) { + //TODO log to logger + console.error(e.message) + } + }, [swapId]) + + const hexed_sequence_number = sequenceNumber?.toString(16) + const sequence_number_even = (hexed_sequence_number?.length % 2 > 0 ? `0${hexed_sequence_number}` : hexed_sequence_number) + + if (!isConnected) { + return + } + else if (activeChain?.id !== chainId) { + return + } + else if (tokenContractAddress) { + return + } + else { + return + } +} + +export default TransferFromWallet diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/message.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/message.tsx new file mode 100644 index 00000000..b4ed8a78 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/message.tsx @@ -0,0 +1,62 @@ +import { FC, useState } from "react"; +import FailIcon from "../../../../icons/FailIcon"; +import Modal from "../../../../modal/modal"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +export type WalletMessageProps = { + header: string; + details: string; + status: 'pending' | 'error'; + showInModal?: boolean; +} +const WalletMessage: FC = ({ header, details, status, showInModal }) => { + const [showErrorModal, setShowErrorModal] = useState(false); + + return
    +
    + { + status === "error" ? + + : + <> +
    +
    +
    + + } +
    + { + showInModal ? +
    + + {/* TODO handle overflow */} + +
    +

    + {header}x +

    +

    + {details} +

    +
    +
    +
    + : +
    +

    + {header} +

    +

    + {details} +

    +
    + } +
    +} + +export default WalletMessage \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/resolveError.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/resolveError.tsx new file mode 100644 index 00000000..e1256009 --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/resolveError.tsx @@ -0,0 +1,36 @@ +import { BaseError, InsufficientFundsError, EstimateGasExecutionError, UserRejectedRequestError } from 'viem' + + +type ResolvedError = "insufficient_funds" | "transaction_rejected" + +const resolveError = (error: BaseError): ResolvedError | undefined => { + + const isInsufficientFundsError = typeof error?.walk === "function" && error?.walk((e: BaseError) => (e instanceof InsufficientFundsError) + || (e instanceof EstimateGasExecutionError) || e?.['data']?.args?.some((a: string) => a?.includes("amount exceeds"))) + + if (isInsufficientFundsError) + return "insufficient_funds" + + const isUserRejectedRequestError = typeof error?.walk === "function" && error?.walk && error?.walk((e: BaseError) => e instanceof UserRejectedRequestError) instanceof UserRejectedRequestError + + if (isUserRejectedRequestError) + return "transaction_rejected" + + const code_name = error?.['code'] + || error?.["name"] + const inner_code = error?.['data']?.['code'] + || error?.['cause']?.['code'] + || error?.["cause"]?.["cause"]?.["cause"]?.["code"] + + if (code_name === 'INSUFFICIENT_FUNDS' + || code_name === 'UNPREDICTABLE_GAS_LIMIT' + || (code_name === -32603 && inner_code === 3) + || inner_code === -32000 + || code_name === 'EstimateGasExecutionError') + return "insufficient_funds" + else if (code_name === 4001) { + return "transaction_rejected" + } +} + +export default resolveError diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/sharedTypes.ts b/app/components/Swap/Withdraw/Wallet/WalletTransfer/sharedTypes.ts new file mode 100644 index 00000000..5e0dea0c --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/sharedTypes.ts @@ -0,0 +1,14 @@ +export type ActionData = { + error: Error | null; + isError: boolean; + isLoading: boolean; +} + +export type BaseTransferButtonProps = { + swapId: string, + sequenceNumber: string, + depositAddress?: `0x${string}`, + userDestinationAddress: `0x${string}`, + amount: number, + savedTransactionHash: `0x${string}`, +} \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/WalletTransfer/transactionMessage.tsx b/app/components/Swap/Withdraw/Wallet/WalletTransfer/transactionMessage.tsx new file mode 100644 index 00000000..69567cfc --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/WalletTransfer/transactionMessage.tsx @@ -0,0 +1,87 @@ +import { FC } from "react" +import WalletMessage from "./message" +import resolveError from "./resolveError" +import { ActionData } from "./sharedTypes" +import { BaseError } from 'viem' + +type TransactionMessageProps = { + prepare: ActionData, + wait: ActionData, + transaction: ActionData, + applyingTransaction: boolean, +} + +const TransactionMessage: FC = ({ + prepare, wait, transaction, applyingTransaction +}) => { + const prepareResolvedError = resolveError(prepare?.error as BaseError) + const transactionResolvedError = resolveError(transaction?.error as BaseError) + const hasError = prepare?.isError || transaction?.isError || wait?.isError + + if (wait?.isLoading || applyingTransaction) { + return + } + else if (transaction?.isLoading || applyingTransaction) { + return + } + else if (prepare?.isLoading) { + return + } + else if (prepare?.isError && prepareResolvedError === "insufficient_funds") { + return + } + else if (transaction?.isError && transactionResolvedError) { + return + } else if (hasError) { + const unexpectedError = prepare?.error + || transaction?.error?.['data']?.message || transaction?.error + || wait?.error + return + } + else return <> +} + +const PreparingTransactionMessage: FC = () => { + return +} + +const ConfirmTransactionMessage: FC = () => { + return +} + +const TransactionInProgressMessage: FC = () => { + return +} + +const InsufficientFundsMessage: FC = () => { + return +} + +const TransactionRejectedMessage: FC = () => { + return +} + +const UexpectedErrorMessage: FC<{ message: string }> = ({ message }) => { + return +} + +export default TransactionMessage \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/ZKsyncWalletWithdraw.tsx b/app/components/Swap/Withdraw/Wallet/ZKsyncWalletWithdraw.tsx new file mode 100644 index 00000000..78aa06fc --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/ZKsyncWalletWithdraw.tsx @@ -0,0 +1,142 @@ +import { Link, ArrowLeftRight } from 'lucide-react'; +import { FC, useCallback, useEffect, useState } from 'react' +import SubmitButton from '../../../buttons/submitButton'; +import toast from 'react-hot-toast'; +import * as zksync from 'zksync'; +import { utils } from 'ethers'; +import { useEthersSigner } from '../../../../lib/ethersToViem/ethers'; +import { useSwapTransactionStore } from '../../../../stores/swapTransactionStore'; +import { PublishedSwapTransactionStatus } from '../../../../lib/BridgeApiClient'; +import { useSwapDataState } from '../../../../context/swap'; +import { ChangeNetworkButton, ConnectWalletButton } from './WalletTransfer/buttons'; +import { useSettingsState } from '../../../../context/settings'; +import { useNetwork } from 'wagmi'; +import { TransactionReceipt } from 'zksync/build/types'; + +type Props = { + depositAddress: string, + amount: number +} + +const ZkSyncWalletWithdrawStep: FC = ({ depositAddress, amount }) => { + const [loading, setLoading] = useState(false); + const [transferDone, setTransferDone] = useState(); + const [syncWallet, setSyncWallet] = useState() + + const { setSwapTransaction } = useSwapTransactionStore(); + const { swap } = useSwapDataState(); + const signer = useEthersSigner(); + const { chain } = useNetwork(); + + const { networks, layers } = useSettingsState(); + const { source_network: source_network_internal_name } = swap || {}; + const source_network = networks.find(n => n.internal_name === source_network_internal_name); + const source_layer = layers.find(l => l.internal_name === source_network_internal_name) + const source_currency = source_network?.currencies?.find(c => c.asset.toLocaleUpperCase() === swap?.source_network_asset.toLocaleUpperCase()); + const defaultProvider = swap?.source_network?.split('_')?.[1]?.toLowerCase() == "mainnet" ? "mainnet" : "goerli"; + const l1Network = networks.find(n => n.internal_name === source_network?.metadata?.L1Network); + + useEffect(() => { + if (signer?._address !== syncWallet?.cachedAddress && source_layer) { + setSyncWallet(null) + } + }, [signer?._address]); + + const handleTransaction = async (swapId: string, publishedTransaction: TransactionReceipt, txHash: string) => { + if (publishedTransaction?.failReason) { + txHash && setSwapTransaction(swapId, PublishedSwapTransactionStatus.Error, txHash, publishedTransaction?.failReason); + toast(String(publishedTransaction.failReason)) + } + else { + txHash && setSwapTransaction(swapId, PublishedSwapTransactionStatus.Completed, txHash, publishedTransaction?.failReason); + setTransferDone(true) + } + }; + + const handleConnect = useCallback(async () => { + if (!signer) + return + setLoading(true) + try { + const syncProvider = await zksync.getDefaultProvider(defaultProvider); + const wallet = await zksync.Wallet.fromEthSigner(signer, syncProvider); + setSyncWallet(wallet) + } + catch (e) { + toast(e.message) + } + finally { + setLoading(false) + } + }, [signer, defaultProvider]) + + const handleTransfer = useCallback(async () => { + + if (!swap) return + + setLoading(true) + try { + const tf = await syncWallet?.syncTransfer({ + to: depositAddress, + token: swap?.source_network_asset, + amount: zksync.closestPackableTransactionAmount(utils.parseUnits(amount.toString(), source_currency?.decimals)), + validUntil: zksync.utils.MAX_TIMESTAMP - swap?.sequence_number, + }); + + const txHash = tf?.txHash?.replace('sync-tx:', '') + + if (txHash) { + const syncProvider = await zksync.getDefaultProvider(defaultProvider); + const txReceipt = await syncProvider.getTxReceipt(String(tf?.txHash)); + //TODO might be unnecessary why handleTransaction does not do this + if (!txReceipt.executed) + setSwapTransaction(swap?.id, PublishedSwapTransactionStatus.Pending, txHash); + else + handleTransaction(swap?.id, txReceipt, String(tf?.txHash)) + } + } + catch (e) { + if (e?.message) { + toast(e.message) + return + } + } + setLoading(false) + + }, [syncWallet, swap, depositAddress, source_currency, amount]) + + if (!signer) { + return + } + + if (l1Network && chain?.id !== Number(l1Network.chain_id)) { + return ( + + ) + } + + return ( + <> +
    +
    + { + !syncWallet && + + } + { + syncWallet && + + } +
    +
    + + ) +} +export default ZkSyncWalletWithdrawStep; \ No newline at end of file diff --git a/app/components/Swap/Withdraw/Wallet/index.tsx b/app/components/Swap/Withdraw/Wallet/index.tsx new file mode 100644 index 00000000..2da3cc7a --- /dev/null +++ b/app/components/Swap/Withdraw/Wallet/index.tsx @@ -0,0 +1,96 @@ +import { FC } from "react" +import { ApiResponse } from "../../../../Models/ApiResponse" +import { useSettingsState } from "../../../../context/settings" +import { useSwapDataState } from "../../../../context/swap" +import KnownInternalNames from "../../../../lib/knownIds" +import BridgeApiClient, { DepositAddress, DepositType, Fee } from "../../../../lib/BridgeApiClient" +import ImtblxWalletWithdrawStep from "./ImtblxWalletWithdrawStep" +import StarknetWalletWithdrawStep from "./StarknetWalletWithdraw" +import useSWR from 'swr' +import TransferFromWallet from "./WalletTransfer" +import ZkSyncWalletWithdrawStep from "./ZKsyncWalletWithdraw" +import { Layer } from "../../../../Models/Layer" +import useWalletTransferOptions from "../../../../hooks/useWalletTransferOptions" + +//TODO have separate components for evm and none_evm as others are sweepless anyway +const WalletTransfer: FC = () => { + const { swap } = useSwapDataState() + const { layers } = useSettingsState() + + const { source_network: source_network_internal_name, destination_network, destination_network_asset, source_network_asset } = swap || {} + const source_layer = layers.find(n => n.internal_name === source_network_internal_name) as (Layer & { isExchange: false }) + const destination = layers.find(n => n.internal_name === destination_network) + const sourceAsset = source_layer?.assets?.find(c => c.asset.toLowerCase() === swap?.source_network_asset.toLowerCase()) + + const sourceIsImmutableX = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.ImmutableXMainnet?.toUpperCase() || swap?.source_network === KnownInternalNames.Networks.ImmutableXGoerli?.toUpperCase() + const sourceIsZkSync = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.ZksyncMainnet?.toUpperCase() + const sourceIsStarknet = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.StarkNetMainnet?.toUpperCase() || swap?.source_network === KnownInternalNames.Networks.StarkNetGoerli?.toUpperCase() + + const { canDoSweepless, isContractWallet } = useWalletTransferOptions() + const shouldGetGeneratedAddress = isContractWallet?.ready && !canDoSweepless + const generateDepositParams = shouldGetGeneratedAddress ? [source_network_internal_name] : null + + const bridgeApiClient = new BridgeApiClient() + const { + data: generatedDeposit + } = useSWR>(generateDepositParams, ([network]) => bridgeApiClient.GenerateDepositAddress(network), { dedupingInterval: 60000 }) + + const managedDepositAddress = sourceAsset?.network?.managed_accounts?.[0]?.address; + const generatedDepositAddress = generatedDeposit?.data?.address + + const depositAddress = isContractWallet?.ready ? + (canDoSweepless ? managedDepositAddress : generatedDepositAddress) + : undefined + + const sourceChainId = (source_layer && source_layer.isExchange === false) ? Number(source_layer?.chain_id) : null + const feeParams = { + source: source_network_internal_name, + destination: destination?.internal_name, + source_asset: source_network_asset, + destination_asset: destination_network_asset, + refuel: swap?.has_refuel + } + + const { data: feeData } = useSWR>([feeParams], ([params]) => bridgeApiClient.GetFee(params), { dedupingInterval: 60000 }) + const walletTransferFee = isContractWallet?.ready ? + feeData?.data?.find(f => f?.deposit_type === (canDoSweepless ? DepositType.Wallet : DepositType.Manual)) + : undefined + + const requested_amount = Number(walletTransferFee?.min_amount) > Number(swap?.requested_amount) ? walletTransferFee?.min_amount : swap?.requested_amount + + if (sourceIsImmutableX) + return + + + else if (sourceIsStarknet) + return + + + else if (sourceIsZkSync) + return + {requested_amount && depositAddress && } + + else + return + {swap && source_layer && sourceAsset && requested_amount && sourceChainId && } + + +} + +const Wrapper: FC<{ children?: React.ReactNode }> = ({ children }) => { + return
    + {children} +
    +} + +export default WalletTransfer diff --git a/app/components/Swap/Withdraw/index.tsx b/app/components/Swap/Withdraw/index.tsx new file mode 100644 index 00000000..bfc950c6 --- /dev/null +++ b/app/components/Swap/Withdraw/index.tsx @@ -0,0 +1,266 @@ +import { AlignLeft, X } from 'lucide-react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import WalletTransfer from './Wallet'; +import ManualTransfer from './ManualTransfer'; +import FiatTransfer from './FiatTransfer'; +import { useSettingsState } from '../../../context/settings'; +import { useSwapDataState, useSwapDataUpdate } from '../../../context/swap'; +import KnownInternalNames from '../../../lib/knownIds'; +import { Tab, TabHeader } from '../../Tabs/Index'; +import SwapSummary from '../Summary'; +import Coinbase from './Coinbase'; +import External from './External'; +import { WithdrawType } from '../../../lib/BridgeApiClient'; +import WalletIcon from '../../icons/WalletIcon'; +import shortenAddress, { shortenEmail } from '../../utils/ShortenAddress'; +import { useAccountModal } from '@rainbow-me/rainbowkit'; +import { GetDefaultNetwork } from '../../../helpers/settingsHelper'; +import Image from 'next/image'; +import SpinIcon from '../../icons/spinIcon'; +import { NetworkType } from '../../../Models/CryptoNetwork'; +import useWallet from '../../../hooks/useWallet'; +import { useQueryState } from '../../../context/query'; +import { Widget } from '../../Widget/Index'; + +const Withdraw: FC = () => { + const { swap } = useSwapDataState() + const { setWithdrawType } = useSwapDataUpdate() + const { layers } = useSettingsState() + const { appName, signature } = useQueryState() + const source_internal_name = swap?.source_exchange ?? swap?.source_network + const source = layers.find(n => n.internal_name === source_internal_name) + + let isFiat = source?.isExchange && source?.type === "fiat" + const sourceIsStarknet = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.StarkNetMainnet?.toUpperCase() + || swap?.source_network === KnownInternalNames.Networks.StarkNetGoerli?.toUpperCase() + const sourceIsImmutableX = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.ImmutableXMainnet?.toUpperCase() + || swap?.source_network === KnownInternalNames.Networks.ImmutableXGoerli?.toUpperCase() + const sourceIsZkSync = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.ZksyncMainnet?.toUpperCase() + const sourceIsArbitrumOne = swap?.source_network?.toUpperCase() === KnownInternalNames.Networks.ArbitrumMainnet?.toUpperCase() + || swap?.source_network === KnownInternalNames.Networks.ArbitrumGoerli?.toUpperCase() + const sourceIsCoinbase = swap?.source_exchange?.toUpperCase() === KnownInternalNames.Exchanges.Coinbase?.toUpperCase() + + const source_layer = layers.find(n => n.internal_name === swap?.source_network) + const sourceNetworkType = GetDefaultNetwork(source_layer, swap?.source_network_asset)?.type + const manualIsAvailable = !(sourceIsStarknet || sourceIsImmutableX || isFiat) + const walletIsAvailable = !isFiat + && !swap?.source_exchange + && (sourceNetworkType === NetworkType.EVM + || sourceNetworkType === NetworkType.Starknet + || sourceIsImmutableX || sourceIsZkSync) + + const isImtblMarketplace = (signature && appName === "imxMarketplace" && sourceIsImmutableX) + const sourceIsSynquote = appName === "ea7df14a1597407f9f755f05e25bab42" && sourceIsArbitrumOne + + let tabs: Tab[] = [] + // TODO refactor + if (isImtblMarketplace || sourceIsSynquote) { + tabs = [{ + id: WithdrawType.External, + label: "Withdrawal pending", + enabled: true, + icon: , + content: + }] + } + else if (isFiat) { + tabs = [{ + id: WithdrawType.Stripe, + label: "Stripe", + enabled: true, + icon: , + content: + }] + } + else if (sourceIsStarknet || sourceIsImmutableX) { + tabs = [ + { + id: WithdrawType.Wallet, + label: "Via wallet", + enabled: true, + icon: , + content: , + footer: + }] + } + else { + tabs = [ + { + id: WithdrawType.Wallet, + label: "Via wallet", + enabled: walletIsAvailable, + icon: , + content: , + footer: + }, + { + id: WithdrawType.Coinbase, + label: "Automatically", + enabled: sourceIsCoinbase, + icon: , + content: , + footer: + }, + { + id: WithdrawType.Manually, + label: "Manually", + enabled: manualIsAvailable, + icon: , + footer: , + content: <> + } + ]; + } + const [activeTabId, setActiveTabId] = useState(tabs.find(t => t.enabled)?.id); + + const activeTab = tabs.find(t => t.id === activeTabId) + const showTabsHeader = tabs?.filter(t => t.enabled)?.length > 1 + + useEffect(() => { + activeTab && setWithdrawType(activeTab.id) + }, [activeTab]) + + return ( + <> + +
    +
    + { + !isFiat && +
    + +
    + } + + + { + showTabsHeader && + <> +
    Choose how you'd like to complete the swap
    +
    + {activeTabId && tabs.filter(t => t.enabled).map((tab) => ( + + ))} +
    + + } +
    + + {activeTab?.content} + +
    +
    +
    + { + activeTab?.footer && + + {activeTab?.footer} + + } + + ) +} + +const WalletTransferContent: FC = () => { + const { openAccountModal } = useAccountModal(); + const { getWithdrawalProvider: getProvider, disconnectWallet } = useWallet() + const { layers, resolveImgSrc } = useSettingsState() + const { swap } = useSwapDataState() + const [isLoading, setIsloading] = useState(false); + const { mutateSwap } = useSwapDataUpdate() + + const { + source_network: source_network_internal_name, + source_exchange: source_exchange_internal_name, + source_network_asset } = swap || {} + + const source_network = layers.find(n => n.internal_name === source_network_internal_name) + const source_exchange = layers.find(n => n.internal_name === source_exchange_internal_name) + const source_layer = layers.find(n => n.internal_name === swap?.source_network) + + const sourceNetworkType = GetDefaultNetwork(source_network, source_network_asset)?.type + const provider = useMemo(() => { + return source_layer && getProvider(source_layer) + }, [source_layer, getProvider]) + + const wallet = provider?.getConnectedWallet() + + const handleDisconnect = useCallback(async (e: React.MouseEvent) => { + if (!wallet) return + setIsloading(true); + await disconnectWallet(wallet.providerName, swap) + if (source_exchange) await mutateSwap() + setIsloading(false); + e?.stopPropagation(); + }, [sourceNetworkType, swap?.source_exchange, disconnectWallet]) + + let accountAddress: string | undefined = "" + if (swap?.source_exchange) { + accountAddress = swap.exchange_account_name || "" + } + else if (wallet) { + accountAddress = wallet.address || ""; + } + + const canOpenAccount = sourceNetworkType === NetworkType.EVM && !swap?.source_exchange + + const handleOpenAccount = useCallback(() => { + if (canOpenAccount && openAccountModal) + openAccountModal() + }, [canOpenAccount, openAccountModal]) + + if (!accountAddress || (swap?.source_exchange && !swap.exchange_account_connected)) { + return <> +
    + +
    + + } + + return
    + { + {swap?.source_exchange ? "Connected account" : "Connected wallet"} + } + +
    +
    + { + !swap?.source_exchange + && wallet?.connector + && + } + { + source_exchange + && {accountAddress} + } +
    +
    +
    + {!swap?.source_exchange && + {shortenAddress(accountAddress)} + } + {swap?.source_exchange && + {shortenEmail(swap?.exchange_account_name)} + } +
    +
    +
    + {isLoading ? : } +
    +
    +
    +} + +export default Withdraw \ No newline at end of file diff --git a/app/components/Swap/index.tsx b/app/components/Swap/index.tsx new file mode 100644 index 00000000..054e3986 --- /dev/null +++ b/app/components/Swap/index.tsx @@ -0,0 +1,71 @@ +import { FC } from 'react' +import { Widget } from '../Widget/Index'; +import { useSwapDataState } from '../../context/swap'; +import Withdraw from './Withdraw'; +import Processing from './Withdraw/Processing'; +import { PublishedSwapTransactionStatus, TransactionType } from '../../lib/BridgeApiClient'; +import { SwapStatus } from '../../Models/SwapStatus'; +import GasDetails from '../gasDetails'; +import { useSettingsState } from '../../context/settings'; + +type Props = { + type: "widget" | "contained", +} +import { useSwapTransactionStore } from '../../stores/swapTransactionStore'; + +const SwapDetails: FC = ({ type }) => { + const { swap } = useSwapDataState() + const settings = useSettingsState() + const swapStatus = swap?.status; + const storedWalletTransactions = useSwapTransactionStore() + + const swapInputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Input) + const storedWalletTransaction = storedWalletTransactions.swapTransactions?.[swap?.id || ''] + + const sourceNetwork = settings.layers.find(l => l.internal_name === swap?.source_network) + const currency = settings.currencies.find(c => c.asset === swap?.source_network_asset) + + if (!swap) return <> +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + return ( + <> + + { + ((swapStatus === SwapStatus.UserTransferPending + && !(swapInputTransaction || (storedWalletTransaction && storedWalletTransaction.status !== PublishedSwapTransactionStatus.Error)))) ? + : + } + + { + process.env.NEXT_PUBLIC_SHOW_GAS_DETAILS === 'true' + && sourceNetwork + && currency && + + } + + ) +} + +const Container = ({ type, children }: Props & { + children: JSX.Element | JSX.Element[] +}) => { + if (type === "widget") + return <>{children} + else + return
    + {children} +
    + +} + +export default SwapDetails \ No newline at end of file diff --git a/app/components/SwapGuide.tsx b/app/components/SwapGuide.tsx new file mode 100644 index 00000000..5bdbe0ab --- /dev/null +++ b/app/components/SwapGuide.tsx @@ -0,0 +1,40 @@ +import Image from 'next/image' +import firstGuidePic from './../public/images/withdrawGuideImages/01.png' +import secondNetworkGuidePic from './../public/images/withdrawGuideImages/02Network.png' +import secondExchangeGuideGuidePic from './../public/images/withdrawGuideImages/02Exchange.png' +import thirdGuidePic from './../public/images/withdrawGuideImages/03.png' +import { SwapItem } from '../lib/BridgeApiClient' + +const SwapGuide = ({ swap }: { swap: SwapItem }) => { + return ( +
    +

    To complete the swap, manually send assets from your wallet, to the Deposit Address generated by Bridge.

    +
    +

    🪜 Steps

    +
    +
    +

    .01 Copy the Deposit Address, or scan the QR code

    +
    + {''} +
    +
    +
    +

    .02 Send {swap?.destination_network_asset} to that address from your {swap?.source_exchange ? 'exchange account' : 'wallet'}

    +
    + {''} +
    +
    + {swap?.source_exchange &&
    +

    .03 Make sure to send via one of the supported networks

    +
    + {''} +
    +
    } +
    +
    + +
    + ) +} + +export default SwapGuide \ No newline at end of file diff --git a/app/components/SwapHistory/StatusIcons.tsx b/app/components/SwapHistory/StatusIcons.tsx new file mode 100644 index 00000000..53c43040 --- /dev/null +++ b/app/components/SwapHistory/StatusIcons.tsx @@ -0,0 +1,127 @@ +import { SwapStatus } from "../../Models/SwapStatus" +import { PublishedSwapTransactions, SwapItem, TransactionType } from "../../lib/BridgeApiClient" + +export default function StatusIcon({ swap, short }: { swap: SwapItem, short?: boolean }) { + const status = swap.status; + switch (status) { + case SwapStatus.Failed: + return ( + <> +
    + + { + !short &&

    Failed

    + } +
    + ) + case SwapStatus.Completed: + return ( + <> +
    + + {!short &&

    Completed

    } +
    + + ) + case SwapStatus.Cancelled: + return ( + <> +
    + + {!short &&

    Cancelled

    } +
    + ) + case SwapStatus.Expired: + return ( + <> +
    + + {!short &&

    Expired

    } +
    + ) + case SwapStatus.UserTransferPending: + const data: PublishedSwapTransactions = JSON.parse(localStorage.getItem('swapTransactions') || "{}") + const txForSwap = data?.[swap.id]; + if (txForSwap || swap.transactions.find(t => t.type === TransactionType.Input)) { + return <> +
    + + {!short &&

    Processing

    } +
    + + } + else { + return <> +
    + + {!short &&

    Pending

    } +
    + + } + case SwapStatus.LsTransferPending: + return <> +
    + + {!short &&

    Processing

    } +
    + + case SwapStatus.UserTransferDelayed: + return <> +
    + + {!short &&

    Delayed

    } +
    + + case SwapStatus.Created: + return ( + <> +
    + + {!short &&

    Created

    } +
    + ) + default: + return <> + } +} + + +export const RedIcon = () => { + return ( + + + + ) +} + +export const GreenIcon = () => { + return ( + + + + ) +} + +export const YellowIcon = () => { + return ( + + + + ) +} + +export const GreyIcon = () => { + return ( + + + + ) +} + +export const PurpleIcon = () => { + return ( + + + + ) +} \ No newline at end of file diff --git a/app/components/SwapHistory/SwapDetailsComponent.tsx b/app/components/SwapHistory/SwapDetailsComponent.tsx new file mode 100644 index 00000000..3e2ceace --- /dev/null +++ b/app/components/SwapHistory/SwapDetailsComponent.tsx @@ -0,0 +1,239 @@ +import { useRouter } from 'next/router'; +import { FC, useEffect, useState } from 'react' +import { useSettingsState } from '../../context/settings'; +import BridgeApiClient, { SwapItem, TransactionType } from '../../lib/BridgeApiClient'; +import Image from 'next/image' +import toast from 'react-hot-toast'; +import shortenAddress from '../utils/ShortenAddress'; +import CopyButton from '../buttons/copyButton'; +import { SwapDetailsComponentSceleton } from '../Sceletons'; +import StatusIcon from './StatusIcons'; +import { ExternalLink } from 'lucide-react'; +import isGuid from '../utils/isGuid'; +import KnownInternalNames from '../../lib/knownIds'; + +type Props = { + id: string +} + +const SwapDetails: FC = ({ id }) => { + const [swap, setSwap] = useState() + const [loading, setLoading] = useState(false) + const router = useRouter(); + const settings = useSettingsState() + const { currencies, exchanges, networks, resolveImgSrc } = settings + + const { source_exchange: source_exchange_internal_name, + destination_network: destination_network_internal_name, + source_network: source_network_internal_name, + destination_exchange: destination_exchange_internal_name, + source_network_asset + } = swap || {} + + const source = source_exchange_internal_name ? exchanges.find(e => e.internal_name === source_exchange_internal_name) : networks.find(e => e.internal_name === source_network_internal_name) + const destination_exchange = destination_exchange_internal_name ? exchanges.find(e => e.internal_name === destination_exchange_internal_name) : null + const exchange_currency = destination_exchange_internal_name ? destination_exchange?.currencies?.find(c => swap?.source_network_asset?.toUpperCase() === c?.asset?.toUpperCase() && c?.is_default) : null + + const destination_network = destination_network_internal_name ? networks.find(n => n.internal_name === destination_network_internal_name) : networks?.find(e => e?.internal_name?.toUpperCase() === exchange_currency?.network?.toUpperCase()) + + const destination = destination_exchange_internal_name ? destination_exchange : networks.find(n => n.internal_name === destination_network_internal_name) + + const currency = currencies.find(c => c.asset === source_network_asset) + + const source_network = networks?.find(e => e.internal_name === source_network_internal_name) + const input_tx_id = source_network?.transaction_explorer_template + const swapInputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Input) + const swapOutputTransaction = swap?.transactions?.find(t => t.type === TransactionType.Output) + + useEffect(() => { + (async () => { + if (!id) + return + setLoading(true) + try { + const bridgeApiClient = new BridgeApiClient(router) + const swapResponse = await bridgeApiClient.GetSwapDetailsAsync(id) + setSwap(swapResponse.data) + } + catch (e) { + toast.error(e.message) + } + finally { + setLoading(false) + } + })() + }, [id, router.query]) + + if (loading) + return + + return ( + <> +
    +
    +
    +
    + Id + +
    + { + swap && + {shortenAddress(swap?.id)} + + } +
    +
    +
    +
    +
    + Status + + {swap && } + +
    +
    +
    + Date + {swap && {(new Date(swap.created_date)).toLocaleString()}} +
    +
    +
    + From + { + source &&
    +
    + { + Exchange Logo + } + +
    +
    {source?.display_name}
    +
    + } +
    +
    +
    + To + { + destination &&
    +
    + { + Exchange Logo + } +
    +
    {destination?.display_name}
    +
    + } +
    +
    +
    + Address + +
    + {swap && + {swap?.destination_address.slice(0, 8) + "..." + swap?.destination_address.slice(swap?.destination_address.length - 5, swap?.destination_address.length)} + } +
    +
    +
    + {swapInputTransaction?.transaction_id && + <> +
    + + + } + {swapOutputTransaction?.transaction_id && + <> +
    +
    + Destination Tx + +
    +
    + {(swapOutputTransaction?.transaction_id && swap?.destination_exchange === KnownInternalNames.Exchanges.Coinbase && (isGuid(swapOutputTransaction?.transaction_id))) ? + {shortenAddress(swapOutputTransaction.transaction_id)} + : + + } +
    +
    +
    +
    + + } +
    +
    + Requested amount + + {swap?.requested_amount} {swap?.destination_network_asset} + +
    + { + swapInputTransaction && + <> +
    +
    + Transfered amount + + {swapInputTransaction?.amount} {swap?.destination_network_asset} + +
    + + } + { + swapOutputTransaction && + <> +
    +
    + Bridge Fee + {swap?.fee} {currency?.asset} +
    + + } + { + swapOutputTransaction && + <> +
    +
    + Amount You Received + + {swapOutputTransaction?.amount} {currency?.asset} + +
    + + } +
    +
    +
    + + ) +} + +export default SwapDetails; diff --git a/app/components/SwapHistory/TransfersWrapper.tsx b/app/components/SwapHistory/TransfersWrapper.tsx new file mode 100644 index 00000000..0a2d4b5e --- /dev/null +++ b/app/components/SwapHistory/TransfersWrapper.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +import TransactionsHistory from "."; +import { useAuthState, UserType } from "../../context/authContext"; +import { FormWizardProvider } from "../../context/formWizardProvider"; +import { TimerProvider } from "../../context/timerContext"; +import { AuthStep } from "../../Models/Wizard"; +import GuestCard from "../guestCard"; + +const TransfersWrapper: FC = () => { + const { userType } = useAuthState() + + return ( +
    + + { + userType && userType != UserType.AuthenticatedUser && + + + + + + } +
    + ) +}; + +export default TransfersWrapper; \ No newline at end of file diff --git a/app/components/SwapHistory/index.tsx b/app/components/SwapHistory/index.tsx new file mode 100644 index 00000000..be873415 --- /dev/null +++ b/app/components/SwapHistory/index.tsx @@ -0,0 +1,337 @@ +import { useRouter } from "next/router" +import { useCallback, useEffect, useState } from "react" +import BridgeApiClient, { SwapItem, SwapStatusInNumbers, TransactionType } from "../../lib/BridgeApiClient" +import SpinIcon from "../icons/spinIcon" +import { ArrowRight, ChevronRight, ExternalLink, Eye, RefreshCcw, Scroll, X } from 'lucide-react'; +import SwapDetails from "./SwapDetailsComponent" +import { useSettingsState } from "../../context/settings" +import Image from 'next/image' +import { classNames } from "../utils/classNames" +import SubmitButton, { DoubleLineText } from "../buttons/submitButton" +import { SwapHistoryComponentSceleton } from "../Sceletons" +import StatusIcon, { } from "./StatusIcons" +import toast from "react-hot-toast" +import { SwapStatus } from "../../Models/SwapStatus" +import ToggleButton from "../buttons/toggleButton"; +import Modal from "../modal/modal"; +import HeaderWithMenu from "../HeaderWithMenu"; +import Link from "next/link"; +import { resolvePersistantQueryParams } from "../../helpers/querryHelper"; +import AppSettings from "../../lib/AppSettings"; +import { truncateDecimals } from "../utils/RoundDecimals"; + +function TransactionsHistory() { + const [page, setPage] = useState(0) + const settings = useSettingsState() + const { exchanges, networks, currencies, resolveImgSrc } = settings + const [isLastPage, setIsLastPage] = useState(false) + const [swaps, setSwaps] = useState() + const [loading, setLoading] = useState(false) + const router = useRouter(); + const [selectedSwap, setSelectedSwap] = useState() + const [openSwapDetailsModal, setOpenSwapDetailsModal] = useState(false) + const [showAllSwaps, setShowAllSwaps] = useState(false) + const [showToggleButton, setShowToggleButton] = useState(false) + + const PAGE_SIZE = 20 + + const goBack = useCallback(() => { + window?.['navigation']?.['canGoBack'] ? + router.back() + : router.push({ + pathname: "/", + query: resolvePersistantQueryParams(router.query) + }) + }, [router]) + + + useEffect(() => { + (async () => { + const bridgeApiClient = new BridgeApiClient(router, '/transactions') + const { data } = await bridgeApiClient.GetSwapsAsync(1, SwapStatusInNumbers.Cancelled) + if (Number(data?.length) > 0) setShowToggleButton(true) + })() + }, []) + + useEffect(() => { + (async () => { + setIsLastPage(false) + setLoading(true) + const bridgeApiClient = new BridgeApiClient(router, '/transactions') + + if (showAllSwaps) { + const { data, error } = await bridgeApiClient.GetSwapsAsync(1) + + if (error) { + toast.error(error.message); + return; + } + + setSwaps(data) + setPage(1) + if (Number(data?.length) < PAGE_SIZE) + setIsLastPage(true) + + setLoading(false) + + } else { + + const { data, error } = await bridgeApiClient.GetSwapsAsync(1, SwapStatusInNumbers.SwapsWithoutCancelledAndExpired) + + if (error) { + toast.error(error.message); + return; + } + + setSwaps(data) + setPage(1) + if (Number(data?.length) < PAGE_SIZE) + setIsLastPage(true) + setLoading(false) + } + })() + }, [router.query, showAllSwaps]) + + const handleLoadMore = useCallback(async () => { + //TODO refactor page change + const nextPage = page + 1 + setLoading(true) + const bridgeApiClient = new BridgeApiClient(router, '/transactions') + if (showAllSwaps) { + const { data, error } = await bridgeApiClient.GetSwapsAsync(nextPage) + + if (error) { + toast.error(error.message); + return; + } + + setSwaps(old => [...(old ? old : []), ...(data ? data : [])]) + setPage(nextPage) + if (Number(data?.length) < PAGE_SIZE) + setIsLastPage(true) + + setLoading(false) + } else { + const { data, error } = await bridgeApiClient.GetSwapsAsync(nextPage, SwapStatusInNumbers.SwapsWithoutCancelledAndExpired) + + if (error) { + toast.error(error.message); + return; + } + + setSwaps(old => [...(old ? old : []), ...(data ? data : [])]) + setPage(nextPage) + if (Number(data?.length) < PAGE_SIZE) + setIsLastPage(true) + + setLoading(false) + } + }, [page, setSwaps]) + + const handleopenSwapDetails = (swap: SwapItem) => { + setSelectedSwap(swap) + setOpenSwapDetailsModal(true) + } + + const handleToggleChange = (value: boolean) => { + setShowAllSwaps(value); + } + + return ( +
    + + { + page == 0 && loading ? + + : <> + { + Number(swaps?.length) > 0 ? +
    +
    + {showToggleButton &&
    +
    +

    + Show all swaps +

    + +
    +
    } +
    + + + + + + + + + + {swaps?.map((swap, index) => { + + const { source_exchange: source_exchange_internal_name, + destination_network: destination_network_internal_name, + source_network: source_network_internal_name, + destination_exchange: destination_exchange_internal_name, + source_network_asset + } = swap + + const source = source_exchange_internal_name ? exchanges.find(e => e.internal_name === source_exchange_internal_name) : networks.find(e => e.internal_name === source_network_internal_name) + const source_currency = currencies?.find(c => c.asset === source_network_asset) + const destination_exchange = destination_exchange_internal_name && exchanges.find(e => e.internal_name === destination_exchange_internal_name) + const destination = destination_exchange_internal_name ? destination_exchange : networks.find(n => n.internal_name === destination_network_internal_name) + const output_transaction = swap.transactions.find(t => t.type === TransactionType.Output) + return handleopenSwapDetails(swap)} key={swap.id}> + + + + + + })} + +
    +
    + Swap details +
    +
    + Status + + Amount +
    +
    +
    + {source && + From Logo + } +
    + +
    + {destination && + To Logo + } +
    +
    + {index !== 0 ?
    : null} + +
    + + {swap && } + + +
    { handleopenSwapDetails(swap); e.preventDefault() }}> +
    + { + swap?.status == 'completed' ? + + {output_transaction ? truncateDecimals(output_transaction?.amount, source_currency?.precision) : '-'} + + : + + {truncateDecimals(swap.requested_amount, source_currency?.precision)} + + } + {swap.destination_network_asset} +
    + +
    +
    +
    +
    +
    + { + !isLastPage && + + } +
    + +
    + { + selectedSwap && + } + { + selectedSwap && +
    +
    + router.push({ + pathname: `/swap/${selectedSwap.id}`, + query: resolvePersistantQueryParams(router.query) + })} + isDisabled={false} + isSubmitting={false} + icon={ + + } + > + View swap + +
    +
    + } +
    +
    +
    + : +
    + +

    It's empty here

    +

    You can find all your transactions by searching with address in

    + + Bridge Explorer + +
    + } + + } +
    +
    + ) +} + +export default TransactionsHistory; diff --git a/app/components/SwapWithdrawal.tsx b/app/components/SwapWithdrawal.tsx new file mode 100644 index 00000000..71575ba6 --- /dev/null +++ b/app/components/SwapWithdrawal.tsx @@ -0,0 +1,33 @@ +import { FC, useEffect } from "react"; +import { useSwapDataState, useSwapDataUpdate } from "../context/swap"; +import SwapDetails from "./Swap"; +import { Widget } from "./Widget/Index"; +import NotFound from "./Swap/NotFound"; +import { BalancesDataProvider } from "../context/balances"; + +const SwapWithdrawal: FC = () => { + const { swap, swapApiError } = useSwapDataState() + const { mutateSwap } = useSwapDataUpdate() + + useEffect(() => { + mutateSwap() + }, []) + + if (!swap) + return +
    + {swapApiError && + + } +
    +
    + + + return ( + + + + ) +}; + +export default SwapWithdrawal; \ No newline at end of file diff --git a/app/components/Tabs/Header.tsx b/app/components/Tabs/Header.tsx new file mode 100644 index 00000000..2e3df5b8 --- /dev/null +++ b/app/components/Tabs/Header.tsx @@ -0,0 +1,38 @@ +import { motion } from "framer-motion" +import { FC } from "react" +import { Tab } from "./Index" +import { WithdrawType } from "../../lib/BridgeApiClient" + +type HeaderProps = { + tab: Tab, + activeTabId: string, + onCLick: (id: WithdrawType) => void +} + +const Header: FC = ({ tab, onCLick, activeTabId }) => { + return +} + +export default Header \ No newline at end of file diff --git a/app/components/Tabs/Index.tsx b/app/components/Tabs/Index.tsx new file mode 100644 index 00000000..8832a9b1 --- /dev/null +++ b/app/components/Tabs/Index.tsx @@ -0,0 +1,12 @@ +import { WithdrawType } from '../../lib/BridgeApiClient'; + +export {default as TabHeader} from './Header'; + +export type Tab = { + id: WithdrawType, + enabled: boolean, + label: string, + icon: JSX.Element | JSX.Element[], + content: JSX.Element | JSX.Element[], + footer?: JSX.Element | JSX.Element[], +} \ No newline at end of file diff --git a/app/components/TimerComponent.tsx b/app/components/TimerComponent.tsx new file mode 100644 index 00000000..a4a51518 --- /dev/null +++ b/app/components/TimerComponent.tsx @@ -0,0 +1,33 @@ +import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle, FC, SetStateAction, Dispatch } from 'react' +import { useTimerState } from '../context/timerContext'; + +type TimerProps = { + seconds?: number + isStarted?: boolean; + setIsStarted?: Dispatch>, + waitingComponent: (remainigTime: string) => JSX.Element | JSX.Element[] + children: JSX.Element | JSX.Element[] +} + +const TimerWithContext: FC = (({ isStarted, waitingComponent, children }, ref) => { + const { secondsRemaining, started } = useTimerState() + const twoDigits = (num) => String(num).padStart(2, '0') + + const secondsToDisplay = Number(secondsRemaining) % 60 + const minutesRemaining = (Number(secondsRemaining) - secondsToDisplay) / 60 + const minutesToDisplay = minutesRemaining % 60 + + return ( + + { + started ? + waitingComponent(`${twoDigits(minutesToDisplay)}:${twoDigits(secondsToDisplay)}`) + : + children + } + + ) +}) + + +export default TimerWithContext \ No newline at end of file diff --git a/app/components/TonConnectProvider.tsx b/app/components/TonConnectProvider.tsx new file mode 100644 index 00000000..df3cead2 --- /dev/null +++ b/app/components/TonConnectProvider.tsx @@ -0,0 +1,63 @@ +import { THEME, TonConnectUIProvider } from "@tonconnect/ui-react" +import { ThemeData } from "../Models/Theme"; + +const TonConnectProvider = ({ children, basePath, themeData }: { children: JSX.Element | JSX.Element[], basePath: string, themeData: ThemeData }) => { + + const rgbToHex = (rgb: string) => { + const rgbArray = rgb.match(/\d+/g) + function componentToHex(c: number) { + var hex = c?.toString(16); + return hex.length == 1 ? "0" + hex : hex; + } + + if (!rgbArray) return + + return "#" + componentToHex(Number(rgbArray[0])) + componentToHex(Number(rgbArray[1])) + componentToHex(Number(rgbArray[2])); + } + + return ( + + {children} + + ) +} + +export default TonConnectProvider \ No newline at end of file diff --git a/app/components/Tooltips/ClickTooltip.tsx b/app/components/Tooltips/ClickTooltip.tsx new file mode 100644 index 00000000..5ee22e22 --- /dev/null +++ b/app/components/Tooltips/ClickTooltip.tsx @@ -0,0 +1,22 @@ +import { Info } from 'lucide-react'; +import { FC } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from '../shadcn/popover'; + +type Props = { + children?: JSX.Element | JSX.Element[], + text: string | JSX.Element | JSX.Element[]; + moreClassNames?: string +} + +const ClickTooltip: FC = (({ children, text, moreClassNames }) => { + return ( + + + + {text} + + ) +}) + +export default ClickTooltip \ No newline at end of file diff --git a/app/components/VerifyEmailCode.tsx b/app/components/VerifyEmailCode.tsx new file mode 100644 index 00000000..d93dd1cb --- /dev/null +++ b/app/components/VerifyEmailCode.tsx @@ -0,0 +1,234 @@ +import { ChevronDown, MailOpen } from 'lucide-react'; +import { Form, Formik, FormikErrors } from 'formik'; +import { FC, useCallback, useState } from 'react' +import toast from 'react-hot-toast'; +import { useAuthDataUpdate, useAuthState, UserType } from '../context/authContext'; +import { useTimerState } from '../context/timerContext'; +import BridgeApiClient from '../lib/BridgeApiClient'; +import BridgeAuthApiClient from '../lib/userAuthApiClient'; +import { AuthConnectResponse } from '../Models/BridgeAuth'; +import SubmitButton from './buttons/submitButton'; +import { DocIframe } from './docInIframe'; +import NumericInput from './Input/NumericInput'; +import Modal from './modal/modal'; +import TimerWithContext from './TimerComponent'; +import { classNames } from './utils/classNames'; +import { Widget } from './Widget/Index'; +interface VerifyEmailCodeProps { + onSuccessfullVerify: (authresponse: AuthConnectResponse) => Promise; + disclosureLogin?: boolean +} + +interface CodeFormValues { + Code: string +} + +const VerifyEmailCode: FC = ({ onSuccessfullVerify, disclosureLogin }) => { + const initialValues: CodeFormValues = { Code: '' } + const { start: startTimer, started } = useTimerState() + const { tempEmail, userLockedOut, guestAuthData, userType } = useAuthState(); + const { updateAuthData, setUserLockedOut } = useAuthDataUpdate() + const [modalUrl, setModalUrl] = useState(null); + const [showDocModal, setShowDocModal] = useState(false) + + const handleResendCode = useCallback(async () => { + try { + const apiClient = new BridgeAuthApiClient(); + const res = await apiClient.getCodeAsync(tempEmail) + const next = new Date(res?.data?.next) + const now = new Date() + const miliseconds = next.getTime() - now.getTime() + startTimer(Math.round((res?.data?.already_sent ? 60000 : miliseconds) / 1000)) + } + catch (error) { + if (error.response?.data?.errors?.length > 0) { + const message = error.response.data.errors.map(e => e.message).join(", ") + toast.error(message) + } + else { + toast.error(error.message) + } + } + }, [tempEmail]) + + const openDoc = (url: string) => { + setModalUrl(url) + setShowDocModal(true) + } + + const handleOpenTerms = () => openDoc('https://docs.bridge.lux.network/user-docs/information/terms-of-services') + const handleOpenPrivacyPolicy = () => openDoc('https://docs.bridge.lux.network/user-docs/information/privacy-policy') + + const timerCountdown = userLockedOut ? 600 : 60 + + return (<> + + {modalUrl ? close()} URl={modalUrl} /> : <>} + + { + const errors: FormikErrors = {}; + if (!/^[0-9]*$/.test(values.Code)) { + errors.Code = "Value should be numeric"; + } + else if (values.Code.length != 6) { + errors.Code = `The length should be 6 instead of ${values.Code.length}`; + } + return errors; + }} + onSubmit={async (values: CodeFormValues) => { + try { + if (!tempEmail) { + //TODO show validation error + return + } + var apiAuthClient = new BridgeAuthApiClient(); + var apiClient = new BridgeApiClient() + const res = await apiAuthClient.connectAsync(tempEmail, values.Code) + updateAuthData(res) + await onSuccessfullVerify(res); + if (userType == UserType.GuestUser && guestAuthData?.access_token) await apiClient.SwapsMigration(guestAuthData?.access_token) + } + catch (error) { + const message = error.response.data.error_description + if (error.response?.data?.error === 'USER_LOCKED_OUT_ERROR') { + toast.error(message) + setUserLockedOut(true) + startTimer(600) + } + else if (error.response?.data?.error_description) { + toast.error(message) + } + else { + toast.error(error.message) + } + } + }} + > + {({ isValid, isSubmitting, errors, handleChange }) => ( +
    + { + disclosureLogin ? +
    +
    +
    +

    + Sign in with email +

    +
    +

    + Please enter the 6 digit code sent to {tempEmail} +

    +
    +
    +
    +
    + { + /^[0-9]*$/.test(e.target.value) && handleChange(e) + }} + className="leading-none h-12 text-2xl pl-5 text-primary-text focus:ring-primary text-center focus:border-primary border-secondary-500 block + placeholder:text-2xl placeholder:text-center tracking-widest placeholder:font-normal placeholder:opacity-50 bg-secondary-700 w-full font-semibold rounded-md placeholder-primary-text" + /> +
    +
    + ( + + {userLockedOut ? 'User is locked out' : 'Confirm'} + + )}> + + Confirm + + +
    +
    + + ( + + Resend in + + {remainingTime} + + + )}> + + Resend code + + + +
    +
    + : + + + +
    +

    Please enter the 6 digit code sent to {tempEmail}

    +
    +
    + { + /^[0-9]*$/.test(e.target.value) && handleChange(e) + }} + className="leading-none h-12 text-2xl pl-5 text-primary-text focus:ring-primary text-center focus:border-primary border-secondary-500 block + placeholder:text-2xl placeholder:text-center tracking-widest placeholder:font-normal placeholder:opacity-50 bg-secondary-700 w-full font-semibold rounded-md placeholder-primary-text" + /> + + ( + + Resend in + + {remainingTime} + + + )}> + + Resend code + + + +
    +
    + +

    + By clicking Confirm you agree to Bridge's  Terms of Service +  and  + Privacy Policy + +

    + ( + + {userLockedOut ? 'User is locked out' : 'Confirm'} + + )}> + + Confirm + + +
    +
    + } +
    + )} +
    + + + ); +} + +export default VerifyEmailCode; \ No newline at end of file diff --git a/app/components/WarningMessage.tsx b/app/components/WarningMessage.tsx new file mode 100644 index 00000000..2785a1e2 --- /dev/null +++ b/app/components/WarningMessage.tsx @@ -0,0 +1,45 @@ +import { AlertOctagon, Scroll } from "lucide-react"; +import { FC } from "react"; + +type messageType = 'warning' | 'informing' + +type Props = { + children: JSX.Element | JSX.Element[] | string; + messageType?: messageType; + className?: string +} + +function constructIcons(messageType: messageType) { + + let iconStyle: JSX.Element + + switch (messageType) { + case 'warning': + iconStyle = ; + break; + case 'informing': + iconStyle = ; + break; + } + return iconStyle +} + +const WarningMessage: FC = (({ children, className, messageType = 'warning' }) => { + return ( +
    +
    + +
    + + {constructIcons(messageType)} + + {children} +
    +
    +
    + ) +}) + +export default WarningMessage; \ No newline at end of file diff --git a/app/components/Widget/Content.tsx b/app/components/Widget/Content.tsx new file mode 100644 index 00000000..44af56db --- /dev/null +++ b/app/components/Widget/Content.tsx @@ -0,0 +1,16 @@ +type ContetProps = { + center?: boolean, + children?: JSX.Element | JSX.Element[]; +} +const Content = ({ children, center }: ContetProps) => { + return center ? +
    +
    +
    + {children} +
    +
    +
    + :
    {children}
    +} +export default Content \ No newline at end of file diff --git a/app/components/Widget/Footer.tsx b/app/components/Widget/Footer.tsx new file mode 100644 index 00000000..1ed9849e --- /dev/null +++ b/app/components/Widget/Footer.tsx @@ -0,0 +1,84 @@ +import { motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react" +import ReactPortal from "../Common/ReactPortal"; + + +const variants = { + enter: () => { + return ({ + opacity: 0, + y: '100%', + }) + }, + center: () => { + return ({ + opacity: 1, + y: 0, + }) + }, + exit: () => { + return ({ + y: '100%', + zIndex: 0, + opacity: 0, + }) + }, +}; + +type FooterProps = { + hidden?: boolean, + children?: JSX.Element | JSX.Element[]; + sticky?: boolean +} + +const Footer = ({ children, hidden, sticky = true }: FooterProps) => { + const [height, setHeight] = useState(0) + const ref = useRef(null) + + useEffect(() => { + setHeight(Number(ref?.current?.clientHeight)) + }, []) + + const handleAnimationEnd = (variant) => { + if (variant == "center") { + setHeight(Number(ref?.current?.clientHeight)) + } + } + return ( + sticky ? + <> + + {children} + + +
    +
    + + : + <> + {children} + + ) +} +export default Footer; \ No newline at end of file diff --git a/app/components/Widget/Index.tsx b/app/components/Widget/Index.tsx new file mode 100644 index 00000000..d2f8631d --- /dev/null +++ b/app/components/Widget/Index.tsx @@ -0,0 +1,63 @@ +import HeaderWithMenu from "../HeaderWithMenu" +import { useRouter } from "next/router" +import { default as Content } from './Content'; +import { default as Footer } from './Footer'; +import { useCallback, useRef } from "react"; +import { resolvePersistantQueryParams } from "../../helpers/querryHelper"; +import BridgeApiClient from "../../lib/BridgeApiClient"; + +type Props = { + children: JSX.Element | JSX.Element[]; + className?: string; + hideMenu?: boolean; +} + +const Widget = ({ children, className, hideMenu }: Props) => { + const router = useRouter() + const wrapper = useRef(null); + + const goBack = useCallback(() => { + window?.['navigation']?.['canGoBack'] ? + router.back() + : router.push({ + pathname: "/", + query: resolvePersistantQueryParams(router.query) + }) + }, []) + + + const handleBack = router.pathname === "/" ? null : goBack + + return <> +
    +
    + { + BridgeApiClient.apiVersion === 'sandbox' &&
    +
    +
    + TESTNET +
    +
    + } +
    + {!hideMenu && } +
    +
    +
    +
    +
    +
    + {children} +
    +
    +
    +
    +
    +
    + +} + +Widget.Content = Content +Widget.Footer = Footer + +export { Widget } \ No newline at end of file diff --git a/app/components/Wizard/AuthWizard.tsx b/app/components/Wizard/AuthWizard.tsx new file mode 100644 index 00000000..9f29308c --- /dev/null +++ b/app/components/Wizard/AuthWizard.tsx @@ -0,0 +1,48 @@ +import { useRouter } from "next/router"; +import { FC, useCallback } from "react"; +import { useFormWizardaUpdate } from "../../context/formWizardProvider"; +import { TimerProvider } from "../../context/timerContext"; +import { AuthStep, SwapCreateStep } from "../../Models/Wizard"; +import { TrackEvent } from "../../pages/_document"; +import CodeStep from "./Steps/CodeStep"; +import EmailStep from "./Steps/EmailStep"; +import Wizard from "./Wizard"; +import WizardItem from "./WizardItem"; +import { resolvePersistantQueryParams } from "../../helpers/querryHelper"; + + +const AuthWizard: FC = () => { + const { goToStep } = useFormWizardaUpdate() + const router = useRouter(); + const { redirect } = router.query; + + const CodeOnNext = useCallback(async () => { + await router.push({ + pathname: redirect?.toString() || '/', + query: resolvePersistantQueryParams(router.query) + }) + plausible(TrackEvent.SignedIn) + }, [redirect]); + + const GoBackToEmailStep = useCallback(() => goToStep(AuthStep.Email, "back"), []) + const GoToCodeStep = useCallback(() => goToStep(AuthStep.Code), []) + + const handleGoBack = useCallback(() => { + router.back() + }, [router]) + + return ( + + + + + + + + + + + ) +} + +export default AuthWizard; \ No newline at end of file diff --git a/app/components/Wizard/Steps/CodeStep.tsx b/app/components/Wizard/Steps/CodeStep.tsx new file mode 100644 index 00000000..5b3d38c9 --- /dev/null +++ b/app/components/Wizard/Steps/CodeStep.tsx @@ -0,0 +1,21 @@ +import { FC, useCallback } from 'react' +import { AuthConnectResponse } from '../../../Models/BridgeAuth'; +import VerifyEmailCode from '../../VerifyEmailCode'; + +type Props = { + OnNext: (res: AuthConnectResponse) => Promise + disclosureLogin?: boolean +} + +const CodeStep: FC = ({ OnNext, disclosureLogin }) => { + + const onSuccessfullVerifyHandler = useCallback(async (res: AuthConnectResponse) => { + await OnNext(res) + }, [OnNext]); + + return ( + + ) +} + +export default CodeStep; \ No newline at end of file diff --git a/app/components/Wizard/Steps/EmailStep.tsx b/app/components/Wizard/Steps/EmailStep.tsx new file mode 100644 index 00000000..8faa5330 --- /dev/null +++ b/app/components/Wizard/Steps/EmailStep.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react' +import SendEmail from '../../SendEmail'; + +type Props = { + OnNext: () => void; + disclosureLogin?: boolean +} + +const EmailStep: FC = ({ OnNext, disclosureLogin }) => { + return (<> + + ) +} + +export default EmailStep; \ No newline at end of file diff --git a/app/components/Wizard/Wizard.tsx b/app/components/Wizard/Wizard.tsx new file mode 100644 index 00000000..63cfa4e7 --- /dev/null +++ b/app/components/Wizard/Wizard.tsx @@ -0,0 +1,56 @@ +import { FC, useEffect, useRef } from 'react' +import { useFormWizardaUpdate, useFormWizardState } from '../../context/formWizardProvider'; +import { AnimatePresence } from 'framer-motion'; +import HeaderWithMenu from '../HeaderWithMenu'; + +type Props = { + children: JSX.Element | JSX.Element[]; +} + +const Wizard: FC = ({ children }) => { + + const wrapper = useRef(null); + + const { setWrapperWidth } = useFormWizardaUpdate() + const { wrapperWidth, positionPercent, moving, goBack, noToolBar, hideMenu } = useFormWizardState() + + useEffect(() => { + function handleResize() { + if (wrapper.current !== null) { + setWrapperWidth(wrapper.current.offsetWidth); + } + } + window.addEventListener("resize", handleResize); + handleResize(); + + return () => window.removeEventListener("resize", handleResize); + }, []); + + const width = positionPercent || 0 + return <> +
    +
    + {!noToolBar &&
    +
    +
    } +
    + {!hideMenu && } +
    + +
    +
    +
    + +
    + {children} +
    +
    +
    +
    +
    +
    + +} + +export default Wizard; \ No newline at end of file diff --git a/app/components/Wizard/WizardItem.tsx b/app/components/Wizard/WizardItem.tsx new file mode 100644 index 00000000..d4f7770b --- /dev/null +++ b/app/components/Wizard/WizardItem.tsx @@ -0,0 +1,60 @@ +import { motion } from 'framer-motion'; +import { FC, useEffect } from 'react' +import { useFormWizardaUpdate, useFormWizardState } from '../../context/formWizardProvider'; +import { Steps } from '../../Models/Wizard'; + +type Props = { + StepName: Steps, + PositionPercent?: number, + GoBack?: () => void, + children: JSX.Element | JSX.Element[]; + fitHeight?: boolean +} + +const WizardItem: FC = (({ StepName, children, GoBack, PositionPercent, fitHeight = false }: Props) => { + const { currentStepName, wrapperWidth, moving } = useFormWizardState() + const { setGoBack, setPositionPercent } = useFormWizardaUpdate() + const styleConfigs = fitHeight ? { width: `${wrapperWidth}px`, height: '100%' } : { width: `${wrapperWidth}px`, minHeight: '534px', height: '100%' } + + useEffect(() => { + if (currentStepName === StepName) { + setGoBack(GoBack) + PositionPercent && setPositionPercent(PositionPercent) + } + }, [currentStepName, GoBack, PositionPercent, StepName, setGoBack, setPositionPercent]) + + return currentStepName === StepName ? + +
    + {Number(wrapperWidth) > 1 && children} +
    +
    + : null +}) + +let variants = { + enter: ({ direction, width }) => ({ + x: direction * width, + }), + center: { + x: 0, + transition: { + when: "beforeChildren", + }, + }, + exit: ({ direction, width }) => ({ + x: direction * -width, + }), +}; + +export default WizardItem; \ No newline at end of file diff --git a/app/components/backgroundField.tsx b/app/components/backgroundField.tsx new file mode 100644 index 00000000..6eff9510 --- /dev/null +++ b/app/components/backgroundField.tsx @@ -0,0 +1,58 @@ +import { FC } from "react"; +import CopyButton from "./buttons/copyButton"; +import QRCodeModal from "./QRCodeWallet"; +import useWindowDimensions from "../hooks/useWindowDimensions"; +import ExploreButton from "./buttons/exploreButton"; + +type Props = { + Copiable?: boolean; + QRable?: boolean; + Explorable?: boolean; + toCopy?: string | number; + toExplore?: string; + header?: JSX.Element | JSX.Element[] | string; + highlited?: boolean, + withoutBorder?: boolean, + children: JSX.Element | JSX.Element[]; +} + +const BackgroundField: FC = (({ Copiable, toCopy, header, children, QRable, highlited, withoutBorder, Explorable, toExplore }) => { + const { isMobile } = useWindowDimensions() + + return ( +
    + { + highlited && +
    +
    +
    + } +
    + { + header &&

    + {header} +

    + } +
    + {children} +
    + { + QRable && toCopy && + + } + { + Copiable && toCopy && + + } + { + Explorable && toExplore && + + } +
    +
    +
    +
    + ) +}) + +export default BackgroundField; \ No newline at end of file diff --git a/app/components/banner.tsx b/app/components/banner.tsx new file mode 100644 index 00000000..92c7f39f --- /dev/null +++ b/app/components/banner.tsx @@ -0,0 +1,51 @@ +import { X } from 'lucide-react' +import { FC } from 'react' +import { usePersistedState } from '../hooks/usePersistedState'; +interface BannerProps { + mobileMessage: string; + desktopMessage: string; + localStorageId: string; + className?: string; +} + +const Banner: FC = ({ localStorageId, desktopMessage, mobileMessage, className }) => { + const localStorageItemKey = `HideBanner-${localStorageId}`; + let [isVisible, setIsVisible] = usePersistedState(true, localStorageItemKey); + if (!isVisible) { + return <> + } + + function onClickClose() { + setIsVisible(false); + } + + return ( +
    +
    +
    +
    + + 🥳 + +

    + {mobileMessage} + {desktopMessage} +

    +
    +
    + +
    +
    +
    +
    + ) +} + +export default Banner; \ No newline at end of file diff --git a/app/components/buttons/connectButton.tsx b/app/components/buttons/connectButton.tsx new file mode 100644 index 00000000..b1d7dfdd --- /dev/null +++ b/app/components/buttons/connectButton.tsx @@ -0,0 +1,193 @@ +import { ReactNode, useState } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "../shadcn/popover"; +import useWallet from "../../hooks/useWallet"; +import { NetworkType } from "../../Models/CryptoNetwork"; +import RainbowIcon from "../icons/Wallets/Rainbow"; +import TON from "../icons/Wallets/TON"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../shadcn/dialog"; +import useWindowDimensions from "../../hooks/useWindowDimensions"; +import MetaMaskIcon from "../icons/Wallets/MetaMask"; +import WalletConnectIcon from "../icons/Wallets/WalletConnect"; +import Braavos from "../icons/Wallets/Braavos"; +import ArgentX from "../icons/Wallets/ArgentX"; +import Argent from "../icons/Wallets/Argent"; +import TonKeeper from "../icons/Wallets/TonKeeper"; +import OpenMask from "../icons/Wallets/OpenMask"; +import Phantom from "../icons/Wallets/Phantom"; +import Solflare from "../icons/Wallets/Solflare"; +import CoinbaseIcon from "../icons/Wallets/Coinbase"; + +const ConnectButton = ({ + children, + className, + onClose, +}: { + children: ReactNode; + className?: string; + onClose?: () => void; +}) => { + const { connectWallet, wallets } = useWallet(); + const [open, setOpen] = useState(); + const { isMobile } = useWindowDimensions(); + + const knownConnectors = [ + { + name: "EVM", + id: "evm", + type: NetworkType.EVM, + }, + { + name: "Starknet", + id: "starknet", + type: NetworkType.Starknet, + }, + { + name: "TON", + id: "ton", + type: NetworkType.TON, + }, + { + name: "Solana", + id: "solana", + type: NetworkType.Solana, + }, + ]; + const filteredConnectors = knownConnectors.filter( + (c) => !wallets.map((w) => w?.providerName).includes(c.id) + ); + return isMobile ? ( + + {children} + + + + Link a new wallet + + +
    + {filteredConnectors.map((connector, index) => ( + + ))} +
    +
    +
    + ) : ( + + + {children} + + + {filteredConnectors.map((connector, index) => ( + + ))} + + + ); +}; + +export default ConnectButton; + +const ResolveConnectorIcon = ({ + connector, + className, +}: { + connector: string; + className: string; +}) => { + switch (connector.toLowerCase()) { + case KnownConnectors.EVM: + return ( +
    + + + +
    + ); + case KnownConnectors.Starknet: + return ( +
    + + + +
    + ); + case KnownConnectors.TON: + return ( +
    + + + +
    + ); + case KnownConnectors.Solana: + return ( +
    + + + + +
    + ); + default: + return <>; + } +}; + +const KnownConnectors = { + Starknet: "starknet", + EVM: "evm", + TON: "ton", + Solana: "solana", +}; \ No newline at end of file diff --git a/app/components/buttons/copyButton.tsx b/app/components/buttons/copyButton.tsx new file mode 100644 index 00000000..b0114eb4 --- /dev/null +++ b/app/components/buttons/copyButton.tsx @@ -0,0 +1,44 @@ +import { Check, Copy } from 'lucide-react' +import { classNames } from '../utils/classNames' +import useCopyClipboard from '../../hooks/useCopyClipboard' +import React, { FC } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '../shadcn/tooltip' + +interface CopyButtonProps { + className?: string + toCopy: string | number + children?: React.ReactNode + iconSize?: number + iconClassName?: string +} + +const CopyButton: FC = ({ className, toCopy, children, iconSize, iconClassName }) => { + const [isCopied, setCopied] = useCopyClipboard() + + return ( + + +
    setCopied(toCopy)}> + {isCopied && ( +
    + + {children} +
    + )} + + {!isCopied && ( +
    + + {children} +
    + )} +
    +
    + +

    Copy

    +
    +
    + ) +} + +export default CopyButton \ No newline at end of file diff --git a/app/components/buttons/exploreButton.tsx b/app/components/buttons/exploreButton.tsx new file mode 100644 index 00000000..36bc6e03 --- /dev/null +++ b/app/components/buttons/exploreButton.tsx @@ -0,0 +1,32 @@ +import { ExternalLink } from 'lucide-react' +import { classNames } from '../utils/classNames' +import React, { AnchorHTMLAttributes, FC, forwardRef } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '../shadcn/tooltip' + +interface ExploreButtonProps extends AnchorHTMLAttributes { + className?: string + children?: React.ReactNode + iconSize?: number + iconClassName?: string +} + +const ExploreButton: FC = forwardRef(function co({ className, children, iconSize, iconClassName, ...rest }, ref) { + + return ( + + + + + +

    View in explorer

    +
    +
    + ); +}) + +export default ExploreButton \ No newline at end of file diff --git a/app/components/buttons/iconButton.tsx b/app/components/buttons/iconButton.tsx new file mode 100644 index 00000000..97ee4779 --- /dev/null +++ b/app/components/buttons/iconButton.tsx @@ -0,0 +1,24 @@ +import React, { ComponentProps, FC, forwardRef } from 'react' +import { classNames } from '../utils/classNames' + +interface IconButtonProps extends Omit, 'color' | 'ref'> { + icon?: React.ReactNode +} + +const IconButton = forwardRef(function IconButton({ className, icon, ...props }, ref){ + const theirProps = props as object; + + return ( + + ) +}) + +export default IconButton \ No newline at end of file diff --git a/app/components/buttons/secondaryButton.tsx b/app/components/buttons/secondaryButton.tsx new file mode 100644 index 00000000..716c53de --- /dev/null +++ b/app/components/buttons/secondaryButton.tsx @@ -0,0 +1,47 @@ +import { FC, MouseEventHandler } from "react" + +type buttonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +type SecondaryButtonProps = { + size?: buttonSize + onClick?: MouseEventHandler + className?: string, + disabled?: boolean + children?: React.ReactNode +} + +const SecondaryButton: FC = ({ size = 'md', onClick, children, className, disabled }) => { + + let defaultStyle = 'rounded-md duration-200 break-keep transition bg-secondary-500 hover:bg-secondary-400 border border-secondary-400 hover:border-secondary-200 font-semibold text-primary-text shadow-sm cursor-pointer ' + className + + switch (size) { + case 'xs': + defaultStyle += " px-2 py-1 text-xs"; + break; + case 'sm': + defaultStyle += " px-2 py-1 text-sm"; + break; + case 'md': + defaultStyle += " px-2.5 py-1.5 text-sm"; + break; + case 'lg': + defaultStyle += " px-3 py-2 text-sm"; + break; + case 'xl': + defaultStyle += " px-3.5 py-2.5 text-sm"; + break; + } + + return ( + + ) +} + +export default SecondaryButton diff --git a/app/components/buttons/submitButton.tsx b/app/components/buttons/submitButton.tsx new file mode 100644 index 00000000..2578b614 --- /dev/null +++ b/app/components/buttons/submitButton.tsx @@ -0,0 +1,87 @@ +import { FC, MouseEventHandler } from "react"; +import SpinIcon from "../icons/spinIcon"; + +type buttonStyle = 'outline' | 'filled'; +type buttonSize = 'small' | 'medium' | 'large'; +type text_align = 'center' | 'left' +type button_align = 'left' | 'right' + +export class SubmitButtonProps { + isDisabled: boolean; + isSubmitting: boolean; + type?: 'submit' | 'reset' | 'button' | undefined; + onClick?: MouseEventHandler | undefined; + icon?: React.ReactNode; + buttonStyle?: buttonStyle = 'filled'; + size?: buttonSize = 'medium'; + text_align?: text_align = 'center' + button_align?: button_align = 'left'; + className?: string; + children?: React.ReactNode; +} + +function constructClassNames(size: buttonSize, buttonStyle: buttonStyle) { + let defaultStyle = ' border border-primary disabled:border-primary-900 items-center space-x-1 disabled:text-opacity-40 disabled:bg-primary-900 disabled:cursor-not-allowed relative w-full flex justify-center font-semibold rounded-md transform hover:brightness-125 transition duration-200 ease-in-out' + defaultStyle += buttonStyle == 'filled' ? " bg-primary text-primary-buttonTextColor" : " text-primary"; + + switch (size) { + case 'large': + defaultStyle += " py-4 px-4"; + break; + case 'medium': + defaultStyle += " py-3 px-2 md:px-3"; + break; + case 'small': + defaultStyle += " py-1.5 px-1.5"; + break; + } + + return defaultStyle; +} + +const SubmitButton: FC = ({ isDisabled, isSubmitting, icon, children, type, onClick, buttonStyle = 'filled', size = 'medium', text_align = 'center', button_align = 'left', className }) => { + return ( + + ); +} + + +type DoubleLineTextProps = { + primaryText: string, + secondarytext: string, + colorStyle: 'mltln-text-light' | 'mltln-text-dark', + reversed?: boolean +} + +const text_styles = { + 'mltln-text-light': { + primary: 'text-primary-buttonTextColor', + secondary: 'text-primary-100' + }, + 'mltln-text-dark': { + primary: 'text-primary', + secondary: 'text-primary-600' + } +} + +export const DoubleLineText = ({ primaryText, secondarytext, colorStyle, reversed }: DoubleLineTextProps) => { + return
    +
    {secondarytext}
    +
    {primaryText}
    +
    +} + +export default SubmitButton; \ No newline at end of file diff --git a/app/components/buttons/swapButton.tsx b/app/components/buttons/swapButton.tsx new file mode 100644 index 00000000..baff2a9f --- /dev/null +++ b/app/components/buttons/swapButton.tsx @@ -0,0 +1,22 @@ +import { ArrowLeftRight } from "lucide-react"; +import { FC, MouseEventHandler } from "react"; +import SubmitButton from "./submitButton"; + +export interface SwapButtonProps { + isDisabled: boolean; + isSubmitting: boolean; + type?: 'submit' | 'reset' | 'button' | undefined; + onClick?: MouseEventHandler | undefined; + className?: string + children?: React.ReactNode +} + +const SwapButton: FC = (props) => { + const swapIcon =