New bridge app + explorer

This commit is contained in:
2023-12-30 14:10:02 +01:00
commit 1c440985ee
366 changed files with 64536 additions and 0 deletions
+15
View File
@@ -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"
}
}
+26
View File
@@ -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 }}
@@ -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 "[email protected]"
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 "[email protected]"
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
+22
View File
@@ -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 "[email protected]"
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
+38
View File
@@ -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
+32
View File
@@ -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;
+32
View File
@@ -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;
+25
View File
@@ -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;
+25
View File
@@ -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;
+39
View File
@@ -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"
}
}
]
}
+39
View File
@@ -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"
}
}
]
}
+39
View File
@@ -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"
}
}
]
}
+39
View File
@@ -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"
}
}
]
}
+14
View File
@@ -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`.
+18
View File
@@ -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"
}
+13
View File
@@ -0,0 +1,13 @@
import { ApiError } from "./ApiError";
export class EmptyApiResponse {
constructor(error?: ApiError) {
this.error = error;
}
error?: ApiError;
}
export class ApiResponse<T> extends EmptyApiResponse {
data?: T
}
+44
View File
@@ -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<Balance[] | undefined> | undefined | void,
getGas: ({ layer, address, currency, userDestinationAddress, wallet }: GasProps) => Promise<Gas[] | undefined> | undefined | void,
supportedNetworks: string[],
name: string,
}
+69
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
export class Currency {
asset: string;
usd_price: number;
precision: number;
img_url?: string
}
+7
View File
@@ -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;
}
+23
View File
@@ -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
}
+48
View File
@@ -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;
}
+105
View File
@@ -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<Layer, 'internal_name'> | { 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
}))
}
}
+15
View File
@@ -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;
}
+21
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
export class Partner {
display_name: string;
logo_url: string;
is_wallet: boolean;
id: number;
name: string
}
+38
View File
@@ -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 = "";
}
+4
View File
@@ -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"
}
+12
View File
@@ -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",
}
+243
View File
@@ -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',
},
}
}
+75
View File
@@ -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<T> {
Name: T;
Content: FC;
onBack?: () => void;
onNext?: (data?: any) => Promise<void>;
positionPercent: number;
}
+2
View File
@@ -0,0 +1,2 @@
# Bridge App
Up to date repository of Bridge UI App deployed at https://bridge.lux.network/app
+23
View File
@@ -0,0 +1,23 @@
import Jazzicon from "@metamask/jazzicon";
import { FC, useEffect, useRef } from "react";
type Props = {
address: string;
size: number;
}
const AddressIcon: FC<Props> = ({ address, size }) => {
const ref = useRef<HTMLDivElement>(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 <div ref={ref as any} />
}
export default AddressIcon
+28
View File
@@ -0,0 +1,28 @@
import Image from 'next/image'
import { FC } from 'react'
type Props = {
imageUrls: string[]
}
const AvatarGroup: FC<Props> = (({ imageUrls }) => {
return (
<div className="">
<div className="isolate flex -space-x-1 overflow-hidden p-1">
{imageUrls.map(x => {
return (
<Image
key={x}
className="relative z-30 inline-block h-5 w-5 rounded-full ring-2 ring-secondary-600"
src={x}
width="60"
height={60}
alt=""
/>
)
})}
</div>
</div>
)
});
export default AvatarGroup;
@@ -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<Props> = ({ 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<ApiResponse<Leaderboard>>(`/campaigns/${campaign?.id}/leaderboard`, apiClient.fetcher, { dedupingInterval: 60000 })
const { data: rewardsData, isLoading: rewardsIsLoading } = useSWR<ApiResponse<Reward>>(`/campaigns/${campaign.id}/rewards/${address}`, apiClient.fetcher, { dedupingInterval: 60000 })
const leaderboard = leaderboardData?.data
if (isLoading) {
return <RewardsComponentLeaderboardSceleton />
}
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 <div className="space-y-2">
{leaderboard?.leaderboard?.length > 0 &&
<div className="flex items-center justify-between">
<p className="font-bold text-left leading-5">Leaderboard</p>
<button onClick={handleOpenTopModal} type="button" className=" leading-4 text-base text-primary underline hover:no-underline hover:text-primary/80">
Top 10
</button>
</div>}
<p className="text-sm text-primary-text">Users who earn the most throughout the program will be featured here.</p>
<div className="bg-secondary-700 border border-secondary-700 hover:border-secondary-500 transition duration-200 rounded-lg shadow-lg">
<div className="p-3">
{leaderboard?.leaderboard?.length > 0 ? <div className="space-y-6">
{
leaderboard?.leaderboard?.filter(u => u.position < 4).map(user => (
<div key={user.position} className="items-center flex justify-between">
<div className="flex items-center">
<p className="text-xl font-medium text-primary-text w-fit mr-1">{user.position}.</p>
<div className="cols-start-2 flex items-center space-x-2">
<AddressIcon address={user.address} size={25} />
<div>
<div className="text-sm font-bold text-primary-text leading-3">
{user?.address && network?.account_explorer_template && <Link target="_blank" className="hover:opacity-80" href={network?.account_explorer_template?.replace("{0}", user?.address)}>
{user?.position === rewards?.user_reward?.position ? <span className="text-primary">You</span> : shortenAddress(user?.address)}
</Link>}
</div>
<p className="mt-1 text-sm font-medium text-secondary-text leading-3">{truncateDecimals(user.amount, campaignAsset?.precision)} {campaign?.asset}</p>
</div>
</div >
</div >
{
leaderboard.leaderboard_budget > 0 && <div className="text-right flex items-center space-x-2">
<ClickTooltip text={
<div className="flex items-center space-x-1">
<span>+</span>
<div className="h-3.5 w-3.5 relative">
<Image
src={resolveImgSrc(campaign)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-full object-contain" />
</div>
<p>
<span>{leaderboardRewards[user.position - 1]} {campaign?.asset}</span>
</p>
</div>}>
<div className='text-primary-text hover:cursor-pointer hover:text-primary-text ml-0.5 hover:bg-secondary-200 active:ring-2 active:ring-gray-200 active:bg-secondary-400 focus:outline-none cursor-default p-1 rounded'>
<Trophy className="h-4 w-4" aria-hidden="true" />
</div>
</ClickTooltip>
</div>
}
</div >
))
}
{
position >= 4 && address && rewards?.user_reward &&
<div className={position > 4 ? "!mt-0 !pt-0" : ""}>
{position > 4 && < div className="text-2xl text-center leading-3 text-secondary-text my-3">
...
</div>}
<div key={position} className="items-center flex justify-between">
<div className="flex items-center">
<p className="text-xl font-medium text-primary-text w-fit mr-1">{position}.</p>
<div className="cols-start-2 flex items-center space-x-2">
<AddressIcon address={address} size={25} />
<div>
<div className="text-sm font-bold text-primary-text leading-3">
{network?.account_explorer_template && <Link target="_blank" className="hover:opacity-80" href={network?.account_explorer_template?.replace("{0}", address)}>
<span className="text-primary">You</span>
</Link>}
</div>
<p className="mt-1 text-sm font-medium text-secondary-text leading-3">{truncateDecimals(rewards.user_reward.total_amount, campaignAsset?.precision)} {campaign?.asset}</p>
</div>
</div >
</div >
</div >
</div >
}
</div >
:
<div className="h-8 flex flex-col justify-center items-center text-sm">
Here will be leaderboard.
</div>
}
</div >
</div >
<Modal height="full" header='Leaderboard' show={openTopModal} setShow={setOpenTopModal} >
<div className="bg-secondary-700 border border-secondary-700 mt-2 hover:border-secondary-500 transition duration-200 rounded-lg shadow-lg text-secondary-text">
<div className="p-3">
<div className="space-y-6">
{
leaderboard?.leaderboard?.map(user => (
<div key={user.position} className="items-center flex justify-between">
<div className="flex items-center">
<p className="text-xl font-medium text-primary-text w-fit mr-1">{user.position}.</p>
<div className="cols-start-2 flex items-center space-x-2">
<AddressIcon address={user.address} size={25} />
<div>
<div className="text-sm font-bold text-primary-text leading-3">
{user?.address && network?.account_explorer_template && <Link target="_blank" className="hover:opacity-80" href={network?.account_explorer_template?.replace("{0}", user?.address)}>
{user.position === rewards?.user_reward?.position ? <span className="text-primary">You</span> : shortenAddress(user.address)}
</Link>}
</div>
<p className="mt-1 text-sm font-medium text-secondary-text leading-3">{truncateDecimals(user.amount, campaignAsset?.precision)} {campaign?.asset}</p>
</div>
</div >
</div >
{
user.position < 4 && leaderboard.leaderboard_budget > 0 &&
<div className="text-right flex items-center space-x-2">
<ClickTooltip text={
<div className="flex items-center space-x-1">
<span>+</span>
<div className="h-3.5 w-3.5 relative">
<Image
src={resolveImgSrc(campaign)}
alt="Address Logo"
height="40"
width="40"
loading="eager"
className="rounded-full object-contain" />
</div>
<p>
<span>{leaderboardRewards[user.position - 1]} {campaign?.asset}</span>
</p>
</div>
}>
<div className='text-secondary-text hover:cursor-pointer hover:text-primary-text ml-0.5 hover:bg-secondary-200 active:ring-2 active:ring-gray-200 active:bg-secondary-400 focus:outline-none cursor-default p-1 rounded'>
<Trophy className="h-4 w-4" aria-hidden="true" />
</div>
</ClickTooltip>
</div>
}
</div >
))
}
</div >
</div >
</div >
</Modal >
</div >
}
export default Component
@@ -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<Props> = ({ campaign }) => {
const settings = useSettingsState()
const { resolveImgSrc, networks, currencies } = settings
const { address } = useAccount();
const apiClient = new BridgeApiClient()
const { data: rewardsData, isLoading: rewardsIsLoading } = useSWR<ApiResponse<Reward>>(`/campaigns/${campaign.id}/rewards/${address}`, apiClient.fetcher, { dedupingInterval: 60000 })
const { data: payoutsData, isLoading: payoutsIsLoading } = useSWR<ApiResponse<RewardPayout[]>>(`/campaigns/${campaign.id}/payouts/${address}`, apiClient.fetcher, { dedupingInterval: 60000 })
if (rewardsIsLoading || payoutsIsLoading) {
return <RewardsComponentSceleton />
}
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 <>
<div className="space-y-4">
<p className="text-primary-text">
<span>
<span>Onboarding incentives that are earned by transferring to&nbsp;</span>{network?.display_name}<span>.&nbsp;</span>
<a
target='_blank'
href="https://docs.bridge.lux.network/user-docs/bridge-campaigns/usdop-rewards"
className="text-primary-text underline hover:no-underline decoration-white cursor-pointer"
>Learn more</a>
</span>
</p>
<div className="bg-secondary-700 divide-y divide-secondary-500 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
{!campaignIsEnded &&
<BackgroundField header={<span className="flex justify-between"><span className="flex items-center"><span>Pending Earnings&nbsp;</span><ClickTooltip text={`${campaign?.asset} tokens that will be airdropped periodically.`} /> </span><span>Next Airdrop</span></span>} withoutBorder>
<div className="flex justify-between w-full text-2xl">
<div className="flex items-center space-x-1">
<div className="h-5 w-5 relative">
<Image
src={resolveImgSrc(campaign)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-full object-contain" />
</div>
<p>
{rewards?.user_reward.total_pending_amount} <span className="text-base sm:text-2xl">{campaign?.asset}</span>
</p>
</div>
<div className="flex items-center space-x-1">
<Clock className="h-5" />
<p>
{difference_in_days}d {difference_in_hours}h
</p>
</div>
</div>
</BackgroundField>
}
<BackgroundField header={<span className="flex justify-between"><span className="flex items-center"><span>Total Earnings&nbsp;</span><ClickTooltip text={`${campaign?.asset} tokens that you’ve earned so far (including Pending Earnings).`} /></span><span>Current Value</span></span>} withoutBorder>
<div className="flex justify-between w-full text-slate-300 text-2xl">
<div className="flex items-center space-x-1">
<div className="h-5 w-5 relative">
<Image
src={resolveImgSrc(campaign)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-full object-contain" />
</div>
<p>
{rewards?.user_reward.total_amount} <span className="text-base sm:text-2xl">{campaign?.asset}</span>
</p>
</div>
<p>
${total_in_usd}
</p>
</div>
</BackgroundField>
</div>
</div>
<div className="bg-secondary-700 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
<BackgroundField header={
<>
<p className="flex items-center"><span>{campaign?.asset} pool</span>
<ClickTooltip text={`The amount of ${campaign?.asset} to be distributed during this round of the campaign.`} />
</p>
</>
} withoutBorder>
<div className="flex flex-col w-full gap-2">
<Progress value={DistributedAmount === Infinity ? 0 : DistributedAmount} />
<div className="flex justify-between w-full font-semibold text-sm ">
<div className="text-primary"><span className="text-primary-text">{campaign?.distributed_amount.toFixed(0)}</span> <span>/</span> {totalBudget} {campaign?.asset}</div>
</div>
</div>
</BackgroundField>
</div>
{
payouts.length > 0 &&
<div className="space-y-1">
<p className="font-bold text-lg text-left">Payouts</p>
<div className=" bg-secondary-700 divide-y divide-secondary-300 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-secondary-500">
<thead className="bg-secondary-800/70">
<tr>
<th scope="col" className="py-3.5 pl-4 text-left text-sm font-semibold sm:pl-6">
Tx Id
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold ">
Amount
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold ">
Date
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-600">
{payouts.map((payout) => (
<tr key={payout.transaction_id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-primary-text sm:pl-6 underline hover:no-underline">
<a target={"_blank"} href={network?.transaction_explorer_template?.replace("{0}", payout.transaction_id)}>{shortenAddress(payout.transaction_id)}</a>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-100">{payout.amount}</td>
<td className="px-3 py-4 text-sm text-gray-100">{new Date(payout.date).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
</>
}
export default Rewards
+129
View File
@@ -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<ApiResponse<Campaign[]>>('/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 <Loading />
}
if (!campaign) {
return <NotFound />
}
return (
<Widget>
<Widget.Content>
<div className="space-y-5">
<div className="flex items-center gap-1">
<div className="h-7 w-7 relative">
{network && <Image
src={resolveImgSrc(network)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-md object-contain" />}
</div>
<p className="font-bold text-xl text-left flex items-center">
{network?.display_name} Rewards
</p>
</div>
{
isConnected ?
<Rewards
campaign={campaign}
/>
:
<BriefInformation
network={network}
campaign={campaign}
/>
}
<Leaderboard campaign={campaign} />
</div>
</Widget.Content>
<>
{
!isConnected &&
<Widget.Footer>
<RainbowKit>
<SubmitButton isDisabled={false} isSubmitting={false} icon={<WalletIcon className="stroke-2 w-6 h-6" />}>
Connect a wallet
</SubmitButton>
</RainbowKit>
</Widget.Footer>
}
</>
</Widget >
)
}
type BriefInformationProps = {
campaign: Campaign,
network?: CryptoNetwork
}
const BriefInformation: FC<BriefInformationProps> = ({ campaign, network }) =>
<p className="text-secondary-text text-base">
<span>You can earn $</span>
<span>{campaign?.asset}</span>
<span>&nbsp;tokens by transferring assets to&nbsp;</span>
<span>{network?.display_name || campaign.network}</span>
<span>. For each transaction, you&amp;ll receive&nbsp;</span>
<span>{campaign?.percentage}</span>
<span>% of Bridge fee back.&nbsp;</span>
<Link target='_blank' href="https://docs.bridge.lux.network/user-docs/bridge-campaigns/usdop-rewards"
className="text-primary underline hover:no-underline decoration-primary cursor-pointer">
Learn more
</Link>
</p>
const Loading = () => <Widget className="min-h-[500px]">
<Widget.Content>
<div className="absolute top-[calc(50%-5px)] left-[calc(50%-5px)]">
<SpinIcon className="animate-spin h-5 w-5" />
</div>
</Widget.Content>
</Widget>
const NotFound = () => <Widget className="min-h-[500px]">
<Widget.Content>
<div className="h-[364px] flex flex-col items-center justify-center space-y-4">
<Gift className="h-20 w-20 text-primary" />
<p className="font-bold text-center">Campaign not found</p>
<LinkWrapper className="text-xs underline hover:no-underline" href='/campaigns'>See all campaigns</LinkWrapper>
</div>
</Widget.Content>
</Widget>
export default CampaignDetails;
+117
View File
@@ -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<ApiResponse<Campaign[]>>('/campaigns', apiClient.fetcher)
const campaigns = campaignsData?.data
const activeCampaigns = campaigns?.filter(IsCampaignActive) || []
const inactiveCampaigns = campaigns?.filter(c => !IsCampaignActive(c)) || []
return (
<Widget className="min-h-[520px]">
<Widget.Content>
{!isLoading ?
<div className="space-y-5 h-full text-primary-text">
<div className="space-y-2">
<p className="font-bold text-left leading-5">Campaigns</p>
<div className="bg-secondary-700 border border-secondary-700 hover:border-secondary-500 transition duration-200 rounded-lg shadow-lg">
<div className="p-3 space-y-4">
{
activeCampaigns.length > 0 ?
activeCampaigns.map(c =>
<CampaignItem
campaign={c}
layers={layers}
resolveImgSrc={resolveImgSrc}
key={c.id}
/>)
:
<div className="flex flex-col items-center justify-center space-y-2">
<Gift className="h-10 w-10 text-primary" />
<p className="font-bold text-center">There are no active campaigns right now</p>
</div>
}
</div>
</div>
</div>
{
inactiveCampaigns.length > 0 &&
<div className="space-y-2">
<p className="font-bold text-left leading-5">Old campaigns</p>
<div className="bg-secondary-700 border border-secondary-700 hover:border-secondary-500 transition duration-200 rounded-lg shadow-lg">
<div className="p-3 dpsv flex flex-col space-y-4">
{inactiveCampaigns.map(c =>
<CampaignItem
campaign={c}
layers={layers}
resolveImgSrc={resolveImgSrc}
key={c.id}
/>)}
</div >
</div >
</div >
}
</div >
:
<div className="absolute top-[calc(50%-5px)] left-[calc(50%-5px)]">
<SpinIcon className="animate-spin h-5 w-5" />
</div>
}
</Widget.Content>
</Widget>
)
}
type CampaignProps = {
campaign: Campaign,
layers: Layer[],
resolveImgSrc: (item: Layer) => string
}
const CampaignItem: FC<CampaignProps> = ({ 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 <LinkWrapper href={`/campaigns/${campaign.name}`}
className="flex justify-between items-center">
<span className="flex items-center gap-1 hover:opacity-70 active:scale-90 duration-200 transition-all">
<span className="h-5 w-5 relative">
{campaignLayer && <Image
src={resolveImgSrc(campaignLayer)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-md object-contain" />}
</span>
<span className="font-semibold text-base text-left flex items-center">{campaign?.display_name} </span>
</span>
{
campaignIsActive &&
<span className="text-primary-text-muted text-right text-sm">
{campaignDaysLeft} days left
</span>
}
</LinkWrapper>
}
function IsCampaignActive(campaign: Campaign) {
const now = new Date()
return campaign.status == 'active' && (new Date(campaign?.end_date).getTime() > now.getTime())
}
export default Rewards
+84
View File
@@ -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<CarouselItemProps> = ({ children, width }) => {
return (
<div className={`rounded-xl inline-flex items-center justify-center flex-col pb-0 bg-gradient-to-b from-secondary-900 to-secondary-700 h-full relative`} style={{ width: width }}>
{children}
</div>
);
};
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<CarouselRef, CarouselProps>(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 (
<div
{...handlers}
className="overflow-hidden h-full"
>
<div
className="whitespace-nowrap transition-transform duration-500 inner h-full"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
>
{children && React.Children.map(children, (child, index) => {
return React.cloneElement(child, { width: "100%" });
})}
</div>
</div>
);
});
export default Carousel;
+54
View File
@@ -0,0 +1,54 @@
import { FC } from "react";
import { THEME_COLORS, ThemeData } from "../Models/Theme";
type Props = {
themeData?: ThemeData | null
}
const ColorSchema: FC<Props> = ({ themeData }) => {
themeData = themeData || THEME_COLORS.default
return (
<>
{themeData &&
<style global jsx>{`
:root {
--ls-colors-backdrop:${themeData.backdrop};
--ls-colors-logo: ${themeData.logo};
--ls-colors-primary: ${themeData.primary?.DEFAULT};
--ls-colors-primary-50: ${themeData.primary?.[50]};
--ls-colors-primary-100: ${themeData.primary?.[100]};
--ls-colors-primary-200: ${themeData.primary?.[200]};
--ls-colors-primary-300: ${themeData.primary?.[300]};
--ls-colors-primary-400: ${themeData.primary?.[400]};
--ls-colors-primary-500: ${themeData.primary?.[500]};
--ls-colors-primary-600: ${themeData.primary?.[600]};
--ls-colors-primary-700: ${themeData.primary?.[700]};
--ls-colors-primary-800: ${themeData.primary?.[800]};
--ls-colors-primary-900: ${themeData.primary?.[900]};
--ls-colors-actionButtonText: ${themeData.actionButtonText};
--ls-colors-text-placeholder: ${themeData.placeholderText};
--ls-colors-primary-text: ${themeData.primary?.text};
--ls-colors-primary-text-muted: ${themeData.primary?.textMuted};
--ls-colors-primary-logoColor: ${themeData.logo};
--ls-colors-secondary: ${themeData.secondary?.DEFAULT};
--ls-colors-secondary-50: ${themeData.secondary?.[50]};
--ls-colors-secondary-100: ${themeData.secondary?.[100]};
--ls-colors-secondary-200: ${themeData.secondary?.[200]};
--ls-colors-secondary-300: ${themeData.secondary?.[300]};
--ls-colors-secondary-400: ${themeData.secondary?.[400]};
--ls-colors-secondary-500: ${themeData.secondary?.[500]};
--ls-colors-secondary-600: ${themeData.secondary?.[600]};
--ls-colors-secondary-700: ${themeData.secondary?.[700]};
--ls-colors-secondary-800: ${themeData.secondary?.[800]};
--ls-colors-secondary-900: ${themeData.secondary?.[900]};
--ls-colors-secondary-950: ${themeData.secondary?.[950]};
--ls-colors-secondary-text: ${themeData.secondary?.text};
}
`}
</style>
}
</>
)
}
export default ColorSchema
@@ -0,0 +1,24 @@
import { FC } from "react";
type AverageCompletionTimeProps = {
time: string | undefined
}
const AverageCompletionTime: FC<AverageCompletionTimeProps> = ({ time }) => {
if (!time) {
return <span>~1-2 minutes</span>
}
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 <span><span>~</span><span>{averageTimeInMinutes.toFixed()}</span> <span>minutes</span></span>
else if (averageTimeInMinutes >= 60) return <span><span>~</span><span>{hours}</span> <span>{hours > 1 ? 'hours' : 'hour'}</span> <span>{minutes > 0 ? ` ${minutes?.toFixed()} minutes` : ''}</span></span>
else return <span>~1-2 minutes</span>
}
export default AverageCompletionTime
+12
View File
@@ -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
+27
View File
@@ -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<Props> = ({ children, wrapperId = "react-portal-wrapper" }) => {
const ref = useRef<Element | null>(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
+32
View File
@@ -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<Props> = ({ NetworkDisplayName, AppURL }) => {
const connectButtonIcon = <Link className='h-5 w-5'></Link>
return (
<div className="relative inset-0 flex flex-col overflow-y-auto styled-scroll">
<div className="relative min-h-full items-center justify-center pt-0 text-center">
<h3 className='mb-4 pt-2 text-xl text-center md:text-left font-roboto text-secondary-text font-semibold'>
<p className='mb-10 pt-2 text-base text-center md:text-left font-roboto text-pink-primary-300 font-light'>
{NetworkDisplayName} account with the provided address does not exist. To create one, go to {NetworkDisplayName} and connect your wallet.
</p>
</h3>
<div className="mt-3 sm:mt-6 text-primary-text text-sm">
<SubmitButton icon={connectButtonIcon} isDisabled={false} isSubmitting={false} onClick={() => window.open(AppURL, '_blank')}>
Connect
</SubmitButton>
</div>
</div>
</div>
)
}
export default ConnectNetwork;
+148
View File
@@ -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<boolean>(false)
if (wallets.length > 0) {
return (
<>
<button type="button" aria-label="Connected wallets" onClick={() => setOpenDialog(true)} className="-mx-2 p-1.5 justify-self-start text-secondary-text hover:bg-secondary-500 hover:text-primary-text focus:outline-none inline-flex rounded-lg items-center">
<WalletsIcons wallets={wallets} />
</button>
<ConnectedWalletsDialog openDialog={openDialog} setOpenDialog={setOpenDialog} />
</>
)
}
return (
<ConnectButton>
<div className="-mx-2 p-1.5 justify-self-start text-secondary-text hover:bg-secondary-500 hover:text-primary-text focus:outline-none inline-flex rounded-lg items-center">
<WalletIcon className="h-6 w-6 mx-0.5" strokeWidth="2" />
</div>
</ConnectButton>
)
}
const WalletsIcons = ({ wallets }: { wallets: Wallet[] }) => {
const firstWallet = wallets[0]
const secondWallet = wallets[1]
return (
<div className="-space-x-2 flex">
{
firstWallet?.connector &&
<firstWallet.icon className="rounded-full border-2 border-secondary-600 bg-secondary-700 flex-shrink-0 h-6 w-6" />
}
{
secondWallet?.connector &&
<secondWallet.icon className="rounded-full border-2 border-secondary-600 bg-secondary-700 flex-shrink-0 h-6 w-6" />
}
{
wallets.length > 2 &&
<div className="h-6 w-6 flex-shrink-0 rounded-full justify-center p-1 bg-secondary-600 text-primary-text overlfow-hidden text-xs">
<span><span>+</span>{wallets.length - 2}</span>
</div>
}
</div>
)
}
export const WalletsMenu = () => {
const [openDialog, setOpenDialog] = useState<boolean>(false)
const { wallets } = useWallet()
const wallet = wallets[0]
if (wallets.length > 0) {
return (
<>
<button onClick={() => setOpenDialog(true)} type="button" className="py-3 px-4 bg-secondary-700 flex items-center w-full rounded-md space-x-1 disabled:text-opacity-40 disabled:bg-primary-900 disabled:cursor-not-allowed relative font-semibold transform border border-secondary-500 hover:bg-secondary-600 transition duration-200 ease-in-out">
{
wallets.length === 1 ?
<div className="flex gap-4 items-start">
<div className="inline-flex items-center relative">
<AddressIcon address={wallet.address} size={20} />
{
wallet.connector && <span className="absolute -bottom-1 -right-2 ml-1 text-[10px] leading-4 font-semibold text-primary-text">
<wallet.icon className="w-4 h-4 border-2 border-secondary-600 rounded-full bg-primary-text" />
</span>
}
</div>
<p>{shortenAddress(wallet.address)}</p>
</div>
:
<>
<div className="flex justify-center w-full">
Connected wallets
</div>
<div className="place-items-end absolute left-2.5">
<WalletsIcons wallets={wallets} />
</div>
</>
}
</button>
<ConnectedWalletsDialog openDialog={openDialog} setOpenDialog={setOpenDialog} />
</>
)
}
return (
<ConnectButton>
<SubmitButton text_align="center" icon={<WalletIcon className='h-5 w-5' strokeWidth={2} />} className="bg-primary/20 border-none !text-primary !px-4" type="button" isDisabled={false} isSubmitting={false}>
Connect a wallet
</SubmitButton>
</ConnectButton>
)
}
const ConnectedWalletsDialog = ({ openDialog, setOpenDialog }: { openDialog: boolean, setOpenDialog: (open: boolean) => void }) => {
const { wallets, disconnectWallet } = useWallet()
return (
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-center">Wallets</DialogTitle>
</DialogHeader>
<div className="flex flex-col justify-start space-y-2">
{
wallets.map((wallet, index) => (
<div key={index} className="w-full relative items-center justify-between gap-2 flex rounded-md outline-none bg-secondary-700 text-primary-text p-3 border border-secondary-500 ">
<div className="flex space-x-4 items-center">
{
wallet.connector &&
<div className="inline-flex items-center relative">
<wallet.icon className="w-8 h-8 p-0.5 rounded-full bg-secondary-800 border border-secondary-400" />
</div>
}
<p>{shortenAddress(wallet.address)}</p>
</div>
<button onClick={() => { disconnectWallet(wallet.providerName); wallets.length === 1 && setOpenDialog(false) }} className="p-1 hover:bg-secondary-700 text-xs text-secondary-text hover:opacity-75">
Disconnect
</button>
</div>
))
}
</div>
<DialogFooter>
<ConnectButton onClose={() => setOpenDialog(false)}>
<div className="text-secondary-text hover:text-secondary-text/80 flex items-center gap-1 justify-end w-fit">
<Plus className="h-4 w-4" />
<span className="text-sm">
Link a new wallet
</span>
</div>
</ConnectButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+18
View File
@@ -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 <span onClick={() => {
boot();
show();
updateWithProps()
}}>
{children}
</span>
}
export default ContactSupport
@@ -0,0 +1,3 @@
export interface SwapConfirmationFormValues {
RightWallet: boolean;
}
+12
View File
@@ -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;
}
@@ -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<CampaignProps> = ({
destination,
fee,
selected_currency
}) => {
const apiClient = new BridgeApiClient()
const { data: campaignsData } = useSWR<ApiResponse<Campaign[]>>('/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 <CampaignDisplay
campaign={campaign}
fee={fee}
selected_currency={selected_currency}
/>
}
type CampaignDisplayProps = {
campaign: Campaign,
fee: number,
selected_currency: Currency,
}
const CampaignDisplay: FC<CampaignDisplayProps> = ({ 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 <motion.div
initial={{ y: "-100%" }}
animate={{
y: 0,
transition: { duration: 0.3, ease: [0.36, 0.66, 0.04, 1] },
}}
exit={{
y: "-100%",
transition: { duration: 0.4, ease: [0.36, 0.66, 0.04, 1] },
}}
className='w-full flex items-center justify-between rounded-b-lg bg-secondary-700 relative bottom-2 z-0 pt-4 pb-2 px-3.5 text-right'>
<div className='flex items-center'>
<p>Est. {campaignAsset?.asset} Reward</p>
<ClickTooltip text={<span><span>The amount of onboarding reward that you’ll earn.&nbsp;</span><a target='_blank' href='/campaigns' className='text-primary underline hover:no-underline decoration-primary cursor-pointer'>Learn more</a></span>} />
</div>
{
Number(reward) > 0 &&
<div className="flex items-center space-x-1">
<span>+</span>
<div className="h-5 w-5 relative">
<Image
src={resolveImgSrc(campaign)}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-md object-contain" />
</div>
<p>
{reward} {campaignAsset?.asset}
</p>
</div>
}
</motion.div>
}
export default Campaign
@@ -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<EstimatesProps> = ({
currencies,
source,
destination,
selected_currency,
fee }) => {
const parsedFee = parseFloat(fee.toFixed(selected_currency?.precision))
const currencyName = selected_currency?.asset || " "
return <>
<div className="mt-2 flex flex-row items-baseline justify-between">
<label className="inline-flex items-center text-left text-primary-text-placeholder">
Bridge fee
</label>
<div className="text-right">
<span>{parsedFee}</span> <span>{currencyName}</span>
</div>
</div>
{
source
&& source?.isExchange === false
&& selected_currency &&
<NetworkGas network={source} currencies={currencies} selected_currency={selected_currency} />
}
<EstimatedArrival currency={selected_currency} destination={destination} />
</>
}
type NetworkGasProps = {
network: Layer & { isExchange: false },
selected_currency: Currency,
currencies: Currency[]
}
const NetworkGas: FC<NetworkGasProps> = ({ 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 <div className="mt-2 flex flex-row items-baseline justify-between">
<label className="inline-flex items-center text-left text-primary-text-placeholder">
Estimated gas
</label>
<div className="text-right flex items-center gap-1">
{isGasLoading ? <div className='h-[10px] w-10 bg-gray-500 rounded-sm animate-pulse' /> : estimatedGas} <span>{network?.native_currency ?? selected_currency.asset}</span>
</div>
</div>
}
type EstimatedArrivalProps = {
destination?: Layer | null,
currency?: Currency | null
}
const EstimatedArrival: FC<EstimatedArrivalProps> = ({ currency, destination }) => {
const destinationNetworkCurrency = (destination && currency) ? GetNetworkCurrency(destination, currency.asset) : null
const destinationNetwork = GetDefaultNetwork(destination, currency?.asset)
return <div className="mt-2 flex flex-row items-baseline justify-between">
<label className="block text-left text-primary-text-placeholder">
Estimated arrival
</label>
<span className="text-right">
{
destinationNetworkCurrency?.status == 'insufficient_liquidity' ?
<span>Up to 2 hours (delayed)</span>
:
<AverageCompletionTime time={destinationNetwork?.average_completion_time} />
}
</span>
</div>
}
export default DetailedEstimates
@@ -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<WillReceiveProps> = ({ receive_amount, currency, to, currencies, refuel }) => {
const parsedReceiveAmount = parseFloat(receive_amount?.toFixed(currency?.precision) || "")
const destinationNetworkCurrency = (to && currency) ? GetNetworkCurrency(to, currency.asset) : null
return <>
<span className="md:font-semibold text-sm md:text-base text-secondary-text leading-8 md:leading-8 flex-1">
You will receive
</span>
<div className='flex items-center space-x-2'>
<span className="text-sm md:text-base">
{
parsedReceiveAmount > 0 ?
<div className="font-semibold md:font-bold text-right leading-4">
<p>
<>{parsedReceiveAmount}</>
&nbsp;
<span>
{destinationNetworkCurrency?.name}
</span>
</p>
{refuel && <Refuel
currencies={currencies}
currency={currency}
to={to}
refuel={refuel}
/>}
</div>
: '-'
}
</span>
</div>
</>
}
type RefuelProps = {
currency?: Currency | null;
to?: Layer | null;
currencies: Currency[];
refuel: boolean
}
export const Refuel: FC<RefuelProps> = ({ 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 ? <p className='text-[12px] text-secondary-text/50'>
<>+</> <span>{truncated_refuel} {destination_native_asset}</span>
</p>
: null
}
</>
}
@@ -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 (
<>
<div className="mx-auto relative w-full rounded-lg border border-secondary-500 hover:border-secondary-300 bg-secondary-700 px-3.5 py-3 z-[1] transition-all duration-200">
<Accordion type="single" collapsible>
<AccordionItem value='item-1'>
<AccordionTrigger className="items-center flex w-full relative gap-2 rounded-lg text-left text-base font-medium">
<ReceiveAmounts
currencies={currencies}
currency={currency}
to={to}
receive_amount={receive_amount}
refuel={!!refuel}
/>
</AccordionTrigger>
<AccordionContent className="text-sm text-secondary-text font-normal">
<DetailedEstimates
currencies={currencies}
networks={networks}
fee={fee}
selected_currency={currency}
source={from}
destination={to}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{
values.to &&
values.currency &&
<Campaign
destination={values.to}
selected_currency={values.currency}
fee={fee}
/>
}
</>
)
}
+76
View File
@@ -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 (
<div className="styled-scroll">
<main className="styled-scroll">
<div className="min-h-screen overflow-hidden relative font-robo">
<Navbar />
<div className="mx-auto max-w-xl bg-secondary-900 shadow-card rounded-lg w-full overflow-hidden relative px-0 md:px-8 py-6 h-[500px] min-h-[550px]">
<MessageComponent>
<MessageComponent.Content icon="red">
<MessageComponent.Header>
Unable to complete the request
</MessageComponent.Header>
<MessageComponent.Description>
<p>
<span>Sorry, but we were unable to complete this request.&nbsp;</span>
{
extension_error ?
<span>It seems that some of your extensions are preventing the app from running.</span>
: <span>We are informed, and are now investigating the issue.</span>
}
</p>
<p>
{
extension_error ?
<span>Please disable extensions and try again or open in incognito mode. If the issue keeps happening,&nbsp;</span>
:
<span>Please try again. If the issue keeps happening,&nbsp;</span>
}
<span className="underline cursor-pointer text-primary "><ContactSupport>contact our support team.</ContactSupport></span>
</p>
</MessageComponent.Description>
</MessageComponent.Content>
<MessageComponent.Buttons>
<div className="flex flex-row text-primary-text text-xs sm:text-base space-x-2">
<div className='basis-1/3'>
{
<GoHomeButton>
<SubmitButton text_align="left" buttonStyle="outline" isDisabled={false} isSubmitting={false} icon={<Home className="h-5 w-5" aria-hidden="true" />}>
Go home
</SubmitButton>
</GoHomeButton>
}
</div>
<div className='basis-2/3'>
<SubmitButton button_align="right" text_align="left" isDisabled={false} isSubmitting={false}
onClick={() =>
resetErrorBoundary()
}
icon={<RefreshCcw className="h-5 w-5" aria-hidden="true" />}>
Try Again
</SubmitButton>
</div>
</div>
</MessageComponent.Buttons>
</MessageComponent>
</div>
<div id="widget_root" />
</div>
</main>
</div>
);
}
+53
View File
@@ -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 (
<div className="w-full grid grid-cols-5 px-6 mt-3" >
{
goBack &&
<IconButton onClick={goBack}
aria-label="Go back"
icon={
<ArrowLeft strokeWidth="3" />
}>
</IconButton>
}
<div className='justify-self-center self-center col-start-2 col-span-3 mx-auto overflow-hidden md:hidden'>
<GoHomeButton />
</div>
<div className="col-start-5 justify-self-end self-center flex items-center gap-4">
<WalletsHeader />
<IconButton className="relative hidden md:inline" onClick={() => {
boot();
show();
updateWithProps()
}}
icon={
<ChatIcon className="h-6 w-6" strokeWidth="2" />
}>
</IconButton>
<div className="fixed-width-container">
<BridgeMenu />
</div>
</div>
</div>
)
}
export default HeaderWithMenu
+342
View File
@@ -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<React.HTMLProps<HTMLInputElement>, '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<Input> = forwardRef<HTMLInputElement, Input>(function Address
({ name, canFocus, close, address_book, disabled, isPartnerWallet, partnerImage, partner }, ref) {
const {
values,
setFieldValue
} = useFormikContext<SwapFormValues>();
const [wrongNetwork, setWrongNetwork] = useState(false)
const inputReference = useRef<HTMLInputElement>(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<string | undefined>(values?.destination_address || "")
const [validInputAddress, setValidInputAddress] = useState<string | undefined>('')
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<HTMLInputElement>) => {
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 (<>
<div className='w-full flex flex-col justify-between h-full text-primary-text'>
<div className='flex flex-col self-center grow w-full'>
<div className={`flex flex-col self-center grow w-full space-y-3`}>
<div className="text-left">
<label htmlFor={name}>Address</label>
{isPartnerWallet && partner && <span className='truncate text-sm text-indigo-200'> ({partner?.display_name})</span>}
<div className="flex flex-wrap flex-col md:flex-row">
<div className="relative flex grow rounded-lg shadow-sm mt-1.5 bg-secondary-700 border-secondary-500 border focus-within:ring-0 focus-within:ring-primary focus-within:border-primary">
{isPartnerWallet &&
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
{
partnerImage &&
<Image alt="Partner logo" className='rounded-md object-contain' src={partnerImage} width="24" height="24"></Image>
}
</div>
}
<input
onChange={handleInputChange}
value={inputValue}
placeholder={placeholder}
autoCorrect="off"
type={"text"}
disabled={disabled || !!(connectedWallet && values.destination_address)}
name={name}
id={name}
ref={inputReference}
tabIndex={0}
className={`${isPartnerWallet ? 'pl-11' : ''} disabled:cursor-not-allowed grow h-12 border-none leading-4 block font-semibold w-full bg-secondary-700 rounded-lg truncate hover:overflow-x-scroll focus:ring-0 focus:outline-none`}
/>
{
inputValue && !disabled &&
<span className="inline-flex items-center mr-2">
<div className="text-xs flex items-center space-x-2 md:ml-5 bg-secondary-500 rounded-md border border-secondary-500">
<button
type="button"
className="p-0.5 duration-200 transition hover:bg-secondary-400 rounded-md border border-secondary-500 hover:border-secondary-200"
onClick={handleRemoveDepositeAddress}
>
<div className="flex items-center px-2 text-sm py-1 font-semibold">
Clear
</div>
</button>
</div>
</span>
}
</div>
{errorMessage &&
<div className="basis-full text-xs text-primary">
{errorMessage}
</div>
}
{wrongNetwork && !inputValue &&
<div className="basis-full text-xs text-primary">
{
destination?.internal_name === KnownInternalNames.Networks.StarkNetMainnet
? <span>Please switch to Starknet Mainnet with your wallet and click Autofill again</span>
: <span>Please switch to Starknet Goerli with your wallet and click Autofill again</span>
}
</div>
}
</div>
</div>
{
validInputAddress &&
<div onClick={handleSetNewAddress} className={`text-left min-h-12 cursor-pointer space-x-2 border border-secondary-300 bg-secondary-600 shadow-xl flex text-sm rounded-md items-center w-full transform hover:bg-secondary-500 transition duration-200 px-2 py-2 hover:border-secondary-500 hover:shadow-xl`}>
<div className='flex text-primary-text bg-secondary-400 flex-row items-left rounded-md p-2'>
{
destinationIsStarknet && connectedWallet ?
<connectedWallet.icon className='rounded-md' alt={connectedWallet?.address} width={25} height={25} />
:
<AddressIcon address={validInputAddress} size={25} />
}
</div>
<div className="flex flex-col grow">
<div className="block text-md font-medium text-primary-text">
{shortenAddress(validInputAddress)}
</div>
</div>
<div className='flex text-primary-text flex-row items-left px-2 py-1 rounded-md'>
Select
</div>
</div>
}
{
!disabled
&& !inputValue
&& destination
&& !destination?.isExchange
&& destinationNetwork
&& provider
&& !connectedWallet &&
<div onClick={() => { 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`}>
<div className='flex text-primary-text flex-row items-left bg-secondary-400 px-2 py-1 rounded-md'>
<WalletIcon className="w-6 h-6 text-primary-text" />
</div>
<div className="flex flex-col">
<div className="block text-sm font-medium">
Autofill from wallet
</div>
<div className="text-gray-500">
Connect your wallet to fetch the address
</div>
</div>
</div>
}
{
destination?.isExchange
&& !inputAddressIsValid
&& values.currency
&& destinationNetwork
&&
<div className='text-left p-4 bg-secondary-800 text-primary-text rounded-lg border border-secondary-500'>
<div className="flex items-center">
<Info className='h-5 w-5 text-primary-600 mr-3' />
<label className="block text-sm md:text-base font-medium leading-6">How to find your {destination?.display_name} deposit address</label>
</div>
<ul className="list-disc font-light space-y-1 text-xs md:text-sm mt-2 ml-8 text-primary-text">
<li>Go to the Deposits page</li>
<li>
<span>Select</span>
<span className="inline-block mx-1">
<span className='flex gap-1 items-baseline text-sm '>
<Image src={settings.resolveImgSrc(values.currency)}
alt="Project Logo"
height="15"
width="15"
className='rounded-sm'
/>
<span className="text-primary-text">{values.currency.asset}</span>
</span>
</span>
<span>as asset</span>
</li>
<li>
<span>Select</span>
<span className="inline-block mx-1">
<span className='flex gap-1 items-baseline text-sm '>
<Image src={settings.resolveImgSrc(destinationNetwork)}
alt="Project Logo"
height="15"
width="15"
className='rounded-sm'
/>
<span className="text-primary-text">{destinationNetwork?.display_name}</span>
</span>
</span>
<span>as network</span>
</li>
</ul>
</div>
}
{
!disabled && valid_addresses?.length > 0 && !inputValue &&
<div className="text-left">
<label className="">Recently used</label>
<RadioGroup disabled={disabled} value={values.destination_address} onChange={handleSelectAddress}>
<div className="rounded-md overflow-y-auto styled-scroll max-h-[300px]">
{valid_addresses?.map((a) => (
<RadioGroup.Option
key={a.address}
value={a.address}
disabled={disabled}
className={({ disabled }) =>
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 (
<RadioGroup.Description
as="span"
className={`space-x-2 flex text-sm rounded-md items-center w-full transform transition duration-200 px-2 py-1.5 border border-secondary-900 hover:border-secondary-500 hover:bg-secondary-700/70 hover:shadow-xl ${checked && 'border-secondary-700'}`}
>
<div className='flex bg-secondary-400 text-primary-text flex-row items-left rounded-md p-2'>
<AddressIcon address={a.address} size={20} />
</div>
<div className="flex flex-col">
<div className="block text-sm font-medium">
{shortenAddress(a.address)}
</div>
<div className="text-gray-500">
{
difference_in_days === 0 ?
<>Used today</>
:
(difference_in_days > 1 ?
<>Used {difference_in_days} days ago</>
: <>Used yesterday</>)
}
</div>
</div>
</RadioGroup.Description>
)
}}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
}
</div>
</div>
</div>
</>
)
});
export default Address
+46
View File
@@ -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: () => <NumericInput
label={<div className="flex items-center w-full justify-between">
<div className="flex items-center space-x-2">
<p>Amount</p>
</div>
</div>}
disabled={true}
placeholder='0.01234'
name="amount"
className="rounded-r-none text-primary-text"
>
</NumericInput>
});
const AmountField = forwardRef(function AmountField(_, ref: any) {
const { values } = useFormikContext<SwapFormValues>();
const { from, to } = values
const name = "amount"
if (!from || !to)
return <NumericInput
label={<div className="flex items-center w-full justify-between">
<div className="flex items-center space-x-2">
<p>Amount</p>
</div>
</div>}
disabled={true}
placeholder='0.01234'
name={name}
className="rounded-r-none text-primary-text"
>
</NumericInput>
return <EnhancedAmountField />
});
export default AmountField
+102
View File
@@ -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<SwapFormValues>();
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<Currency>) => {
setFieldValue(name, item.baseObject, true)
}, [name])
return <PopoverSelectWrapper values={currencyMenuItems} value={value} setValue={handleSelect} disabled={!value?.isAvailable?.value} />;
};
export function GenerateCurrencyMenuItems(currencies: Currency[], source: Layer, resolveImgSrc: (item: Layer | Currency) => string, lockedCurrency?: Currency, balances?: Balance[]): SelectMenuItem<Currency>[] {
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<Currency> = {
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
+136
View File
@@ -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<SwapFormValues>();
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 (<>
<NumericInput
label={<AmountLabel detailsAvailable={!!(from && to && amount)}
maxAllowedAmount={maxAllowedDisplayAmont}
minAllowedAmount={minAllowedAmount}
isBalanceLoading={(isBalanceLoading || isGasLoading)}
walletBalance={walletBalanceAmount}
/>}
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 ? <div className="text-xs flex items-center space-x-1 md:space-x-2 ml-2 md:ml-5">
<SecondaryButton onClick={handleSetMinAmount} size="xs">
MIN
</SecondaryButton>
<SecondaryButton onClick={handleSetMaxAmount} size="xs">
MAX
</SecondaryButton>
</div>
: <></>
}
<CurrencyFormField />
</NumericInput>
</>)
});
type AmountLabelProps = {
detailsAvailable: boolean;
minAllowedAmount: number;
maxAllowedAmount: number;
isBalanceLoading: boolean;
walletBalance?: number
}
const AmountLabel = ({
detailsAvailable,
minAllowedAmount,
maxAllowedAmount,
isBalanceLoading,
walletBalance,
}: AmountLabelProps) => {
return <div className="flex items-center w-full justify-between">
<div className="flex items-center space-x-2">
<p>Amount</p>
{
detailsAvailable &&
<div className="text-xs hidden md:flex text-secondary-text items-center">
<span>(Min:&nbsp;</span>{minAllowedAmount}<span>&nbsp;-&nbsp;Max:&nbsp;</span>{isBalanceLoading ? <span className="ml-1 h-3 w-6 rounded-sm bg-gray-500 animate-pulse" /> : <span>{maxAllowedAmount}</span>}<span>)</span>
</div>
}
</div>
{
walletBalance != undefined && !isNaN(walletBalance) &&
<div
className="border-primary-text text-xs text-primary-text flex items-center space-x-1">
<span>Balance:</span>
{isBalanceLoading ?
<span className="ml-1 h-3 w-6 rounded-sm bg-gray-500 animate-pulse" />
:
<span>{walletBalance}</span>}
</div>
}
</div>
}
export default EnhancedAmountField
+153
View File
@@ -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<SwapFormValues>();
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<Layer>[];
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<Layer>) => {
setFieldValue(name, item.baseObject, true)
}, [name])
return (<div className="rounded-xl p-3 bg-secondary-700">
<label htmlFor={name} className="block font-semibold text-secondary-text text-sm">
{label}
</label>
<div ref={ref} className={`mt-1.5 `}>
<CommandSelectWrapper
disabled={false}
valueGrouper={valueGrouper}
placeholder={placeholder}
setValue={handleSelect}
value={value}
values={menuItems}
searchHint={searchHint}
/>
</div>
</div>)
});
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<Layer>[] {
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<Layer> = {
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
+93
View File
@@ -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<HTMLInputElement>) => void;
}
// Use with Formik
const NumericInput: FC<Input> = forwardRef<HTMLInputElement, Input>(
function NumericInput({ label, pattern, disabled, placeholder, min, max, minLength, maxLength, precision, step, name, className, children, onChange }, ref) {
const { handleChange } = useFormikContext<SwapFormValues>();
const [field] = useField(name)
return <>
{label &&
<label htmlFor={name} className="block font-semibold text-secondary-text text-sm mb-1.5 w-full">
{label}
</label>
}
<div className="flex rounded-lg shadow-sm bg-secondary-700 border-secondary-500 border ">
<input
{...field}
pattern={pattern ? pattern : "^[0-9]*[.,]?[0-9]*$"}
inputMode="decimal"
autoComplete="off"
disabled={disabled}
placeholder={placeholder}
autoCorrect="off"
min={min}
max={max}
minLength={minLength}
maxLength={maxLength}
onInput={(event: React.ChangeEvent<HTMLInputElement>) => { 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 &&
<span className="inline-flex items-center">
{children}
</span>
}
</div>
</>;
});
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
+80
View File
@@ -0,0 +1,80 @@
import { ChevronRight, ExternalLink } from "lucide-react"
import LinkWrapper from "../LinkWraapper"
import { ReactNode } from "react"
const Menu = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col gap-3 mt-3">
{children}
<div style={{ height: '70px' }} />
</div>
}
const Group = ({ children }: { children: JSX.Element | JSX.Element[] }) => {
return (
<div>
<div className="divide-y divide-secondary-500 rounded-md bg-secondary-700 overflow-hidden">
{children}
</div>
</div>
)
}
const Item = (function Item({ children, pathname, onClick, icon, target = '_self' }: MenuIemProps) {
return (
pathname ?
<LinkWrapper href={pathname} target={target} className="gap-4 flex relative cursor-pointer hover:bg-secondary-600 select-none w-full items-center px-4 py-3 outline-none text-primary-text">
<div>
{icon}
</div>
<p className="text-primary-text">{children}</p>
{
target === '_self' ?
<ChevronRight className="h-4 w-4 absolute right-3" />
:
<ExternalLink className="h-4 w-4 absolute right-3" />
}
</LinkWrapper>
:
<button
type="button"
onClick={onClick}
className={`gap-4 flex relative cursor-pointer hover:bg-secondary-600 select-none items-center px-4 py-3 outline-none w-full text-primary-text`}
>
<div>
{icon}
</div>
<p className="text-primary-text">{children}</p>
<ChevronRight className="h-4 w-4 absolute right-3" />
</button>
)
})
export enum ItemType {
button = 'button',
link = 'link'
}
type Target = '_blank' | '_self'
type MenuIemProps = {
children: ReactNode;
pathname?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
icon: JSX.Element;
target?: Target;
};
const Footer = ({ children }: { children: JSX.Element | JSX.Element[] }) => {
return (
<div className={`text-primary-text text-base border-t border-secondary-500 fixed inset-x-0 bottom-0 z-30 bg-secondary-900 shadow-widget-footer px-6 py-4 w-full `}>
{children}
</div>
)
}
Menu.Group = Group
Menu.Item = Item
Menu.Footer = Footer
export default Menu
+240
View File
@@ -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<boolean>()
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) => <Map {...props}></Map>,
className: 'plausible-event-name=Roadmap'
},
]
}
const handleCloseFeedback = () => {
setOpenFeedbackModal(false)
}
return <>
<span className="text-secondary-text cursor-pointer relative">
{
<>
<div className="relative top-">
<IconButton onClick={() => setOpenTopModal(true)} icon={
<MenuIcon strokeWidth={3} />
}>
</IconButton>
</div>
<Modal show={openTopModal} setShow={setOpenTopModal} header={<h2 className="font-normal leading-none tracking-tight">Menu</h2>}>
<div className="text-sm font-medium focus:outline-none h-full">
<Menu>
<WalletsMenu />
<Menu.Group>
<>
{
router.pathname != '/' &&
<Menu.Item pathname='/' icon={<Home className="h-5 w-5" />} >
Home
</Menu.Item>
}
</>
<>
{
router.pathname != '/transactions' &&
<Menu.Item pathname='/transactions' icon={<ScrollText className="h-5 w-5" />} >
Transfers
</Menu.Item>
}
</>
<>
{!embedded && router.pathname != '/campaigns' &&
<Menu.Item pathname='/campaigns' icon={<Gift className="h-5 w-5" />} >
Campaigns
</Menu.Item>
}
</>
</Menu.Group>
<Menu.Group>
<Menu.Item onClick={() => {
boot();
show();
updateWithProps();
}} target="_blank" icon={<ChatIcon strokeWidth={2} className="h-5 w-5" />} >
Help
</Menu.Item>
<Menu.Item pathname='https://docs.bridge.lux.network/' target="_blank" icon={<BookOpen className="h-5 w-5" />} >
Docs for Users
</Menu.Item>
<Menu.Item pathname='https://docs.bridge.lux.network/user-docs/partners-and-integrations' target="_blank" icon={<Users className="h-5 w-5" />} >
Docs for Partners
</Menu.Item>
</Menu.Group>
<Menu.Group>
<Menu.Item pathname='https://docs.bridge.lux.network/user-docs/information/privacy-policy' target="_blank" icon={<Shield className="h-5 w-5" />} >
Privacy Policy
</Menu.Item>
<Menu.Item pathname='https://docs.bridge.lux.network/user-docs/information/terms-of-services' target="_blank" icon={<LibraryIcon className="h-5 w-5" />} >
Terms of Service
</Menu.Item>
</Menu.Group>
<Menu.Group>
<Popover
opener={
<Menu.Item onClick={() => setOpenFeedbackModal(true)} target="_blank" icon={<MessageSquarePlus className="h-5 w-5" />} >
Suggest a Feature
</Menu.Item>
}
isNested={true}
show={openFeedbackModal}
header="Suggest a Feature"
setShow={setOpenFeedbackModal} >
<div className="p-0 md:max-w-md">
<SendFeedback onSend={handleCloseFeedback} />
</div>
</Popover>
</Menu.Group>
<div className="space-y-3 w-full">
<hr className="border-secondary-500" />
<p className="text-primary-text-muted flex justify-center my-3">Media links & suggestions:</p>
</div>
<div className="grid grid-cols-2 gap-2 justify-center">
{navigation.social.map((item, index) => (
<Link key={index} target="_blank" href={item.href} className={`flex relative bg-secondary-700 hover:bg-secondary-600 rounded-md cursor-pointer select-none items-center outline-none text-primary-text ${item.className}`}>
<div className="p-2 w-full flex justify-center gap-1">
<item.icon className="h-5 w-5" aria-hidden="true" />
<p>{item.name}</p>
</div>
</Link>
))}
</div>
{
router.pathname != '/auth' &&
<Menu.Footer>
<Menu.Group>
{
userType == UserType.AuthenticatedUser ?
<div>
<div
className={`gap-4 flex justify-between items-center relative select-none px-4 py-3 outline-none w-full text-primary-text`}
>
<div className="font-normal flex gap-2 items-center">
<UserCircle2 className="h-5 w-5" />
<p>{email && <UserEmail email={email} />}</p>
</div>
<button type="button" className="text-primary hover:text-primary-600" onClick={handleLogout}>
Sign out
</button>
</div>
</div>
:
<Menu.Item pathname='/auth' icon={<LogIn className="h-5 w-5" />} >
Sign in
</Menu.Item>
}
</Menu.Group>
</Menu.Footer>
}
</Menu>
</div>
</Modal>
</>
}
</span >
</>
}
const UserEmail = ({ email }: { email: string }) => {
return shortenEmail(email, 22)
}
+31
View File
@@ -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<Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & LinkProps & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLAnchorElement>> = (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 (
<Link
{...props}
href={{
pathname: pathname,
query: {
...resolvePersistantQueryParams(router.query),
...query
}
}}
>
{children}
</Link>
)
}
export default LinkWrapper
+28
View File
@@ -0,0 +1,28 @@
import { FC, useEffect } from "react"
import { useLoadingState } from "../context/loadingContext"
type Props = {
name: string
}
const LoadingCard: FC<Props> = ({ name }) => {
return <div
className={`bg-secondary-900 md:shadow-card rounded-lg w-full sm:overflow-hidden relative`}
>
<div className='text-center text-xl text-secondary-100'>
</div>
<div className="relative px-6">
<div className="flex items-start">
<div className={`flex flex-nowrap grow`}>
<div className="w-full pb-6 flex flex-col justify-between space-y-5 text-secondary-text h-full">
<div className="sm:min-h-[504px] flex flex-col justify-between">
<div className="relative m-auto origin-center">
</div>
</div>
</div>
</div>
</div>
</div>
<div id="widget_root" />
</div>
}
export default LoadingCard
+84
View File
@@ -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 = <FailIcon />;
break;
case 'green':
iconStyle = <SuccessIcon />;
break;
case 'yellow':
iconStyle = <DelayIcon />
break
case 'gray':
iconStyle = CancelIcon
break
}
return iconStyle
}
const MessageComponent = ({ children }) => {
return <div className="w-full flex flex-col h-full justify-between pt-6 min-h-full">
{children}
</div>
}
const Content = ({ children, icon, center }: MessageComponentProps) => {
return (
center ?
<div className='flex flex-col self-center grow w-full'>
<div className='flex self-center grow w-full'>
<div className='flex flex-col space-y-8 self-center w-full'>
<div className='flex place-content-center'>{constructIcons(icon)}</div>
{children}
</div>
</div>
</div>
:
<div className='space-y-3'>
<div className='flex place-content-center'>{constructIcons(icon)}</div>
{children}
</div>
)
}
const Header = ({ children }) => {
return <div className='md:text-3xl text-lg font-bold text-primary-text leading-6 text-center'>
{children}
</div>
}
const Description = ({ children }) => {
return <div className="text-base font-medium space-y-6 text-secondary-text text-center mb-6">
{children}
</div>
}
const Buttons = ({ children }) => {
return <div className="space-y-3">
{children}
</div>
}
MessageComponent.Content = Content
MessageComponent.Header = Header
MessageComponent.Description = Description
MessageComponent.Buttons = Buttons
export default MessageComponent
+52
View File
@@ -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<boolean>()
useEffect(() => {
setEmbedded(inIframe())
}, [])
return (
<div className="styled-scroll">
<div className="min-h-screen overflow-hidden relative font-robo">
<div className="mx-auto max-w-xl bg-secondary-900 shadow-card rounded-lg w-full overflow-hidden relative px-0 md:px-8 py-6 h-[500px] min-h-[550px]">
<MessageComponent>
<MessageComponent.Content icon="red">
<MessageComponent.Header>
Sorry
</MessageComponent.Header>
<MessageComponent.Description>
<div className="text-secondary-text space-y-5 text-left">
<div className="space-y-2">
<p className="text-primary-text">
It seems like you’ve either:
</p>
<ul className="text-secondary-text list-disc ml-4 mt-0 ">
<li>Disabled cookies</li>
<li>Or using Bridge in a partner’s page in Incognito mode</li>
</ul>
</div>
<p className="text-secondary-text">Unforunately, we can’t run in those conditions 🙁</p>
</div>
{
embedded &&
<Link target="_blank" href={window?.location?.href} className="bg-primary text-primary-buttonTextColor py-3 px-3 border border-primary disabled:border-primary-900 shadowed-button 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 shadow-md hover:shadow-xl transform hover:-translate-y-0.5 transition duration-200 ease-in-out">
Try on Bridge
</Link>
}
</MessageComponent.Description>
</MessageComponent.Content>
</MessageComponent>
</div>
</div>
</div >
);
}
export default NoCookies;
+73
View File
@@ -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<NavRadioProps> = ({ value, items, setSelected, label, disabled }) => {
const onchange = (item: NavRadioOption) => {
setSelected(item.value)
}
return (
<RadioGroup value={items.find(i => i.value === value)} disabled={disabled} onChange={onchange} className="mt-2 w-full my-4">
<RadioGroup.Label className="font-normal text-secondary-text text-sm">{label}</RadioGroup.Label>
<div className={`grid grid-cols-${items?.length} gap-1 md:gap-2 p-0.5 md:p-2 rounded-lg bg-secondary-700 border-secondary-500 border`}>
{items.map((option) => (
<RadioGroup.Option
key={option.value}
value={option}
className={({ checked }) =>
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 &&
<div className="flex items-center md:p-0 p-1.5 text-xs md:text-base">
On-Ramp
</div>
}
{
option.value === SwapType.OffRamp &&
<div className="flex items-center space-x-1 md:p-0 p-1.5 text-xs md:text-base ">
Off-Ramp
</div>
}
{
option.value === SwapType.CrossChain &&
<div className="inline-flex items-center ">
<span className="flex items-center space-x-1 md:p-0 p-1.5 text-xs md:text-base">
Cross-Chain
</span>
<span className="absolute ml-1 -top-1 -right-2 shadow-sm rounded bg-primary px-2 text-[10px] leading-4 font-semibold text-primary-text"> New </span>
</div>
}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)
}
export default OptionToggle
+27
View File
@@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={classNames(
"relative h-4 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 transition-all bg-primary"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+57
View File
@@ -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<QRCodeModalProps> = ({ qrUrl, className, iconSize, iconClassName }) => {
const qrCode =
<QRCodeSVG
className="rounded-lg"
value={qrUrl}
includeMargin={true}
size={160}
level={"H"}
/>
return (
<>
<Popover>
<PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className={classNames(className)}>
<div className="flex items-center gap-1 cursor-pointer">
<QrCode className={iconClassName} width={iconSize ? iconSize : 16} height={iconSize ? iconSize : 16} />
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Show QR code</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent className="w-full p-4" side="left">
<motion.div whileHover={{
scale: 1.2,
transition: { duration: 0.5 },
}}>
{qrCode}
</motion.div>
</PopoverContent>
</Popover>
</>
)
}
export default QRCodeModal
+106
View File
@@ -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 }) => (
<Text>
Thanks for choosing Bridge!
</Text>
);
const CustomAvatar: AvatarComponent = ({ address, size }) => {
return <AddressIcon address={address} size={size} />
};
return (
<WagmiConfig config={wagmiConfig}>
<RainbowKitProvider avatar={CustomAvatar} modalSize="compact" chains={chains} theme={theme}
appInfo={{
appName: 'Bridge',
learnMoreUrl: 'https://docs.bridge.lux.network/',
disclaimer: disclaimer
}}>
{children}
</RainbowKitProvider>
</WagmiConfig>
)
}
export default RainbowKitComponent
+53
View File
@@ -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<SwapFormValues>();
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 &&
<WarningMessage messageType="warning" className="mt-4">
<div className="font-normal text-primary-text">
<div>
You might not be able to complete the transaction.
</div>
<div onClick={() => onSubmit(walletBalance, networkGas)} className="cursor-pointer border-b border-dotted border-primary-text w-fit hover:text-primary hover:border-primary text-primary-text">
<span>Reserve</span> <span>{gasToReserveFormatted}</span> <span>{values?.currency?.asset}</span> <span>for gas.</span>
</div>
</div>
</WarningMessage>
)
}
export default ReserveGasNote
+48
View File
@@ -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 (
<motion.div
animate={{ height: height || "auto" }}
className="relative overflow-hidden"
>
<AnimatePresence initial={false}>
<motion.div
key={JSON.stringify(children, ignoreCircularReferences())}
initial={{
x: 382,
}}
animate={{
x: 0,
}}
exit={{
x: -382,
}}
>
<div
ref={ref}
className={`${className}`}
>
{children}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
);
}
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;
};
};
+329
View File
@@ -0,0 +1,329 @@
import { ChevronRight, Clock } from "lucide-react"
import BackgroundField from "./backgroundField"
import { classNames } from "./utils/classNames"
export const SwapHistoryComponentSceleton = () => {
return <div className="animate-pulse">
<div className=" mb-10 ">
<div className="-mx-4 mt-10 sm:-mx-6 md:mx-0 md:rounded-lg ">
<table className="min-w-full divide-y divide-secondary-500">
<thead>
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-500 sm:pl-6">
<div className="grid grid-cols-1 gap-4">
<div className="hidden lg:block">
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</div>
</th>
<th
scope="col"
className="hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-500 lg:table-cell"
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-500 "
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th
scope="col"
className="hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-500 lg:table-cell"
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th
scope="col"
className="hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-500 lg:table-cell"
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th
scope="col"
className="hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-500 lg:table-cell"
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
</th>
</tr>
</thead>
<tbody>
{[...Array(5)]?.map((item, index) => (
<tr key={index}>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'relative py-4 pl-4 sm:pl-6 pr-3 text-sm'
)}
>
<div className="text-primary-text hidden lg:block">
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</div>
{index !== 0 ? <div className="absolute right-0 left-6 -top-px h-px bg-secondary-500" /> : null}
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'hidden px-3 py-3.5 text-sm text-primary-text lg:table-cell'
)}
>
<div className="flex space-x-2">
<div className="rounded-full bg-slate-700 h-4 w-4"></div>
<div className="grid grid-cols-4 items-center">
<div className="h-2 w-16 bg-slate-700 rounded col-span-3"></div>
</div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'px-3 py-3.5 text-sm text-primary-text table-cell'
)}
>
<div className="flex space-x-2">
<div className="rounded-full bg-slate-700 h-4 w-4"></div>
<div className="grid grid-cols-4 items-center">
<div className="h-2 w-16 bg-slate-700 rounded col-span-3"></div>
</div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'hidden px-3 py-3.5 text-sm text-primary-text lg:table-cell'
)}
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'relative px-3 py-3.5 text-sm text-primary-text'
)}
>
<div className="grid grid-cols-1 gap-4">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'px-3 py-3.5 text-sm text-primary-text hidden lg:table-cell'
)}
>
<div className="flex space-x-2">
<div className="rounded bg-slate-700 h-2 w-2"></div>
<div className="grid grid-cols-1 items-center">
<div className="h-2 w-16 bg-slate-700 rounded col-span-1"></div>
</div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'px-3 py-3.5 text-sm text-primary-text hidden lg:table-cell'
)}
>
<div className="grid grid-cols-2 gap-4">
<div className="h-2 w-12 bg-slate-700 rounded col-span-1"></div>
<div className="h-2 w-8 bg-slate-700 rounded col-span-1"></div>
</div>
</td>
<td
className={classNames(
index === 0 ? '' : 'border-t border-secondary-500',
'px-3 py-3.5 text-sm text-primary-text hidden lg:table-cell'
)}
>
<div className="grid grid-cols-1 gap-4">
<ChevronRight className="h-5 w-5 text-slate-700" />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
}
export const SwapDetailsComponentSceleton = () => {
return <div className="animate-pulse"><div className="w-full grid grid-flow-row">
<div className="rounded-md bg-secondary-900 w-full grid grid-flow-row">
<div className="items-center block text-base font-lighter leading-6 text-secondary-text">
<div className="flex justify-between items-baseline">
<div className="h-2 m-2 w-1/4 bg-slate-400 rounded col-span-1"></div>
<div className="h-2 m-2 w-1/4 bg-slate-700 rounded col-span-1"></div>
</div>
{[...Array(8)]?.map((item, index) => (
<div key={index}>
<hr className='horizontal-gradient my-1' />
<div className="flex justify-between items-baseline">
<div className="h-2.5 m-2 w-1/4 bg-slate-700 rounded-full col-span-1"></div>
<div className="h-2 m-2 w-1/4 bg-slate-700 rounded col-span-1"></div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
}
export const DocInFrameSceleton = () => {
return <div className="shadow rounded-md w-full mx-auto px-2 md:px-4">
<div className="animate-pulse flex space-x-4">
<div className="flex-1 items-center space-y-6 py-1 content-start">
<div className="h-4 mx-auto w-1/2 place-self-center justify-self-center self-center bg-slate-700 rounded mb-4"></div>
<div className="space-y-6">
{[...Array(8)]?.map((item, index) =>
<div className="space-y-4"
key={index}
>
<div className="grid grid-cols-3 gap-3">
<div className="h-2 bg-slate-700 rounded col-span-2"></div>
<div className="h-2 bg-slate-700 rounded col-span-1"></div>
</div>
<div className="h-2 bg-slate-700 rounded"></div>
</div>
)}
</div>
</div>
</div>
</div>
}
export const ExchangesComponentSceleton = () => {
return <>
{[...Array(12)]?.map((item, index) =>
<div
key={index}
className="animate-pulse bg-secondary-700 select-none rounded-lg py-5 px-3">
<div className="flex justify-between space-x-4 md:space-x-16 px-3">
<div className="flex space-x-2">
<div className="rounded-md bg-slate-700 h-8 w-8"></div>
<div className="grid grid-cols-5">
<div className="h-2 w-20 bg-slate-700 rounded col-span-3"></div>
</div>
</div>
<div className="rounded bg-slate-700 h-8 w-20 place-self-end py-3 px-4"></div>
</div>
</div>
)}
</>
}
export const RewardsComponentSceleton = () => {
return (
<div className="space-y-5">
<div className="space-y-2">
<div className="flex justify-start">
<div className="rounded-md w-48 bg-gray-500 h-[28px] animate-pulse" />
</div>
<div className=" bg-secondary-700 divide-y divide-secondary-500 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
<BackgroundField header={<span className="flex justify-between"><span>Pending Earnings</span><span>Next Airdrop</span></span>} withoutBorder>
<div className="flex justify-between w-full text-2xl">
<div className="flex items-center space-x-1">
<div className="w-32 h-6 rounded-md animate-pulse bg-gray-500" />
</div>
<div className="flex items-center space-x-1">
<Clock className="h-5" />
<div className="w-14 h-6 rounded-md animate-pulse bg-gray-500" />
</div>
</div>
</BackgroundField>
<BackgroundField header={<span className="flex justify-between"><span>Total Earnings</span><span>Current Value</span></span>} withoutBorder>
<div className="flex justify-between w-full text-slate-300 text-2xl">
<div className="flex items-center space-x-1">
<div className="w-40 h-6 rounded-md animate-pulse bg-gray-500" />
</div>
<div className="w-20 h-6 rounded-md animate-pulse bg-gray-500" />
</div>
</BackgroundField>
</div>
<div className="bg-secondary-700 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
<BackgroundField header='Daily Reward Claimed' withoutBorder>
<div className="flex flex-col w-full gap-2">
<div className="rounded-full h-4 bg-gray-500 w-full animate-pulse" />
<div className="flex justify-between w-full font-semibold text-sm ">
<div className="rounded-md w-20 h-5 animate-pulse bg-gray-500" />
<div className="rounded-md w-32 h-5 animate-pulse bg-gray-500" />
</div>
</div>
</BackgroundField>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-start">
<div className="rounded-md w-48 bg-gray-500 h-5 animate-pulse" />
</div>
<div className=" bg-secondary-700 rounded-lg shadow-lg border border-secondary-700 hover:border-secondary-500 transition duration-200">
<div className="p-3">
<div className="space-y-6">
{[...Array(4)]?.map((user, index) => (
<div key={index} className="items-center flex justify-between">
<div className="w-full h-4 rounded-md bg-gray-500 animate-pulse" />
</div>
))
}
</div>
</div>
</div>
</div>
</div>
)
}
export const RewardsComponentLeaderboardSceleton = () => {
return (
<div className="space-y-2">
<div className="flex justify-start">
<div className="rounded-md w-48 bg-gray-500 h-5 animate-pulse" />
</div>
<div className="bg-secondary-700 border border-secondary-700 hover:border-secondary-500 transition duration-200 rounded-lg">
<div className="p-3">
<div className="space-y-6">
{[...Array(4)]?.map((user, index) => (
<div key={index} className="items-center flex justify-between">
<div className="w-full h-4 rounded-md bg-gray-500 animate-pulse" />
</div>
))
}
</div>
</div>
</div>
</div>
)
}
@@ -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<T>({
setValue,
value,
disabled,
placeholder,
searchHint,
values,
valueGrouper
}: CommandSelectWrapperProps) {
const [showModal, setShowModal] = useState(false)
function openModal() {
setShowModal(true)
}
const handleSelect = useCallback((item: SelectMenuItem<T>) => {
setValue(item)
setShowModal(false)
}, [])
return (
<>
<div className="flex items-center relative">
<button
type="button"
onClick={openModal}
disabled={disabled}
className="rounded-lg focus-peer:ring-primary focus-peer:border-secondary-400 focus-peer:border focus-peer:ring-1 focus:outline-none disabled:cursor-not-allowed relative grow h-12 flex items-center text-left justify-bottom w-full pl-3 pr-2 py-2 bg-secondary-600 border border-secondary-500 font-semibold"
>
<span className='flex grow text-left items-center'>
{
value && <div className="flex items-center">
<div className="flex-shrink-0 h-6 w-6 relative">
{
value.imgSrc && <Image
src={value.imgSrc}
alt="Project Logo"
height="40"
width="40"
loading="eager"
priority
className="rounded-md object-contain"
/>
}
</div>
</div>
}
{value
?
<span className="ml-3 block font-medium text-primary-text flex-auto items-center">
{value?.name}
</span>
:
<span className="block font-medium text-primary-text-placeholder flex-auto items-center">
{placeholder}
</span>}
</span>
<span className="ml-3 right-0 flex items-center pr-2 pointer-events-none text-primary-text">
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</span>
</button>
</div>
<CommandSelect
setShow={setShowModal}
setValue={handleSelect}
show={showModal}
value={value}
searchHint={searchHint}
valueGrouper={valueGrouper}
values={values}
/>
</>
)
}
@@ -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<SelectMenuItemGroup>) {
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 (
<Modal height='full' show={show} setShow={setShow}>
{show ?
<CommandWrapper>
<CommandInput autoFocus={isDesktop} placeholder={searchHint} />
{
values.some(v => v.isAvailable.value === false && v.isAvailable.disabledReason === LayerDisabledReason.LockNetworkIsTrue) &&
<div className='text-xs text-left text-secondary-text mb-2'>
<Info className='h-3 w-3 inline-block mb-0.5' /><span>&nbsp;You&apos;re accessing Bridge from a partner&apos;s page. In case you want to transact with other networks, please open bridge.lux.network in a separate tab.</span>
</div>
}
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{groups.filter(g => g.items?.length > 0).map((group) => {
return (
<CommandGroup key={group.name} heading={group.name}>
{group.items.map(item =>
<CommandItem disabled={!item.isAvailable.value} value={item.name} key={item.id} onSelect={() => {
setValue(item)
setShow(false)
}}>
<SelectItem item={item} />
</CommandItem>)
}
</CommandGroup>)
})}
</CommandList>
</CommandWrapper>
: <></>
}
</Modal>
)
}
@@ -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 (
<CommandWrapper>
<CommandList>
{values.map(item =>
<CommandItem disabled={!item.isAvailable.value} value={item.id} key={item.id} onSelect={() => {
setValue(item)
}}>
<SelectItem item={item} />
</CommandItem>)
}
</CommandList>
</CommandWrapper>
)
}
export enum LayerDisabledReason {
LockNetworkIsTrue = '',
InsufficientLiquidity = 'Temporarily disabled. Please check later.'
}
@@ -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<T>({
setValue,
value,
values,
disabled
}: PopoverSelectWrapper) {
const [showModal, setShowModal] = useState(false)
const handleSelect = useCallback((item: SelectMenuItem<T>) => {
setValue(item)
setShowModal(false)
}, [])
return (
disabled ?
<div className="relative">
<div className='w-full py-0 px-3 md:px-5 border-transparent bg-transparent font-semibold rounded-md'>
<span className="flex items-center">
<div className="flex-shrink-0 h-6 w-6 relative">
{
value?.imgSrc && <Image
src={value?.imgSrc}
alt="Project Logo"
priority
height="40"
width="40"
className="rounded-md object-contain"
/>
}
</div>
<span className="ml-3 block truncate text-primary-text">{value?.name}</span>
</span>
</div>
</div>
:
<Popover open={showModal} onOpenChange={() => setShowModal(!showModal)}>
<PopoverTrigger asChild>
{
value &&
<div className="relative">
<button type='button' className='w-full py-0 pl-3 md:pl-5 pr-10 border-transparent bg-transparent font-semibold rounded-md'>
<span className="flex items-center">
<div className="flex-shrink-0 h-6 w-6 relative">
{
value.imgSrc && <Image
src={value.imgSrc}
alt="Project Logo"
priority
height="40"
width="40"
className="rounded-md object-contain"
/>
}
</div>
<span className="ml-3 block truncate text-primary-text">{value.name}</span>
</span>
<span className="ml-3 absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-primary-text">
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</span>
</button>
</div>
}
</PopoverTrigger>
<PopoverContent className="w-fit">
<PopoverSelect setValue={handleSelect} value={value} values={values} />
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,7 @@
import { ISelectMenuItem } from '../../Shared/Props/selectMenuItem'
export interface SelectProps {
values: ISelectMenuItem[],
value?: ISelectMenuItem;
setValue: (value: ISelectMenuItem) => void;
}
@@ -0,0 +1,41 @@
import { CurrencyDisabledReason } from "../../../Input/CurrencyFormField";
import { LayerDisabledReason } from "../../Popover/PopoverSelect";
export class SelectMenuItem<T> 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;
}
@@ -0,0 +1,27 @@
import { ISelectMenuItem } from "./Props/selectMenuItem";
import Image from 'next/image'
export default function SelectItem({ item }: { item: ISelectMenuItem }) {
return (<div className="flex items-center w-full">
<div className="flex-shrink-0 h-6 w-6 relative">
{item.imgSrc && <Image
src={item.imgSrc}
alt="Project Logo"
height="40"
width="40"
loading="eager"
className="rounded-md object-contain" />}
</div>
<div className="ml-4 flex items-center gap-3 justify-between w-full">
<p className='text-md font-medium'>
{item.name}
</p>
{
item.details &&
<p className="text-primary-text-muted">
{item.details}
</p>
}
</div>
</div>);
}
+208
View File
@@ -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<Props> = ({ 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<EmailFormValues> = {};
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 (
<>
<Formik
initialValues={initialValues}
onSubmit={sendEmail}
validateOnMount={true}
validate={validateEmail}
>
{({ isValid, isSubmitting }) => (
<Form autoComplete='true' className='w-full h-full'>
{
disclosureLogin ?
<div>
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="w-full text-left text-base font-light">
<div className='flex items-center justify-between'>
<p className='text-xl text-primary-text'>
Sign in with email
</p>
<div className='bg-secondary-500 hover:bg-secondary-500 p-0.5 rounded-md duration-200 transition'>
<ChevronDown
className={`${open ? 'rotate-180 transform' : ''
} h-5 text-secondary-text`}
/>
</div>
</div>
<p className='mt-2 text-left text-secondary-text'>
Securely store your exchange accounts, access your full transfer history and more.
</p>
</Disclosure.Button>
<Disclosure.Panel className="text-sm text-secondary-text font-normal mt-4">
<div className='grid gap-4 grid-cols-5 items-center'>
<div className="relative rounded-md shadow-sm col-span-3">
<Field name="email">
{({ field }) => (
<input
{...field}
id='email'
placeholder="[email protected]"
autoComplete="email"
type="email"
className="h-12 pb-1 pt-0 text-primary-text focus:ring-primary focus:border-primary border-secondary-500 pr-42 block
placeholder:text-secondary-text placeholder:text-sm placeholder:font-normal placeholder:opacity-50 bg-secondary-700 w-full font-semibold rounded-md"
/>
)}
</Field>
</div>
<div className='col-start-4 col-span-2'>
<SubmitButton isDisabled={!isValid} isSubmitting={isSubmitting} >
Continue
</SubmitButton>
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
</div>
:
<Widget>
<Widget.Content center={true}>
<User className='w-16 h-16 text-primary self-center mt-auto' />
<div>
<p className='mb-6 mt-2 pt-2 text-2xl font-bold text-primary-text leading-6 text-center font-roboto'>
What&apos;s your email?
</p>
</div>
<div className="relative rounded-md shadow-sm">
<Field name="email">
{({ field }) => (
<input
{...field}
id='email'
placeholder="[email protected]"
autoComplete="email"
type="email"
className="h-12 pb-1 pt-0 text-primary-text focus:ring-primary focus:border-primary border-secondary-500 pr-42 block
placeholder:text-secondary-text placeholder:text-sm placeholder:font-normal placeholder:opacity-50 bg-secondary-700 w-full font-semibold rounded-md"
/>
)}
</Field>
</div>
<div className='px-3 text-primary-text mt-6'>
<p className='text-left text-sm mb-3 text-secondary-text font-semibold'>
By signing in you get
</p>
<ul className='space-y-3'>
<li className='flex gap-3'>
<div>
<ScrollText className='h-5 w-5 mt-0.5 text-primary' />
</div>
<div>
<p className='font-semibold'>History</p>
<p className='text-secondary-text text-sm'>
Access your entire transaction history
</p>
</div>
</li>
<li className='flex gap-3'>
<div>
<Mail className='h-5 w-5 mt-0.5 text-primary' />
</div>
<div>
<p className='font-semibold'>Email updates</p>
<p className='text-secondary-text text-sm'>
Get a notification upon transfer completion
</p>
</div>
</li>
<li className='flex gap-3'>
<div>
<Album className='h-5 w-5 mt-0.5 text-primary' />
</div>
<div>
<p className='font-semibold'>Dedicated deposit address</p>
<p className='text-secondary-text text-sm'>
Get deposit addresses that stay the same and can be whitelisted in CEXes
</p>
</div>
</li>
</ul>
</div>
</Widget.Content>
<Widget.Footer>
<SubmitButton isDisabled={!isValid} isSubmitting={isSubmitting} >
Continue
</SubmitButton>
</Widget.Footer>
</Widget>
}
</Form>
)}
</Formik >
</>
)
}
export default SendEmail;
+48
View File
@@ -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 (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect={true}>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
}
export default SolanaProvider;
+351
View File
@@ -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<Props> = ({ partner, isPartnerWallet }) => {
const {
values,
setValues,
errors, isValid, isSubmitting, setFieldValue
} = useFormikContext<SwapFormValues>();
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<ApiResponse<AddressBookItem[]>>(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<boolean | null>(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 <>
<Widget className="sm:min-h-[504px]">
<Form className={`h-full ${(isSubmitting) ? 'pointer-events-none' : 'pointer-events-auto'}`} >
<Widget.Content>
<div className='flex-col relative flex justify-between w-full space-y-4 mb-3.5 leading-4'>
{!(query?.hideFrom && values?.from) && <div className="flex flex-col w-full">
<NetworkFormField direction="from" label="From" />
</div>}
{!query?.hideFrom && !query?.hideTo &&
<button type="button"
aria-label="Reverse the source and destination"
disabled={valuesSwapperDisabled}
onClick={valuesSwapper}
className='absolute right-[calc(50%-16px)] top-[74px] z-10 border-4 border-secondary-900 bg-secondary-900 rounded-full disabled:cursor-not-allowed hover:text-primary disabled:text-secondary-text duration-200 transition'>
<motion.div
animate={animate}
transition={{ duration: 0.3 }}
onTap={() => !valuesSwapperDisabled && cycle()}
>
<ArrowUpDown className={classNames(valuesSwapperDisabled && 'opacity-50', "w-8 h-auto p-1 bg-secondary-900 border-2 border-secondary-500 rounded-full disabled:opacity-30")} />
</motion.div>
</button>}
{!(query?.hideTo && values?.to) && <div className="flex flex-col w-full">
<NetworkFormField direction="to" label="To" />
</div>}
</div>
<div className="mb-6 leading-4">
<AmountField />
</div>
{
!hideAddress ?
<div className="w-full mb-3.5 leading-4">
<label htmlFor="destination_address" className="block font-semibold text-secondary-text text-sm">
{`To ${values?.to?.display_name || ''} address`}
</label>
<AddressButton
disabled={!values.to || !values.from}
isPartnerWallet={!!isPartnerWallet}
openAddressModal={() => setShowAddressModal(true)}
partnerImage={partnerImage}
values={values} />
<Modal
header={`To ${values?.to?.display_name || ''} address`}
height="fit"
show={showAddressModal} setShow={setShowAddressModal}
className="min-h-[70%]"
>
<Address
close={() => setShowAddressModal(false)}
disabled={lockAddress || (!values.to || !values.from)}
name={"destination_address"}
partnerImage={partnerImage}
isPartnerWallet={!!isPartnerWallet}
partner={partner}
address_book={address_book?.data}
/>
</Modal>
</div>
: <></>
}
<div className="w-full">
{
destination && asset && !destination.isExchange && GetNetworkCurrency(destination, asset)?.is_refuel_enabled && !query?.hideRefuel &&
<div className="flex items-center justify-between px-3.5 py-3 bg-secondary-700 border border-secondary-500 rounded-lg mb-4">
<div className="flex items-center space-x-2">
<Fuel className='h-8 w-8 text-primary' />
<div>
<p className="font-medium flex items-center">
<span>Need gas?</span>
<ClickTooltip text={`You will get a small amount of ${destination_native_currency} that you can use to pay for gas fees.`} />
</p>
<p className="font-light text-xs">
<span>Get&nbsp;</span><span className="font-semibold">{destination_native_currency}</span><span>&nbsp;to pay fees in&nbsp;</span><span>{values.to?.display_name}</span>
</p>
</div>
</div>
<ToggleButton name="refuel" value={!!values?.refuel} onChange={handleConfirmToggleChange} />
</div>
}
<FeeDetails values={values} />
{
//TODO refactor
destination && asset && GetNetworkCurrency(destination, asset)?.status == 'insufficient_liquidity' &&
<WarningMessage messageType="warning" className="mt-4">
<span className="font-normal"><span>We&apos;re experiencing delays for transfers of</span> <span>{values?.currency?.asset}</span> <span>to</span> <span>{values?.to?.display_name}</span><span>. Estimated arrival time can take up to 2 hours.</span></span>
</WarningMessage>
}
{
destination && asset && GetNetworkCurrency(destination, asset)?.status !== 'insufficient_liquidity' && destination?.internal_name === KnownInternalNames.Networks.StarkNetMainnet && averageTimeInMinutes > 30 &&
<WarningMessage messageType="warning" className="mt-4">
<span className="font-normal"><span>{destination?.display_name}</span> <span>network congestion. Transactions can take up to 1 hour.</span></span>
</WarningMessage>
}
{
values.amount &&
<ReserveGasNote onSubmit={(walletBalance, networkGas) => handleReserveGas(walletBalance, networkGas)} />
}
</div>
</Widget.Content>
<Widget.Footer>
<SwapButton
className="plausible-event-name=Swap+initiated"
type='submit'
isDisabled={!isValid}
isSubmitting={isSubmitting}>
{ActionText(errors, actionDisplayName as string)}
</SwapButton>
</Widget.Footer>
</Form >
</Widget>
{
process.env.NEXT_PUBLIC_SHOW_GAS_DETAILS === 'true'
&& values.from
&& values.currency &&
<GasDetails network={values.from} currency={values.currency} />
}
</>
}
function ActionText(errors: FormikErrors<SwapFormValues>, 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 <div className="tracking-wider text-primary-text">{shortAddress}</div>
}
type AddressButtonProps = {
openAddressModal: () => void;
isPartnerWallet: boolean;
values: SwapFormValues;
partnerImage?: string;
disabled: boolean;
}
const AddressButton: FC<AddressButtonProps> = ({ openAddressModal, isPartnerWallet, values, partnerImage, disabled }) => {
const destination = values?.to
return <button type="button" disabled={disabled} onClick={openAddressModal} className="flex rounded-lg space-x-3 items-center cursor-pointer shadow-sm mt-1.5 text-primary-text-placeholder bg-secondary-700 border-secondary-500 border disabled:cursor-not-allowed h-12 leading-4 focus:ring-primary focus:border-primary font-semibold w-full px-3.5 py-3">
{isPartnerWallet && !destination?.isExchange &&
<div className="shrink-0 flex items-center pointer-events-none">
{
partnerImage &&
<Image
alt="Partner logo"
className='rounded-md object-contain'
src={partnerImage}
width="24"
height="24"></Image>
}
</div>
}
<div className="truncate">
{values.destination_address ?
<TruncatedAdrress address={values.destination_address} />
:
<span>Enter your address here</span>
}
</div>
</button>
}
export default SwapForm
+250
View File
@@ -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: () => <div className="w-full h-[450px]">
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-6 py-1">
<div className="h-32 bg-secondary-700 rounded-lg"></div>
<div className="h-40 bg-secondary-700 rounded-lg"></div>
<div className="h-12 bg-secondary-700 rounded-lg"></div>
</div>
</div>
</div>
})
export default function Form() {
const formikRef = useRef<FormikProps<SwapFormValues>>(null);
const [showConnectNetworkModal, setShowConnectNetworkModal] = useState(false);
const [showSwapModal, setShowSwapModal] = useState(false);
const [networkToConnect, setNetworkToConnect] = useState<NetworkToConnect>();
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<ApiResponse<Partner>>(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 <>
<div className="rounded-r-lg cursor-pointer absolute z-10 md:mt-3 border-l-0">
<AnimatePresence mode='wait'>
{
swap &&
!showSwapModal &&
<PendingSwap onClick={() => setShowSwapModal(true)} />
}
</AnimatePresence>
</div>
<Modal height="fit" show={showConnectNetworkModal} setShow={setShowConnectNetworkModal} header={`${networkToConnect?.DisplayName} connect`}>
{networkToConnect && <ConnectNetwork NetworkDisplayName={networkToConnect?.DisplayName} AppURL={networkToConnect?.AppURL} />}
</Modal>
<Modal height='fit' show={showSwapModal} setShow={setShowSwapModal} header={`Complete the swap`}>
<ResizablePanel>
<SwapDetails type="contained" />
</ResizablePanel>
</Modal>
<Formik
innerRef={formikRef}
initialValues={initialValues}
validateOnMount={true}
validate={MainStepValidation({ settings, query })}
onSubmit={handleSubmit}
isInitialValid={!initiallyInValid}
>
<SwapForm isPartnerWallet={!!isPartnerWallet} partner={partner} />
</Formik>
</>
}
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 <motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
onClick={onClick}
initial="rest" whileHover="hover" animate="rest"
className="relative bg-secondary-600 rounded-r-lg">
<motion.div
variants={textMotion}
className="flex items-center bg-secondary-600 rounded-r-lg">
<div className="text-primary-text flex px-3 p-2 items-center space-x-2">
<span className="flex items-center">
{swap && <StatusIcon swap={swap} short={true} />}
</span>
<div className="flex-shrink-0 h-5 w-5 relative">
{source &&
<Image
src={resolveImgSrc(source)}
alt="From Logo"
height="60"
width="60"
className="rounded-md object-contain"
/>
}
</div>
<ChevronRight className="block h-4 w-4 mx-1" />
<div className="flex-shrink-0 h-5 w-5 relative block">
{destination &&
<Image
src={resolveImgSrc(destination)}
alt="To Logo"
height="60"
width="60"
className="rounded-md object-contain"
/>
}
</div>
</div>
</motion.div>
</motion.div>
</motion.div>
}
+72
View File
@@ -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 <MessageComponent>
<MessageComponent.Content icon='red'>
<MessageComponent.Header>
Swap not found
</MessageComponent.Header>
<MessageComponent.Description>
<span className="text-sm mt-5">
<p>
Your funds are safe, but there seems to be an issue with the swap.
</p>
<p>
Please contact our support team and we’ll help you fix this.
</p>
</span>
</MessageComponent.Description>
</MessageComponent.Content>
<MessageComponent.Buttons>
<MessageComponent.Buttons>
<div className="flex flex-row text-primary-text text-base space-x-2">
<div className='basis-1/3'>
<SubmitButton text_align='left' onClick={startIntercom} isDisabled={false} isSubmitting={false} buttonStyle='outline' icon={<MessageSquare className="h-5 w-5" aria-hidden="true" />}>
<DoubleLineText
colorStyle='mltln-text-dark'
primaryText='Support'
secondarytext='Contact'
/>
</SubmitButton>
</div>
<div className='basis-2/3'>
<GoHomeButton>
<SubmitButton button_align='right' text_align='left' isDisabled={false} isSubmitting={false} buttonStyle='outline' icon={<Home className="h-5 w-5" aria-hidden="true" />}>
<DoubleLineText
colorStyle='mltln-text-dark'
primaryText='Swap'
secondarytext='Do another'
/>
</SubmitButton>
</GoHomeButton>
</div>
</div>
</MessageComponent.Buttons>
</MessageComponent.Buttons>
</MessageComponent>
}
export default NotFound
+64
View File
@@ -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 (
<span className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/20">
<Check className="h-5 w-5 text-primary" aria-hidden="true" />
</span>
);
case ProgressStatus.Current:
return (
<span className="animate-spin">
<Gauge value={40} size="verySmall" />
</span>
);
case ProgressStatus.Failed:
return (
<span className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/20">
<X className="h-5 w-5 text-primary" aria-hidden="true" />
</span>
);
case ProgressStatus.Delayed:
return (
<span className="animate-spin opacity-50">
<Gauge value={40} size="verySmall" />
</span>)
default:
return (
<span className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 border-primary/20">
</span>
);
}
}
function Step({ step, isLastStep }: { step: StatusStep, isLastStep: boolean }) {
return (
<li className={classNames(isLastStep ? '' : 'pb-10', 'relative')} key={step?.name}>
<div className="flex items-center justify-between w-full">
{!isLastStep && (
<div className={`absolute top-1/2 left-4 -ml-px mt-0.5 h-[40%] w-0.5 ${step.status === "complete" ? "bg-primary" : "bg-primary/20"} `} aria-hidden="true" />
)}
<div className={`group relative flex ${step?.description ? "items-start" : "items-center"}`}>
<span className="flex h-9 items-center text-primary-text" aria-hidden="true">
{renderStepIcon(step)}
</span>
<span className="ml-3 flex min-w-0 flex-col">
<span className={`text-sm font-medium ${step.status === "current" ? "text-primary" : step.status === "upcoming" ? "text-secondary-text/70" : "text-primary-text"}`}>{step.name}</span>
{step?.description &&
<span className="text-sm text-secondary-text">{step?.description}</span>}
</span>
</div>
</div>
</li>
);
}
export default Step;
+14
View File
@@ -0,0 +1,14 @@
import Step from "./Step";
import { StatusStep } from "./Withdraw/Processing/types";
export default function Steps({ steps }: { steps: StatusStep[] }) {
return (
<nav aria-label="Progress">
<ol role="list" className="overflow-hidden">
{steps.map((step, index) => (
<Step key={index} step={step} isLastStep={index === steps.length - 1} />
))}
</ol>
</nav>
);
}
+151
View File
@@ -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<SwapInfoProps> = ({ 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<ApiResponse<Partner>>(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 (
<div>
<div className="font-normal flex flex-col w-full relative z-10 space-y-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
{source && <Image src={resolveImgSrc(source)} alt={source.display_name} width={32} height={32} className="rounded-full" />}
<div>
<p className="text-primary-text text-sm leading-5">{source?.display_name}</p>
{
sourceAccountAddress &&
<p className="text-sm text-secondary-text">{sourceAccountAddress}</p>
}
</div>
</div>
<div className="flex flex-col">
<p className="text-primary-text text-sm">{truncateDecimals(requestedAmount, currency.precision)} {sourceCurrencyName}</p>
<p className="text-secondary-text text-sm flex justify-end">${requestedAmountInUsd}</p>
</div>
</div>
<div className="flex items-center justify-between w-full ">
<div className="flex items-center gap-3">
{destination && <Image src={resolveImgSrc(destination)} alt={destination.display_name} width={32} height={32} className="rounded-full" />}
<div>
<p className="text-primary-text text-sm leading-5">{destination?.display_name}</p>
<p className="text-sm text-secondary-text">{shortenAddress(destAddress as string)}</p>
</div>
</div>
{
fee != undefined && receiveAmount != undefined && fee >= 0 ?
<div className="flex flex-col justify-end">
<p className="text-primary-text text-sm">{truncateDecimals(receiveAmount, currency.precision)} {destCurrencyName}</p>
<p className="text-secondary-text text-sm flex justify-end">${receiveAmountInUsd}</p>
</div>
:
<div className="flex flex-col justify-end">
<div className="h-[10px] my-[5px] w-20 animate-pulse rounded bg-gray-500" />
<div className="h-[10px] my-[5px] w-10 animate-pulse rounded bg-gray-500 ml-auto" />
</div>
}
</div>
{
refuelAmount &&
<div
className="flex items-center justify-between w-full ">
<div className='flex items-center gap-3 text-sm'>
<span className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full p-2 bg-primary/20">
<Fuel className="h-5 w-5 text-primary" aria-hidden="true" />
</span>
<p>Refuel</p>
</div>
<div className="flex flex-col">
<p className="text-primary-text text-sm">{truncatedRefuelAmount} {nativeCurrency?.asset}</p>
<p className="text-secondary-text text-sm flex justify-end">${refuelAmountInUsd}</p>
</div>
</div>
}
</div>
</div>
)
}
export default Summary
+103
View File
@@ -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<ApiResponse<Fee[]>>([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 <Summary
currency={currency}
source={source_layer}
destination={destination_layer}
requestedAmount={requested_amount as number}
receiveAmount={receive_amount}
destinationAddress={swap.destination_address}
hasRefuel={swap?.has_refuel}
refuelAmount={refuelAmountInNativeCurrency}
fee={fee}
exchange_account_connected={swap?.exchange_account_connected}
exchange_account_name={swap?.exchange_account_name}
/>
}
export default SwapSummary
@@ -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<Props> = ({ 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<Window | null>()
const [firstScreen, setFirstScreen] = useState<boolean>(true)
const carouselRef = useRef<CarouselRef | null>(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 (
<>
<Widget.Content>
{
!hideHeader ?
<h3 className='md:mb-4 pt-2 text-lg sm:text-xl text-left font-roboto text-primary-text font-semibold'>
Please connect your {exchange_name} account
</h3>
: <></>
}
{
<div className="w-full flex flex-col self-center h-[100%]">
{swap && <Carousel onLast={onCarouselLast} onFirst={setFirstScreen} ref={carouselRef} starAtLast={alreadyFamiliar}>
<CarouselItem width={100} >
<FirstScreen exchange_name={exchange_name} />
</CarouselItem>
<CarouselItem width={100}>
<SecondScreen />
</CarouselItem>
<CarouselItem width={100}>
<ThirdScreen minimalAuthorizeAmount={minimalAuthorizeAmount} />
</CarouselItem>
<CarouselItem width={100}>
<FourthScreen minimalAuthorizeAmount={minimalAuthorizeAmount} />
</CarouselItem>
<CarouselItem width={100}>
<LastScreen number={!alreadyFamiliar} minimalAuthorizeAmount={Number(minimalAuthorizeAmount)} />
</CarouselItem>
</Carousel>}
</div>
}
</Widget.Content>
<Widget.Footer sticky={true}>
<div>
{
<div className="flex items-center mb-3">
<input
name="alreadyFamiliar"
id='alreadyFamiliar'
type="checkbox"
className="h-4 w-4 bg-secondary-600 cursor-pointer rounded border-secondary-400 text-priamry"
onChange={handleToggleChange}
checked={alreadyFamiliar}
/>
<label htmlFor="alreadyFamiliar" className="ml-2 cursor-pointer block text-sm text-primary-text">
I&apos;m already familiar with the process.
</label>
</div>
}
{
<div className='flex items-center'>
{(!firstScreen && !alreadyFamiliar) &&
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<IconButton onClick={handlePrev} className='mr-4 py-3 px-3' icon={<ArrowLeft strokeWidth="3" />} />
</motion.div>
}
<SubmitButton isDisabled={false} isSubmitting={false} onClick={handleConnect}>
{
carouselFinished ? "Connect" : "Next"
}
</SubmitButton>
</div>
}
<div className="pt-2 font-normal text-xs text-secondary-text">
<p className="block font-lighter text-left">
<span>Even after authorization Bridge can&apos;t initiate a withdrawal without your explicit confirmation.&nbsp;</span>
<a target='_blank' href='https://docs.bridge.lux.network/user-docs/connect-a-coinbase-account' className='text-primary-text underline hover:no-underline decoration-white cursor-pointer'>Learn more</a></p>
</div>
</div>
</Widget.Footer>
</ >
)
}
export default Authorize;
@@ -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<void>
footerStickiness?: boolean
}
//TODO email code is almost identical create reusable component for email and two factor code verification
const Coinbase2FA: FC<Props> = ({ 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<FormikProps<CodeFormValues>>(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 <>
<Modal show={showInsufficientFundsModal} setShow={setShowInsufficientFundsModal} >
<MessageComponent>
<MessageComponent.Content center icon='red'>
<MessageComponent.Header>
Transfer failed
</MessageComponent.Header>
<MessageComponent.Description>
This transfer can&apos;t be processed because you don&apos;t have enough available funds on Coinbase.
</MessageComponent.Description>
</MessageComponent.Content>
<MessageComponent.Buttons>
<SubmitButton isDisabled={false} isSubmitting={false} onClick={() => {
window.open("https://www.coinbase.com/", "_blank")
}}>
Check Coinbase
</SubmitButton>
</MessageComponent.Buttons>
</MessageComponent>
</Modal>
<Modal show={showFundsOnHoldModal} setShow={setShowFundsOnHoldModal} >
<MessageComponent>
<MessageComponent.Content center icon='red'>
<MessageComponent.Header>
Transfer failed
</MessageComponent.Header>
<MessageComponent.Description>
This transfer can&apos;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.
</MessageComponent.Description>
</MessageComponent.Content>
<MessageComponent.Buttons>
<SubmitButton isDisabled={false} isSubmitting={false} onClick={() => {
window.open("https://help.coinbase.com/en/coinbase/trading-and-funding/sending-or-receiving-cryptocurrency/available-balance-faq", "_blank")
}}>
Learn More
</SubmitButton>
</MessageComponent.Buttons>
</MessageComponent>
</Modal>
<Formik
initialValues={initialValues}
validateOnMount={true}
innerRef={formikRef}
validate={(values: CodeFormValues) => {
const errors: FormikErrors<CodeFormValues> = {};
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 }) => (
<Form className='flex flex-col jutsify-center text-secondary-text h-full '>
<Widget.Content center>
<div className="w-full flex-col justify-between flex h-full">
<ScanFace className='w-12 h-12 md:w-16 md:h-16 mt-auto text-primary self-center' />
<div className='text-center md:mt-5 md:mb-8'>
<p className='mb-2 md:mb-6 mt-2 pt-2 text-2xl font-bold text-primary-text leading-6 text-center font-roboto'>
Coinbase 2FA
</p>
<p className='text-center text-base px-2'>
Please enter the 2 step verification code of your Coinbase account.
</p>
</div>
<div className="relative rounded-md shadow-sm mt-2 md:mt-5">
<NumericInput
pattern='^[0-9]*$'
placeholder="XXXXXXX"
maxLength={7}
name='Code'
onChange={e => {
/^[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"
/>
</div>
<span className="flex text-sm leading-6 items-center mt-1.5">
<TimerWithContext seconds={120}
waitingComponent={(remainingTime) => (
<span>
<span>Resend in</span>
<span className='ml-1'>
{remainingTime}
</span>
</span>
)}>
{!loading ? <span onClick={handleResendTwoFACode} className="decoration underline-offset-1 underline hover:no-underline decoration-primary hover:cursor-pointer">
Resend code
</span>
: <SpinIcon className="animate-spin h-5 w-5" />}
</TimerWithContext>
</span>
<div className='p-4 bg-secondary-700 text-secondary-text rounded-lg border border-secondary-500 my-4'>
<div className="flex items-center">
<Info className='h-5 w-5 text-primary-600 mr-3' />
<label className="block text-sm md:text-base font-medium leading-6">To obtain the 2 step verification code, check:</label>
</div>
<ul className="list-disc font-light space-y-1 text-xs md:text-sm mt-2 ml-8 text-left">
<li>your authenticator app (Google, Microsoft, or other), or</li>
<li>text messages of the phone number associated with your Coinbase account</li>
</ul>
</div>
</div>
</Widget.Content>
<Widget.Footer sticky={footerStickiness}>
<div className='md:mb-5'>
<SubmitButton type="submit" isDisabled={!isValid || loading} isSubmitting={isSubmitting || loading}>
Confirm
</SubmitButton>
</div>
</Widget.Footer>
</Form >
)}
</Formik>
</>;
}
export default Coinbase2FA;
@@ -0,0 +1,14 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CoinbaseState {
alreadyFamiliar: boolean
toggleAlreadyFamiliar: () => void
}
export const useCoinbaseStore = create<CoinbaseState>()(persist((set) => ({
alreadyFamiliar: false,
toggleAlreadyFamiliar: () => set((state) => ({ alreadyFamiliar: !state.alreadyFamiliar })),
}),
{
name: 'coinbase-config-storage'
}))
File diff suppressed because one or more lines are too long
@@ -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 <TimerProvider>
<TransferElements />
</TimerProvider>
}
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 (
<>
<Modal
height='90%'
show={showCoinbaseConnectModal}
setShow={setShowCoinbaseConnectModal}
header={`Connect your Coinbase account`}
>
<Authorize
hideHeader
onDoNotConnect={() => setShowCoinbaseConnectModal(false)}
onAuthorized={handleAuthorized}
stickyFooter={false}
/>
</Modal>
<Modal
height='90%'
show={openCoinbase2FA}
setShow={setOpenCoinbase2FA}
>
<Coinbase2FA
onSuccess={handleSuccess}
footerStickiness={false}
/>
</Modal>
<div className="w-full space-y-5 flex flex-col justify-between h-full text-secondary-text">
<div className='space-y-4'>
<div className='border-secondary-500 rounded-md border bg-secondary-700 p-3'>
{
swap?.exchange_account_connected ?
<SubmitButton
isDisabled={loading}
isSubmitting={loading}
onClick={handleTransfer}
icon={<ArrowLeftRight
className="h-5 w-5 ml-2"
aria-hidden="true"
/>}
>
Transfer using Coinbase
</SubmitButton>
:
<SubmitButton
isDisabled={loading}
isSubmitting={loading}
onClick={openConnect}
icon={<Link
className="h-5 w-5 ml-2"
aria-hidden="true" />
}
>
Connect Coinbase account
</SubmitButton>
}
</div>
</div>
</div>
</>
)
}
export default Coinbase;
+31
View File
@@ -0,0 +1,31 @@
import { FC } from 'react'
import { Widget } from '../../Widget/Index';
const External: FC = () => {
return (
<Widget.Content>
<div className="w-full py-12 grid grid-flow-row">
<div className='md:text-3xl text-lg font-bold text-primary-text leading-6 text-center'>
Withdrawal pending
</div>
<div className='flex place-content-center mt-20 mb-16 md:mb-8'>
<div className='relative'>
<div className='absolute top-1 left-1 w-10 h-10 opacity-40 bg bg-primary rounded-full animate-ping'></div>
<div className='absolute top-2 left-2 w-8 h-8 opacity-40 bg bg-primary rounded-full animate-ping'></div>
<div className='relative top-0 left-0 w-12 h-12 scale-75 bg bg-primary-800 rounded-full'></div>
</div>
</div>
<div className="flex flex-col text-center place-content-center mt-1 text-lg font-lighter text-secondary-text">
<p className="text-base font-medium space-y-6 text-secondary-text text-center">
The withdrawal has been initiated, please don&apos;t close this screen.
</p>
</div>
</div>
</Widget.Content>
)
}
export default External;
+92
View File
@@ -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 (
<>
<div>
<div className="flex items-center gap-2">
<span className="relative z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/20">
<QuestionIcon className="h-7 w-7 text-primary" aria-hidden="true" />
</span>
<label className="block text-sm md:text-base text-primary-text font-medium">What&apos;s happening?</label>
</div>
<div className='mt-4 text-xs md:text-sm text-primary-text'>
{
swap?.status == SwapStatus.Cancelled &&
<Canceled onGetHelp={startIntercom} swap={swap} />
}
{
swap?.status == SwapStatus.Expired &&
<Expired onGetHelp={startIntercom} swap={swap} />
}
{
swap?.status == SwapStatus.UserTransferDelayed &&
<Delay />
}
</div>
</div>
</>
)
}
type Props = {
swap: SwapItem
onGetHelp: () => void
}
const Expired = ({ onGetHelp }: Props) => {
return (
<div>
<span className='text-md text-left text-xs md:text-sm text-primary-text'>The transfer wasn&apos;t completed during the allocated timeframe.</span>
<span className='text-secondary-text'><span> If you&apos;ve already sent crypto for this swap, your funds are safe, </span><a className='underline hover:cursor-pointer' onClick={() => onGetHelp()}>please contact our support.</a></span>
</div>
)
}
const Delay: FC = () => {
return (
<div>
<p className='text-md '><span>This usually means that the exchange needs additional verification.</span>
<Link target='_blank' href="https://docs.bridge.lux.network/user-docs/why-is-coinbase-transfer-taking-so-long"
className='disabled:text-opacity-40 disabled:bg-primary-900 disabled:cursor-not-allowed ml-1 underline hover:no-underline cursor-pointer'>Learn More</Link></p>
<ul className="list-inside list-decimal font-light space-y-1 mt-2 text-left text-primary-text ">
<li>Check your email for details from Coinbase</li>
<li>Check your Coinbase account&apos;s transfer history</li>
</ul>
</div>
)
}
const Canceled = ({ onGetHelp }: Props) => {
return (
<div>
<p className='text-md text-left text-primary-text'><span>The transaction was cancelled by your request.</span>
<span className='text-secondary-text'><span> If you&apos;ve already sent crypto for this swap, your funds are safe,</span><a className='underline hover:cursor-pointer' onClick={() => onGetHelp()}> please contact our support.</a></span>
</p>
</div>
)
}
export default Failed;
@@ -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<boolean>()
useEffect(() => {
setEmbedded(inIframe())
}, [])
return <div className='rounded-xl bg-secondary-700 border border-secondary-500 divide-y divide-secondary-500'>
{
embedded ?
<SubmitButton onClick={() => window.open(swap?.fiat_redirect_url, '_blank')} icon={<ExternalLink className='h-5 w-5' strokeWidth={2} />} isDisabled={!swap} isSubmitting={false}>
Continue in Stripe
</SubmitButton>
:
<CryptoElements stripeOnramp={stripeOnrampPromise}>
{stripeSessionId && <OnrampElement clientSecret={stripeSessionId} swapId={swap.id} />}
</CryptoElements>
}
</div>
}
const CryptoElementsContext = createContext<ContextState | null>(null);
export const CryptoElements: FC<{ stripeOnramp: Promise<StripeOnramp | null>, 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 (
<CryptoElementsContext.Provider value={ctx}>
{children}
</CryptoElementsContext.Provider>
);
};
// React hook to get StripeOnramp from context
export const useStripeOnramp = () => {
const context = useContext<ContextState>(CryptoElementsContext as Context<ContextState>);
return context?.onramp;
};
type OnrampElementProps = {
clientSecret: string,
swapId: string,
}
// React element to render Onramp UI
export const OnrampElement: FC<OnrampElementProps> = ({
clientSecret,
swapId,
}) => {
const stripeOnramp = useStripeOnramp();
const onrampElementRef = useRef<HTMLDivElement>(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 <div className="relative">
{
loading &&
<Skeleton />
}
<div ref={onrampElementRef} ></div>
</div>;
};
const Skeleton: FC = () => {
return <div className="absolute pt-10 top-0 left-0 right-0 bottom-0 w-full overflow-hidden flex flex-col items-center justify-between">
<div className="animate-pulse flex flex-col items-center justify-between w-full">
<div className="rounded-md p-4 max-w-sm w-full mx-auto">
<div className="animate-pulse flex space-x-4">
<div className="rounded-md bg-slate-700 h-6 w-6"></div>
<div className="space-y-2">
<div className="h-2 w-12 bg-slate-700 rounded"></div>
<div className="h-2 w-40 bg-slate-700 rounded"></div>
</div>
</div>
</div>
<div className="border border-slate-700 grid grid-rows-2 gap-2 rounded-t-md p-4 max-w-sm w-full mx-auto">
<div className="grid grid-cols-8 gap-4">
<div className="h-2 bg-slate-700 rounded"></div>
</div>
<div className="grid grid-cols-8 gap-4">
<div className="h-6 bg-slate-700 rounded col-span-2"></div>
<div className="h-4 col-start-6 bg-slate-700 rounded"></div>
<div className="h-4 bg-slate-700 rounded"></div>
<div className="h-4 bg-slate-700 rounded"></div>
</div>
</div>
<div className="grid grid-rows-3 gap-2 rounded-md p-4 max-w-sm w-full mx-auto">
<div className="grid grid-cols-8 gap-4">
<div className="h-2 bg-slate-700 rounded col-span-2"></div>
</div>
<div className="grid grid-cols-8 gap-4">
<div className="h-6 bg-slate-700 rounded col-span-2"></div>
<div className="h-4 col-start-6 col-span-3 bg-slate-700 rounded"></div>
</div>
<div className="grid grid-cols-7 gap-4">
<div className="h-2 bg-slate-700 rounded"></div>
<div className="h-2 bg-slate-700 rounded"></div>
</div>
</div>
<div className="rounded-md p-4 max-w-sm w-full mx-auto">
<div className="animate-pulse flex justify-between space-x-4">
<div className="space-y-2">
<div className="h-2 w-10 bg-slate-700 rounded"></div>
<div className="h-2 w-6 bg-slate-700 rounded"></div>
</div>
<div className="space-y-2">
<div className="h-2 w-8 bg-slate-700 rounded"></div>
<div className="h-2 w-8 bg-slate-700 rounded"></div>
</div>
</div>
</div>
</div>
<div className="animate-pulse grid rounded-md p-4 max-w-sm w-full mx-auto">
<div className="h-10 bg-slate-700 rounded col-span-2"></div>
</div>
</div>
}
export default FiatTransfer
@@ -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<ApiResponse<DepositAddress>>(`/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 (
<div className='rounded-md bg-secondary-700 border border-secondary-500 w-full h-full items-center relative'>
<div className={!hintClicked ? "absolute w-full h-full flex flex-col items-center px-3 pb-3 text-center" : "hidden"}>
<div className="flex flex-col items-center justify-center h-full pb-2">
<div className="max-w-xs">
<p className="text-base text-primary-text">
About manual transfers
</p>
<p className="text-xs text-secondary-text">
Transfer assets to Bridge’s deposit address to complete the swap.
</p>
</div>
</div>
<SubmitButton isDisabled={false} isSubmitting={false} size="medium" onClick={handleCloseNote}>
OK
</SubmitButton>
</div>
<div className={hintClicked ? "flex" : "invisible"}>
<TransferInvoice address={generatedDepositAddress} shouldGenerateAddress={shouldGenerateAddress} />
</div>
</div>
)
}
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<ApiResponse<DepositAddress>>(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<ApiResponse<Fee[]>>([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 <div className='divide-y divide-secondary-500 text-primary-text h-full'>
{source_exchange && <div className={`w-full relative rounded-md px-3 py-3 shadow-sm border-secondary-700 border bg-secondary-700 flex flex-col items-center justify-center gap-2`}>
<ExchangeNetworkPicker onChange={handleChangeSelectedNetwork} />
</div>
}
<div className="flex divide-x divide-secondary-500">
<BackgroundField Copiable={true} QRable={true} header={"Deposit address"} toCopy={depositAddress} withoutBorder>
<div>
{
depositAddress ?
<p className='break-all'>
{depositAddress}
</p>
:
<div className='bg-gray-500 w-56 h-5 animate-pulse rounded-md' />
}
{
(source_network_internal_name === KnownInternalNames.Networks.LoopringMainnet || source_network_internal_name === KnownInternalNames.Networks.LoopringGoerli) &&
<div className='flex text-xs items-center py-1 mt-1 border-2 border-secondary-300 rounded border-dashed text-secondary-text'>
<p>
This address might not be activated. You can ignore it.
</p>
</div>
}
</div>
</BackgroundField>
</div>
{
(source_network_internal_name === KnownInternalNames.Networks.LoopringMainnet || source_network_internal_name === KnownInternalNames.Networks.LoopringGoerli) &&
<div className='grid grid-cols-3 divide-x divide-secondary-500'>
<div className="col-span-2">
<BackgroundField header={'Send type'} withoutBorder>
<div className='flex items-center text-xs sm:text-sm'>
<ArrowLeftRight className='hidden sm:inline-block sm:h-4 sm:w-4' />
<p>
To Another Loopring L2 Account
</p>
</div>
</BackgroundField>
</div>
<BackgroundField header={'Address Type'} withoutBorder>
<p className="text-xs sm:text-sm">
EOA Wallet
</p>
</BackgroundField>
</div>
}
<div className='flex divide-x divide-secondary-500'>
<BackgroundField Copiable={true} toCopy={requested_amount} header={'Amount'} withoutBorder>
<p>
{requested_amount}
</p>
</BackgroundField>
<BackgroundField header={'Asset'} withoutBorder Explorable={asset?.contract_address != null && isValidAddress(asset?.contract_address, selectedAssetNetwork?.network)} toExplore={asset?.contract_address != null ? selectedAssetNetwork?.network?.account_explorer_template?.replace("{0}", asset?.contract_address) : undefined}>
<div className="flex items-center gap-2">
<div className="flex-shrink-0 h-7 w-7 relative">
{
asset &&
<Image
src={resolveImgSrc({ asset: asset?.asset })}
alt="From Logo"
height="60"
width="60"
className="rounded-md object-contain"
/>
}
</div>
<div className="flex flex-col">
<span className="font-semibold leading-4">
{asset?.name}
</span>
{asset?.contract_address && isValidAddress(asset.contract_address, selectedAssetNetwork?.network) &&
<span className="text-xs text-secondary-text flex items-center leading-3">
{shortenAddress(asset?.contract_address)}
</span>
}
</div>
</div>
</BackgroundField>
</div>
</div>
}
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 <div className='flex items-center gap-1 text-sm my-2'>
<span>Network:</span>
{exchangeAssets?.length === 1 ?
<div className='flex space-x-1 items-center w-fit font-semibold text-primary-text'>
<Image alt="chainLogo" height='20' width='20' className='h-5 w-5 rounded-md ring-2 ring-secondary-600' src={resolveImgSrc(exchangeAssets?.[0])}></Image>
<span>{defaultSourceNetwork?.network?.display_name}</span>
</div>
:
<Select onValueChange={handleChangeSelectedNetwork} defaultValue={defaultSourceNetwork?.network_internal_name}>
<SelectTrigger className="w-fit border-none !text-primary-text !font-semibold !h-fit !p-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Networks</SelectLabel>
{exchangeAssets?.map(sn => (
<SelectItem key={sn.network_internal_name} value={sn.network_internal_name}>
<div className="flex items-center">
<div className="flex-shrink-0 h-5 w-5 relative">
{
sn.network &&
<Image
src={resolveImgSrc(sn.network)}
alt="From Logo"
height="60"
width="60"
className="rounded-md object-contain"
/>
}
</div>
<div className="mx-1 block">{sn?.network?.display_name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
}
</div>
}
const Sceleton = () => {
return <div className="animate-pulse rounded-lg p-4 flex items-center text-center bg-secondary-700 border border-secondary-500">
<div className="flex-1 space-y-6 py-1 p-8 pt-4 items-center">
<div className="h-2 bg-secondary-text rounded self-center w-16 m-auto"></div>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="h-2 bg-secondary-text rounded col-span-2"></div>
<div className="h-2 bg-secondary-text rounded col-span-1"></div>
</div>
<div className="h-2 bg-secondary-text rounded"></div>
</div>
<div className="h-2 bg-secondary-text rounded"></div>
</div>
</div>
}
export default ManualTransfer
@@ -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<Props> = ({ 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 = <div className='flex items-center space-x-1'>
<span>Estimated arrival after confirmation:</span>
<div className='text-primary-text'>
{
destinationNetworkCurrency?.status == 'insufficient_liquidity' ?
<span>Up to 2 hours (delayed)</span>
:
<AverageCompletionTime time={destination_network?.average_completion_time} />
}
</div>
</div>
const progressStates: ProgressStates = {
"input_transfer": {
upcoming: {
name: 'Waiting for your transfer',
description: null
},
current: {
name: 'Processing your deposit',
description: <div>
<span>
<span>Waiting for confirmations</span>
{swapInputTransaction && swapInputTransaction?.confirmations && (
<span className="text-primary-text ml-1">
<span>{swapInputTransaction?.confirmations >= swapInputTransaction?.max_confirmations
? swapInputTransaction?.max_confirmations
: swapInputTransaction?.confirmations}</span>
<span>/</span>{swapInputTransaction?.max_confirmations}
</span>
)}
</span>
</div>
},
complete: {
name: `Your deposit is confirmed`,
description: <div className='flex items-center space-x-1'>
<span>Transaction: </span>
<div className='underline hover:no-underline flex items-center space-x-1'>
<a target={"_blank"} href={input_tx_explorer?.replace("{0}", transactionHash)}>{shortenAddress(transactionHash)}</a>
<ExternalLink className='h-4' />
</div>
</div>
},
failed: {
name: `The transfer failed`,
description: <div className='flex space-x-1'>
<span>Error: </span>
<div className='space-x-1 text-primary-text'>
{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"
}
</div>
</div>
},
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 ? <div className="flex flex-col">
<div className='flex items-center space-x-1'>
<span>Transaction: </span>
<div className='underline hover:no-underline flex items-center space-x-1'>
<a target={"_blank"} href={output_tx_explorer?.replace("{0}", swapOutputTransaction.transaction_id)}>{shortenAddress(swapOutputTransaction.transaction_id)}</a>
<ExternalLink className='h-4' />
</div>
</div>
</div> : null,
},
failed: {
name: swap?.fail_reason == SwapFailReasons.RECEIVED_MORE_THAN_VALID_RANGE ? `The transfer is on hold` : "The transfer has failed",
description: <div className='flex space-x-1'>
<div className='space-x-1 text-secondary-text'>
{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"
}
</div>
</div>
},
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: <div className='flex items-center space-x-1'>
<span>Transaction: </span>
<div className='underline hover:no-underline flex items-center space-x-1'>
{swapRefuelTransaction && <>
<a target={"_blank"} href={swapRefuelTransaction?.explorer_url}>{shortenAddress(swapRefuelTransaction?.transaction_id)}</a>
<ExternalLink className='h-4' />
</>}
</div>
</div>
},
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 (
<Widget.Content>
<div className={`w-full min-h-[422px] space-y-5 flex flex-col justify-between text-primary-text`}>
<div className='space-y-5'>
<div className="w-full flex flex-col h-full space-y-5">
<div className="bg-secondary-700 font-normal px-3 py-4 rounded-lg flex flex-col border border-secondary-500 w-full relative z-10">
<SwapSummary></SwapSummary>
</div>
</div>
<div className="bg-secondary-700 font-normal px-3 py-6 rounded-lg flex flex-col border border-secondary-500 w-full relative z-10 divide-y-2 divide-secondary-500 divide-dashed">
<div className='pb-4'>
<div className='flex flex-col gap-2 items-center'>
<div className='flex items-center'>
<Gauge value={stepsProgressPercentage} size="small" showCheckmark={swap?.status === SwapStatus.Completed} />
</div>
<div className="flex-col text-center ">
<span className="font-medium text-primary-text">
{progressStatuses.generalStatus.title}
</span>
<span className='text-sm block space-x-1 text-secondary-text'>
<span>{progressStatuses.generalStatus.subTitle ?? outputPendingDetails}</span>
</span>
</div>
</div></div>
<div className='pt-4'>
{
swap?.status != SwapStatus.Cancelled && swap?.status != SwapStatus.Expired && currentSteps.find(x => x.status != null) &&
<div className='flex flex-col h-full justify-center'>
<Steps steps={currentSteps} />
</div>
}
{
([SwapStatus.Expired, SwapStatus.Cancelled, SwapStatus.UserTransferDelayed].includes(swap?.status)) &&
<Failed />
}
</div>
</div>
</div>
</div>
</Widget.Content>
)
}
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;
@@ -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 && <Processing settings={settings} swap={swap} />}
</>
)
}
export default Component;

Some files were not shown because too many files have changed in this diff Show More