diff --git a/packages/arb-token-bridge-ui/next.config.js b/packages/arb-token-bridge-ui/next.config.js index 957dae6ba5..51f8167984 100644 --- a/packages/arb-token-bridge-ui/next.config.js +++ b/packages/arb-token-bridge-ui/next.config.js @@ -8,6 +8,14 @@ module.exports = { distDir: 'build', productionBrowserSourceMaps: true, reactStrictMode: true, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'portal.arbitrum.io' + } + ] + }, async headers() { return [ { diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx index 03d8ed54d6..74c4a4c788 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx @@ -1,14 +1,14 @@ import dayjs from 'dayjs' -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useEffect } from 'react' import Tippy from '@tippyjs/react' -import { constants, utils } from 'ethers' +import { utils } from 'ethers' import { useLatest } from 'react-use' import { useAccount, useNetwork, useSigner } from 'wagmi' import { TransactionResponse } from '@ethersproject/providers' import { twMerge } from 'tailwind-merge' import { useAppState } from '../../state' -import { getNetworkName } from '../../util/networks' +import { getNetworkName, isNetwork } from '../../util/networks' import { TokenDepositCheckDialog, TokenDepositCheckDialogType @@ -75,6 +75,7 @@ import { ExternalLink } from '../common/ExternalLink' import { isExperimentalFeatureEnabled } from '../../util' import { useIsTransferAllowed } from './hooks/useIsTransferAllowed' import { MoveFundsButton } from './MoveFundsButton' +import { ProjectsListing } from '../common/ProjectsListing' import { useAmountBigNumber } from './hooks/useAmountBigNumber' const signerUndefinedError = 'Signer is undefined' @@ -183,8 +184,15 @@ export function TransferPanel() { const { destinationAddressError } = useDestinationAddressError() + const [showProjectsListing, setShowProjectsListing] = useState(false) + const isBatchTransfer = isBatchTransferSupported && Number(amount2) > 0 + useEffect(() => { + // hide Project listing when networks are changed + setShowProjectsListing(false) + }, [childChain.id, parentChain.id]) + function closeWithResetTokenImportDialog() { setTokenQueryParam(undefined) setImportTokenModalStatus(ImportTokenModalStatus.CLOSED) @@ -828,6 +836,11 @@ export function TransferPanel() { setTransferring(false) clearAmountInput() + // for custom orbit pages, show Projects' listing after transfer + if (isDepositMode && isNetwork(childChain.id).isOrbitChain) { + setShowProjectsListing(true) + } + await (sourceChainTransaction as TransactionResponse).wait() // tx confirmed, update balances @@ -1007,6 +1020,8 @@ export function TransferPanel() { )} + + {showProjectsListing && } ) } diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanelMain.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanelMain.tsx index 398482041a..83dcbff77c 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanelMain.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanelMain.tsx @@ -6,7 +6,7 @@ import { Chain, useAccount } from 'wagmi' import { useMedia } from 'react-use' import { useAppState } from '../../state' -import { getExplorerUrl, isNetwork } from '../../util/networks' +import { getExplorerUrl } from '../../util/networks' import { useDestinationAddressStore } from './AdvancedSettings' import { ExternalLink } from '../common/ExternalLink' diff --git a/packages/arb-token-bridge-ui/src/components/common/PortalProject.tsx b/packages/arb-token-bridge-ui/src/components/common/PortalProject.tsx new file mode 100644 index 0000000000..8d66eb57cb --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/common/PortalProject.tsx @@ -0,0 +1,78 @@ +import Image from 'next/image' +import { ExternalLink } from './ExternalLink' +import { PORTAL_API_ENDPOINT } from '../../constants' + +export type PortalProject = { + chains: string[] + description: string + id: string + images: { logoUrl: string; bannerUrl: string } + subcategories: { id: string; title: string }[] + title: string + url: string +} + +export const Project = ({ + project, + onClick, + isTestnetMode +}: { + project: PortalProject + onClick?: () => void + isTestnetMode: boolean +}) => { + return ( + + {/* Normal project contents */} +
+ {/* Logos */} +
+ {/* Project logo */} +
+
+ {`${project.title} +
+
+
+ + {/* Content */} +
+
+
+ {project.title} +
+

+ {project.description} +

+ +

+ {project.subcategories.slice(0, 2).map(subcategory => ( + + {subcategory.title.replaceAll('/', ' / ')} + + ))} +

+
+
+
+
+ ) +} diff --git a/packages/arb-token-bridge-ui/src/components/common/ProjectsListing.tsx b/packages/arb-token-bridge-ui/src/components/common/ProjectsListing.tsx new file mode 100644 index 0000000000..24169687dc --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/common/ProjectsListing.tsx @@ -0,0 +1,170 @@ +import { useMemo } from 'react' +import axios from 'axios' +import useSWRImmutable from 'swr/immutable' +import { ChevronRightIcon } from '@heroicons/react/24/outline' +import { useNetworks } from '../../hooks/useNetworks' +import { getNetworkName, isNetwork } from '../../util/networks' +import { PortalProject, Project } from './PortalProject' +import { PORTAL_API_ENDPOINT } from '../../constants' +import { ExternalLink } from './ExternalLink' +import { getBridgeUiConfigForChain } from '../../util/bridgeUiConfig' +import { getChainQueryParamForChain } from '../../types/ChainQueryParam' +import { trackEvent } from '../../util/AnalyticsUtils' +import { useIsTestnetMode } from '../../hooks/useIsTestnetMode' +import { isTestingEnvironment } from '../../util/CommonUtils' + +const shuffleArray = (array: PortalProject[]) => { + return array.sort(() => Math.random() - 0.5) +} + +const generateTestnetProjects = ( + chainId: number, + count: number +): PortalProject[] => { + const { + network: { name: chainName, logo: chainImage } + } = getBridgeUiConfigForChain(chainId) + + return [...Array(count)].map((_, key) => ({ + chains: [chainName], + description: `This is a featured project deployed on ${chainName}.`, + id: `project_${key}`, + images: { + logoUrl: chainImage, + bannerUrl: chainImage + }, + subcategories: [ + { id: 'defi', title: 'Defi' }, + { id: 'nfts', title: 'NFTs' } + ], + title: `Featured Project ${key + 1}`, + url: PORTAL_API_ENDPOINT + })) +} + +const fetchProjects = async ( + chainId: number, + isTestnetMode: boolean +): Promise => { + const isChainOrbit = isNetwork(chainId).isOrbitChain + const chainSlug = getChainQueryParamForChain(chainId) + + if (!isChainOrbit || !chainSlug) { + return [] + } + + if (isTestnetMode) { + return isTestingEnvironment ? generateTestnetProjects(chainId, 6) : [] // don't show any test projects in production + } + + try { + const response = await axios.get( + `${PORTAL_API_ENDPOINT}/api/projects?chains=${chainSlug}` + ) + return response.data as PortalProject[] + } catch (error) { + console.warn('Error fetching projects:', error) + return [] + } +} + +export const ProjectsListing = () => { + const [{ destinationChain }] = useNetworks() + const [isTestnetMode] = useIsTestnetMode() + + const isDestinationChainOrbit = isNetwork(destinationChain.id).isOrbitChain + const { color: destinationChainUIcolor } = getBridgeUiConfigForChain( + destinationChain.id + ) + const destinationChainSlug = getChainQueryParamForChain(destinationChain.id) + + const { + data: projects, + error, + isLoading + } = useSWRImmutable( + isDestinationChainOrbit + ? [destinationChain.id, isTestnetMode, 'fetchProjects'] + : null, + ([destinationChainId, isTestnetMode]) => + fetchProjects(destinationChainId, isTestnetMode) + ) + + // Shuffle projects and limit to 4 + const randomizedProjects = useMemo( + () => (projects ? shuffleArray(projects).slice(0, 4) : []), + [projects] + ) + + if ( + isLoading || + !projects || + projects.length === 0 || + typeof error !== 'undefined' + ) { + return null + } + + return ( +
+

+ Explore Apps on {getNetworkName(destinationChain.id)} +

+ + {isTestnetMode && ( +
+ Development-mode only. These are placeholder projects for + showing how this feature works in non-production mode. Real projects + are fetched from the Portal for mainnet Orbit chains. +
+ )} + +
+ {randomizedProjects.map(project => ( + { + if (isTestnetMode) return + + trackEvent('Project Click', { + network: getNetworkName(destinationChain.id), + projectName: project.title + }) + }} + /> + ))} +
+ {projects.length > 4 && ( + { + if (isTestnetMode) return + + trackEvent('Show All Projects Click', { + network: getNetworkName(destinationChain.id) + }) + }} + > + See all + + + )} +
+ ) +} diff --git a/packages/arb-token-bridge-ui/src/constants.ts b/packages/arb-token-bridge-ui/src/constants.ts index 0e69e08219..2e62500b05 100644 --- a/packages/arb-token-bridge-ui/src/constants.ts +++ b/packages/arb-token-bridge-ui/src/constants.ts @@ -32,3 +32,5 @@ export const MULTICALL_TESTNET_ADDRESS = export const ETHER_TOKEN_LOGO = '/images/EthereumLogoRound.svg' export const ether = { name: 'Ether', symbol: 'ETH', decimals: 18 } as const + +export const PORTAL_API_ENDPOINT = 'https://portal.arbitrum.io' diff --git a/packages/arb-token-bridge-ui/src/util/AnalyticsUtils.ts b/packages/arb-token-bridge-ui/src/util/AnalyticsUtils.ts index ea25eeaae7..5ae69c9953 100644 --- a/packages/arb-token-bridge-ui/src/util/AnalyticsUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/AnalyticsUtils.ts @@ -99,6 +99,13 @@ type AnalyticsEventMap = { complete: boolean version: number } + 'Project Click': { + network: string + projectName: string + } + 'Show All Projects Click': { + network: string + } } type AnalyticsEvent = keyof AnalyticsEventMap diff --git a/packages/arb-token-bridge-ui/tailwind.config.js b/packages/arb-token-bridge-ui/tailwind.config.js index 8bd5a1b6a9..687d267831 100644 --- a/packages/arb-token-bridge-ui/tailwind.config.js +++ b/packages/arb-token-bridge-ui/tailwind.config.js @@ -43,6 +43,7 @@ module.exports = { 'gray-dark': '#6D6D6D', 'line-gray': '#F4F4F4', dark: '#1A1C1D', // (or default-black) + 'dark-hover': '#2b2e30', // (or default-black-hover) 'bg-gray-1': '#191919',