Skip to content

Commit

Permalink
feat(april-fools): add I'm Feeling Lucky (#4094)
Browse files Browse the repository at this point in the history
* feat: add initial I'm Feeling Luck flow

* feat: add sound on click Im feeling lucky

* feat: disable buy token selector completely for april fools

* fix: do show the buy token selector in more cases

1. When not connected
2. When not in SWAP form

* chore: move isAprilFoolsEnabled to feature flag and don't show it for the widget

* refactor: move useImFeelingLucky to its own file

* chore: add lucky list to default enabled lists

* feat: load lucky list for mainnet

* fix: update import

* chore: bump localStorage keys to trigger list update

* chore: show unsupported network when network is unsupported

* chore: add wasImFeelingLuckyClickedAtom

* fix: add missing hook dependency

* refactor: move useImFeelingLucky hook to a folder

* fix: use regular token list if there are not lucky tokens loaded

* chore: remove debug statement

* chore: use a local token list rather than importing from url twice

* chore: use token address to avoid clashes

* chore: april fools 2024 styling (#4099)

* feat: progress

* feat: progress

* feat: progress

* chore: remove unsupported tokens from lucky list

* chore: move aprilFools logic to SwapWidget and rely on button status

* chore: use only gchain addresses from CoWSwap token list

* chore: fix build

* fix: do not show lucky button unless sell token is selected

* chore: only show fees error when both tokens are selected

* chore: use a modified uniswap list for now

---------

Co-authored-by: fairlight <[email protected]>
  • Loading branch information
alfetopito and fairlighteth authored Mar 28, 2024
1 parent 9c69a01 commit 895991b
Show file tree
Hide file tree
Showing 26 changed files with 1,077 additions and 58 deletions.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { isInjectedWidget } from '@cowprotocol/common-utils'

export function useIsAprilFoolsEnabled(): boolean {
const { isAprilFoolsEnabled } = useFeatureFlags()

return isAprilFoolsEnabled && !isInjectedWidget()
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'

import { setMaxSellTokensAnalytics } from '@cowprotocol/analytics'
import { NATIVE_CURRENCIES } from '@cowprotocol/common-const'
Expand Down Expand Up @@ -45,6 +45,7 @@ export interface CurrencyInputPanelProps extends Partial<BuiltItProps> {
onUserInput: (field: Field, typedValue: string) => void
openTokenSelectWidget(selectedToken: string | undefined, onCurrencySelection: (currency: Currency) => void): void
topLabel?: string
wasImFeelingLuckyClicked?: boolean
}

export function CurrencyInputPanel(props: CurrencyInputPanelProps) {
Expand Down Expand Up @@ -72,6 +73,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) {
},
},
topLabel,
wasImFeelingLuckyClicked,
} = props

const { field, currency, balance, fiatAmount, amount, isIndependent, receiveAmountInfo } = currencyInfo
Expand Down Expand Up @@ -157,6 +159,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) {
currency={disabled ? undefined : currency || undefined}
loading={areCurrenciesLoading || disabled}
readonlyMode={tokenSelectorDisabled}
wasImFeelingLuckyClicked={wasImFeelingLuckyClicked}
/>
</div>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export interface CurrencySelectButtonProps {
loading: boolean
readonlyMode?: boolean
onClick?(): void
wasImFeelingLuckyClicked?: boolean
}

export function CurrencySelectButton(props: CurrencySelectButtonProps) {
const { currency, onClick, loading, readonlyMode = false } = props
const { currency, onClick, loading, readonlyMode = false, wasImFeelingLuckyClicked = false } = props
const $stubbed = !currency || false

return (
Expand All @@ -26,6 +27,7 @@ export function CurrencySelectButton(props: CurrencySelectButtonProps) {
onClick={onClick}
isLoading={loading}
$stubbed={$stubbed}
wasImFeelingLuckyClicked={wasImFeelingLuckyClicked}
>
{currency ? <TokenLogo token={currency} size={24} /> : <div></div>}
<styledEl.CurrencySymbol className="token-symbol-container" $stubbed={$stubbed}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ReactComponent as DropDown } from '@cowprotocol/assets/images/dropdown.svg'
import { UI } from '@cowprotocol/ui'

import styled from 'styled-components/macro'
import styled, { css } from 'styled-components/macro'

export const ArrowDown = styled(DropDown)<{ $stubbed?: boolean }>`
margin: 0 3px;
Expand All @@ -21,7 +21,12 @@ export const ArrowDown = styled(DropDown)<{ $stubbed?: boolean }>`
`};
`

export const CurrencySelectWrapper = styled.button<{ isLoading: boolean; $stubbed: boolean; readonlyMode: boolean }>`
export const CurrencySelectWrapper = styled.button<{
isLoading: boolean
$stubbed: boolean
readonlyMode: boolean
wasImFeelingLuckyClicked?: boolean
}>`
display: flex;
justify-content: space-between;
align-items: center;
Expand Down Expand Up @@ -54,6 +59,45 @@ export const CurrencySelectWrapper = styled.button<{ isLoading: boolean; $stubbe
transition: stroke var(${UI.ANIMATION_DURATION}) ease-in-out;
stroke: ${({ $stubbed }) => ($stubbed ? 'currentColor' : `var(${UI.COLOR_BUTTON_TEXT})`)};
}
${({ wasImFeelingLuckyClicked }) =>
wasImFeelingLuckyClicked &&
css`
animation: buzz-out 1s ease-in-out;
animation-iteration-count: 1;
@keyframes buzz-out {
10% {
transform: translateX(3px) rotate(2deg);
}
20% {
transform: translateX(-3px) rotate(-2deg);
}
30% {
transform: translateX(3px) rotate(2deg);
}
40% {
transform: translateX(-3px) rotate(-2deg);
}
50% {
transform: translateX(2px) rotate(1deg);
}
60% {
transform: translateX(-2px) rotate(-1deg);
}
70% {
transform: translateX(2px) rotate(1deg);
}
80% {
transform: translateX(-2px) rotate(-1deg);
}
90% {
transform: translateX(1px) rotate(0);
}
100% {
transform: translateX(-1px) rotate(0);
}
}
`}
`

export const CurrencySymbol = styled.div<{ $stubbed: boolean }>`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'

import { useCurrencyAmountBalance } from '@cowprotocol/balances-and-allowances'
import { NATIVE_CURRENCIES, TokenWithLogo } from '@cowprotocol/common-const'
Expand All @@ -18,6 +18,7 @@ import { EthFlowModal, EthFlowProps } from 'modules/swap/containers/EthFlow'
import { SwapModals, SwapModalsProps } from 'modules/swap/containers/SwapModals'
import { SwapButtonState } from 'modules/swap/helpers/getSwapButtonState'
import { getInputReceiveAmountInfo, getOutputReceiveAmountInfo } from 'modules/swap/helpers/tradeReceiveAmount'
import { useResetWasImFeelingLuckyClicked } from 'modules/swap/hooks/useImFeelingLucky'
import { useIsEoaEthFlow } from 'modules/swap/hooks/useIsEoaEthFlow'
import { useShowRecipientControls } from 'modules/swap/hooks/useShowRecipientControls'
import { useSwapButtonContext } from 'modules/swap/hooks/useSwapButtonContext'
Expand Down Expand Up @@ -214,6 +215,14 @@ export function SwapWidget() {
const nativeCurrencySymbol = useNativeCurrency().symbol || 'ETH'
const wrappedCurrencySymbol = useWrappedToken().symbol || 'WETH'

const hideBuyTokenInput = swapButtonContext.swapButtonState === SwapButtonState.ImFeelingLucky

const resetWasImFeelingLuckyClicked = useResetWasImFeelingLuckyClicked()

useEffect(() => {
hideBuyTokenInput && resetWasImFeelingLuckyClicked()
}, [hideBuyTokenInput, resetWasImFeelingLuckyClicked])

const swapWarningsTopProps: SwapWarningsTopProps = {
chainId,
trade,
Expand Down Expand Up @@ -276,6 +285,7 @@ export function SwapWidget() {
priceImpact: priceImpactParams,
disableQuotePolling: true,
disablePriceImpact,
hideBuyTokenInput,
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export enum SwapButtonState {
SwapWithWrappedToken = 'SwapWithWrappedToken',
RegularEthFlowSwap = 'EthFlowSwap',
ApproveAndSwap = 'ApproveAndSwap',

ImFeelingLucky = 'ImFeelingLucky',
WrapAndSwap = 'WrapAndSwap',
}

Expand All @@ -54,6 +54,9 @@ export interface SwapButtonStateParams {
isBestQuoteLoading: boolean
wrappedToken: Token
isPermitSupported: boolean
isAprilFoolsEnabled: boolean
hasSellToken: boolean
hasBuyToken: boolean
}

const quoteErrorToSwapButtonState: { [key in QuoteError]: SwapButtonState | null } = {
Expand All @@ -67,7 +70,7 @@ const quoteErrorToSwapButtonState: { [key in QuoteError]: SwapButtonState | null
}

export function getSwapButtonState(input: SwapButtonStateParams): SwapButtonState {
const { trade, quote, approvalState, isPermitSupported } = input
const { trade, quote, approvalState, isPermitSupported, isAprilFoolsEnabled, hasSellToken, hasBuyToken } = input
const quoteError = quote?.error

// show approve flow when: no error on inputs, not approved or pending, or approved in current session
Expand All @@ -79,9 +82,14 @@ export function getSwapButtonState(input: SwapButtonStateParams): SwapButtonStat

const isValid = !input.inputError && input.feeWarningAccepted && input.impactWarningAccepted
const swapBlankState = !input.inputError && !trade
const tokensSelected = hasSellToken && hasBuyToken

const isSellOrder = trade?.tradeType === TradeType.EXACT_INPUT
const amountAfterFees = isSellOrder ? trade?.outputAmountAfterFees : trade?.inputAmountAfterFees
const amountAfterFees = tokensSelected
? isSellOrder
? trade?.outputAmountAfterFees
: trade?.inputAmountAfterFees
: undefined

if (quoteError) {
const quoteErrorState = quoteErrorToSwapButtonState[quoteError]
Expand Down Expand Up @@ -121,6 +129,9 @@ export function getSwapButtonState(input: SwapButtonStateParams): SwapButtonStat
}

if (input.inputError) {
if (isAprilFoolsEnabled && input.inputError === 'Select a token' && input.hasSellToken) {
return SwapButtonState.ImFeelingLucky
}
return SwapButtonState.SwapError
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[
"0x1509706a6c66ca549ff0cb464de88231ddbe213b",
"0x177127622c4a00f3d409b75571e12cb3c8973d3c",
"0x2bf2ba13735160624a0feae98f6ac8f70885ea61",
"0x3a97704a1b25f08aa230ae53b352e2e72ef52843",
"0x44fa8e6f47987339850636f88629646662444217",
"0x5cb9073902f2035222b9749f8fb0c9bfe5527108",
"0x63e62989d9eb2d37dfdb1f93a22f063635b07d51",
"0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1",
"0x6c76971f98945ae98dd7d4dfca8711ebea946ea6",
"0x6de572faa138048ce8142c4a206eb09a8ec39e45",
"0x71850b7e9ee3f13ab46d67167341e4bdc905eef9",
"0x8e5bbbb09ed1ebde8674cda39a0c169401db4252",
"0x9c58bacc331c9aa871afd802db6379a98e80cedb",
"0xa4ef9da5ba71cc0d2e5e877a910a37ec43420445",
"0xaf204776c7245bf4147c2612bf6e5972ee483701",
"0xb0c5f3100a4d9d9532a4cfd68c55f1ae8da987eb",
"0xb7d311e2eb55f2f68a9440da38e7989210b9a05e",
"0xc45b3c1c24d5f54e7a2cf288ac668c74dd507a84",
"0xcb444e90d8198415266c6a2724b7900fb12fc56e",
"0xce11e14225575945b8e6dc0d4f2dd4c570f79d9f",
"0xddafbb505ad214d7b80b1f830fccc89b60fb7a83",
"0xe2e73a1c69ecf83f464efce6a5be353a37ca09b2",
"0xe91d153e0b41518a2ce8dd3d7944fa863463a97d"
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { atom, useSetAtom } from 'jotai'
import { useCallback } from 'react'

import { NATIVE_CURRENCIES, SWR_NO_REFRESH_OPTIONS, TokenWithLogo } from '@cowprotocol/common-const'
import { getImFeelingLuckySound } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useAllTokens } from '@cowprotocol/tokens'
import { useWalletInfo } from '@cowprotocol/wallet'

import useSWR from 'swr'
import { Nullish } from 'types'

import { useTradeNavigate } from 'modules/trade/hooks/useTradeNavigate'
import { useTradeState } from 'modules/trade/hooks/useTradeState'

import gchainAddresses from './gchainTokenAddresses.json'
import mainnetLuckyTokens from './luckyTokens.tokenlist.json'

const GCHAIN_ADDRESS = new Set(gchainAddresses)

export function useImFeelingLucky() {
const { state } = useTradeState()
const inputCurrencyId = state?.inputCurrencyId
const setWasImFeelingLuckyClicked = useSetAtom(wasImFeelingLuckyClickedAtom)

const { chainId } = useWalletInfo()
const navigate = useTradeNavigate()

const tokens = useImFeelingLuckyTokens(chainId, inputCurrencyId)

return useCallback(() => {
const selected = pickRandom(tokens)

getImFeelingLuckySound().play()
setWasImFeelingLuckyClicked(true)
navigate(chainId, {
inputCurrencyId: inputCurrencyId || NATIVE_CURRENCIES[chainId].symbol || null,
outputCurrencyId: selected?.address || null,
})
}, [tokens, setWasImFeelingLuckyClicked, navigate, chainId, inputCurrencyId])
}

function pickRandom<T>(list: T[]): T | undefined {
if (list.length === 0) {
return undefined
}

const randomIndex = Math.floor(Math.random() * list.length)
return list[randomIndex]
}

function useImFeelingLuckyTokens(chainId: SupportedChainId, sellTokenId: Nullish<string>): TokenWithLogo[] {
const isMainnet = chainId === SupportedChainId.MAINNET
const isGchain = chainId === SupportedChainId.GNOSIS_CHAIN

const tokens = useAllTokens()

const { data: mainnetList } = useSWR<TokenWithLogo[]>(
'luckyTokens',
() => mainnetLuckyTokens.tokens.map((t) => TokenWithLogo.fromToken(t)),
{ ...SWR_NO_REFRESH_OPTIONS, fallbackData: [] }
)

if (isMainnet && mainnetList?.length) {
return mainnetList.filter(sellTokenFilterFactory(sellTokenId))
} else if (isGchain) {
return tokens
.filter(({ address }) => GCHAIN_ADDRESS.has(address.toLowerCase()))
.filter(sellTokenFilterFactory(sellTokenId))
}
return tokens.filter(sellTokenFilterFactory(sellTokenId))
}

function sellTokenFilterFactory(sellTokenId: Nullish<string>) {
return ({ symbol, address }: TokenWithLogo) => symbol !== sellTokenId && address !== sellTokenId
}

export const wasImFeelingLuckyClickedAtom = atom(false)

export function useResetWasImFeelingLuckyClicked() {
const setAtom = useSetAtom(wasImFeelingLuckyClickedAtom)

return useCallback(() => setAtom(false), [setAtom])
}
Loading

0 comments on commit 895991b

Please sign in to comment.