diff --git a/apps/cowswap-frontend/package.json b/apps/cowswap-frontend/package.json
index 928496fb8a6..561e5c95680 100644
--- a/apps/cowswap-frontend/package.json
+++ b/apps/cowswap-frontend/package.json
@@ -123,6 +123,7 @@
"react-icons": "^5.2.1",
"react-inlinesvg": "^4.1.5",
"react-markdown": "^10.1.0",
+ "react-qrcode-logo": "^4.0.0",
"react-redux": "^8.0.2",
"react-router": "7.5.2",
"react-snowfall": "^2.2.0",
diff --git a/apps/cowswap-frontend/src/common/constants/routes.ts b/apps/cowswap-frontend/src/common/constants/routes.ts
index 6bc36e19fdf..d3a5a6a8341 100644
--- a/apps/cowswap-frontend/src/common/constants/routes.ts
+++ b/apps/cowswap-frontend/src/common/constants/routes.ts
@@ -25,6 +25,8 @@ export const Routes = {
ACCOUNT_TOKENS: '/account/tokens',
ACCOUNT_TOKENS_SINGLE: '/account/tokens/:address',
ACCOUNT_GOVERNANCE: '/account/governance',
+ ACCOUNT_AFFILIATE: '/account/affiliate',
+ ACCOUNT_MY_REWARDS: '/account/my-rewards',
ABOUT: '/about',
PRIVACY_POLICY: '/privacy-policy',
COOKIE_POLICY: '/cookie-policy',
diff --git a/apps/cowswap-frontend/src/common/pure/CancelButton/index.tsx b/apps/cowswap-frontend/src/common/pure/CancelButton/index.tsx
index 299c6e547d8..f601bede870 100644
--- a/apps/cowswap-frontend/src/common/pure/CancelButton/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/CancelButton/index.tsx
@@ -1,10 +1,9 @@
import { PropsWithChildren } from 'react'
import { Command } from '@cowprotocol/types'
-import { UI } from '@cowprotocol/ui'
+import { UI, LinkStyledButton } from '@cowprotocol/ui'
import { Trans } from '@lingui/react/macro'
-import { LinkStyledButton } from 'theme'
export type CancelButtonProps = {
onClick: Command
diff --git a/apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx b/apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx
index da87f4bce63..4dc0d60437d 100644
--- a/apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx
+++ b/apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx
@@ -1,13 +1,12 @@
import { ReactNode, useCallback, useState } from 'react'
-import { TokenAmount, UI } from '@cowprotocol/ui'
+import { TokenAmount, UI, LinkStyledButton } from '@cowprotocol/ui'
import { CurrencyAmount } from '@uniswap/sdk-core'
import { t } from '@lingui/core/macro'
import { Trans } from '@lingui/react/macro'
import { ArrowLeft, ArrowRight } from 'react-feather'
import styled from 'styled-components/macro'
-import { LinkStyledButton } from 'theme'
import NotificationBanner from 'legacy/components/NotificationBanner'
diff --git a/apps/cowswap-frontend/src/legacy/components/Copy/CopyMod.tsx b/apps/cowswap-frontend/src/legacy/components/Copy/CopyMod.tsx
index 7b0ae4f8453..d1f83f8ab46 100644
--- a/apps/cowswap-frontend/src/legacy/components/Copy/CopyMod.tsx
+++ b/apps/cowswap-frontend/src/legacy/components/Copy/CopyMod.tsx
@@ -2,11 +2,11 @@ import React, { MouseEvent } from 'react'
import { useCopyClipboard } from '@cowprotocol/common-hooks'
import { UI } from '@cowprotocol/ui'
+import { LinkStyledButton } from '@cowprotocol/ui'
import { Trans } from '@lingui/react/macro'
import { CheckCircle, Copy } from 'react-feather'
import styled, { DefaultTheme, StyledComponentProps } from 'styled-components/macro'
-import { LinkStyledButton } from 'theme'
import { TransactionStatusText } from 'legacy/components/Copy/index'
@@ -54,12 +54,13 @@ interface CopyHelperProps
children?: React.ReactNode
clickableLink?: boolean
copyIconWidth?: string
+ hideCopiedLabel?: boolean
}
// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default function CopyHelper(props: CopyHelperProps) {
- const { toCopy, children, clickableLink, copyIconWidth, ...rest } = props
+ const { toCopy, children, clickableLink, copyIconWidth, hideCopiedLabel = false, ...rest } = props
const [isCopied, setCopied] = useCopyClipboard()
// TODO: Add proper return type annotation
@@ -78,11 +79,13 @@ export default function CopyHelper(props: CopyHelperProps) {
isCopied={isCopied} // mod
>
-
- Copied
-
+ {!hideCopiedLabel ? (
+
+ Copied
+
+ ) : null}
) : (
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index f4db5be1695..6d048d42ea4 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -31,6 +31,10 @@ msgstr "View token contract"
msgid "Signature is undefined!"
msgstr "Signature is undefined!"
+#: apps/cowswap-frontend/src/modules/affiliate/components/ReferralCodeModal/content.tsx
+#~ msgid "Connect to verify eligibility. Code binds on your first eligible trade. Earn 10 USDC per 50k eligible volume in 90 days. Payouts happen on Ethereum mainnet."
+#~ msgstr "Connect to verify eligibility. Code binds on your first eligible trade. Earn 10 USDC per 50k eligible volume in 90 days. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/BenefitComponents.tsx
#~ msgid "I just received surplus on"
#~ msgstr "I just received surplus on"
@@ -51,6 +55,10 @@ msgstr "Learn about surplus on CoW Swap"
msgid "Disconnect"
msgstr "Disconnect"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Your rewards activity will show here."
+#~ msgstr "Your rewards activity will show here."
+
#: apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/PendingBridgingContent/index.tsx
#: apps/cowswap-frontend/src/modules/bridge/pure/contents/BridgingProgressContent/ReceivedBridgingContent/index.tsx
msgid "Bridge transaction"
@@ -60,6 +68,10 @@ msgstr "Bridge transaction"
msgid "Because you are using a smart contract wallet, you will pay a separate gas cost for signing the order placement on-chain."
msgstr "Because you are using a smart contract wallet, you will pay a separate gas cost for signing the order placement on-chain."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "It isn’t a valid referral code."
+msgstr "It isn’t a valid referral code."
+
#: apps/cowswap-frontend/src/api/cowProtocol/errors/OperatorError.ts
msgid "cancelled. Too many order cancellations"
msgstr "cancelled. Too many order cancellations"
@@ -158,6 +170,10 @@ msgstr "Type"
msgid "You don't have any orders at the moment."
msgstr "You don't have any orders at the moment."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Active referrals"
+msgstr "Active referrals"
+
#: apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts
msgid "Unexpected response from BFF: {resStatus}"
msgstr "Unexpected response from BFF: {resStatus}"
@@ -179,6 +195,10 @@ msgstr "What is {accountProxyLabel}?"
msgid "Your activity will appear here..."
msgstr "Your activity will appear here..."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+msgid "Save code"
+msgstr "Save code"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/OrderTypeField.tsx
msgid "(Partially fillable)"
msgstr "(Partially fillable)"
@@ -192,6 +212,15 @@ msgstr "Try before you buy - see the potential fill price before you hit trade"
msgid "Wrap {nativeSymbol}"
msgstr "Wrap {nativeSymbol}"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Affiliate code disabled."
+#~ msgstr "Affiliate code disabled."
+
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Total earned"
+msgstr "Total earned"
+
#: apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/index.tsx
msgid "Order expires in"
msgstr "Order expires in"
@@ -223,6 +252,10 @@ msgstr "is a helper contract that improves the user experience within CoW Swap f
msgid "To"
msgstr "To"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
+msgid "FAQ"
+msgstr "FAQ"
+
#: apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx
msgid "Are you sure you want to cancel order <0>{shortId}0>?"
msgstr "Are you sure you want to cancel order <0>{shortId}0>?"
@@ -318,6 +351,10 @@ msgstr "Set any limit price and time horizon"
msgid "token"
msgstr "token"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Unable to reach the affiliate service."
+msgstr "Unable to reach the affiliate service."
+
#: apps/cowswap-frontend/src/modules/account/pure/OrderFillabilityWarning/index.tsx
msgid "Please, top up {symbol} balance or cancel the order."
msgstr "Please, top up {symbol} balance or cancel the order."
@@ -384,11 +421,19 @@ msgstr "This is the current market price, including the fee."
msgid "Convert to COW <0/>"
msgstr "Convert to COW <0/>"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Switch to Ethereum mainnet"
+#~ msgstr "Switch to Ethereum mainnet"
+
#: apps/cowswap-frontend/src/common/pure/CustomRecipientWarningBanner/index.tsx
#: apps/cowswap-frontend/src/common/pure/TransactionErrorContent/index.tsx
msgid "Dismiss"
msgstr "Dismiss"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+msgid "ENTER CODE"
+msgstr "ENTER CODE"
+
#: apps/cowswap-frontend/src/common/pure/ToggleArrow/ToggleArrow.tsx
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
@@ -398,6 +443,10 @@ msgstr "Dismiss"
msgid "Open"
msgstr "Open"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Checking code…"
+msgstr "Checking code…"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/BridgingStatusHeader/index.tsx
msgid "Refund completed!"
msgstr "Refund completed!"
@@ -424,6 +473,10 @@ msgstr ""
msgid "amount per"
msgstr "amount per"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Provide a valid referral code"
+msgstr "Provide a valid referral code"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/BridgingStatusHeader/index.tsx
msgid "Bridging failed. Refund started..."
msgstr "Bridging failed. Refund started..."
@@ -520,6 +573,10 @@ msgstr "Free"
msgid "Considering current network costs (<0><1/>0> per chunk), you could save more by reducing the number of parts or switch to a {swapOrderLink}."
msgstr "Considering current network costs (<0><1/>0> per chunk), you could save more by reducing the number of parts or switch to a {swapOrderLink}."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "generate"
+msgstr "generate"
+
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
#~ msgid "Permit an address to spend one token on your behalf."
#~ msgstr "Permit an address to spend one token on your behalf."
@@ -615,6 +672,10 @@ msgstr "When selling {aNativeCurrency}, the minimum slippage tolerance is set to
msgid "EthFlow contract ({actualContractAddress}) address don't match the expected address for chain {chainId} ({expectedContractAddress}). Please refresh the page and try again."
msgstr "EthFlow contract ({actualContractAddress}) address don't match the expected address for chain {chainId} ({expectedContractAddress}). Please refresh the page and try again."
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Active referral code"
+msgstr "Active referral code"
+
#: apps/cowswap-frontend/src/modules/swap/containers/SwapWidget/index.tsx
#: apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
#: apps/cowswap-frontend/src/modules/yield/containers/YieldWidget/index.tsx
@@ -651,6 +712,10 @@ msgstr "Only proceed if you trust this provider."
msgid "I received surplus on"
msgstr "I received surplus on"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Created on"
+msgstr "Created on"
+
#: apps/cowswap-frontend/src/modules/application/containers/App/CowSpeechBubble.tsx
msgid "View jobs"
msgstr "View jobs"
@@ -667,6 +732,10 @@ msgstr "This is the minimum amount that you will receive across your entire TWAP
#~ msgid "CoW AMM Withdraw Liquidity"
#~ msgstr "CoW AMM Withdraw Liquidity"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Verify code"
+msgstr "Verify code"
+
#: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/index.tsx
msgid "Adding this app/hook grants it access to your wallet actions and trading information. Ensure you understand the implications."
msgstr "Adding this app/hook grants it access to your wallet actions and trading information. Ensure you understand the implications."
@@ -699,6 +768,10 @@ msgstr "COW token is not available on this network"
msgid "CoW AMM only"
msgstr "CoW AMM only"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
+msgid "Terms"
+msgstr "Terms"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts
msgid "Scheduled"
msgstr "Scheduled"
@@ -751,9 +824,14 @@ msgid "CoW forum"
msgstr "CoW forum"
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
msgid "Signing..."
msgstr "Signing..."
+#: apps/cowswap-frontend/src/modules/affiliate/components/ReferralCodeModal/controller.helpers.tsx
+#~ msgid "Wallet ineligible. Try another code"
+#~ msgstr "Wallet ineligible. Try another code"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/constants.ts
#~ msgid "CoW Swap is now live on Arbitrum, Base, Polygon, and Avalanche. Switch the network toggle in the nav bar for quick, cheap transactions"
#~ msgstr "CoW Swap is now live on Arbitrum, Base, Polygon, and Avalanche. Switch the network toggle in the nav bar for quick, cheap transactions"
@@ -904,6 +982,10 @@ msgstr "Manage token lists"
#~ msgid "Cancelling order with id {shortId}:<0/><1>{summary}1>"
#~ msgstr "Cancelling order with id {shortId}:<0/><1>{summary}1>"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for {timeCapDays} days."
+msgstr "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for {timeCapDays} days."
+
#: apps/cowswap-frontend/src/modules/wallet/containers/AccountSelectorModal/index.tsx
msgid "Select {walletName} Account"
msgstr "Select {walletName} Account"
@@ -944,6 +1026,10 @@ msgstr "The recipient address you inputted had the chain prefix <0>{chainPrefixW
msgid "1 Day"
msgstr "1 Day"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Unsupported network."
+msgstr "Unsupported network."
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderFillsAt/index.tsx
msgid "Smart contract"
msgstr "Smart contract"
@@ -952,6 +1038,10 @@ msgstr "Smart contract"
msgid "Switch Network"
msgstr "Switch Network"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+msgid "Invalid code"
+msgstr "Invalid code"
+
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/OrderWarning.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/OrdersTabs.tsx
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/TransactionSlippageInput/index.tsx
@@ -974,6 +1064,11 @@ msgstr "You possibly have other items to claim, but not Airdrops"
msgid "APR"
msgstr "APR"
+#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
+#: apps/cowswap-frontend/src/pages/Account/Menu.tsx
+msgid "My rewards"
+msgstr "My rewards"
+
#: apps/cowswap-frontend/src/common/pure/NetworksList/index.tsx
msgid "CoW Protocol Explorer"
msgstr "CoW Protocol Explorer"
@@ -1100,6 +1195,10 @@ msgstr "Orders history"
msgid "You must give the CoW Protocol smart contracts permission to use your <0/>."
msgstr "You must give the CoW Protocol smart contracts permission to use your <0/>."
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "How it works"
+#~ msgstr "How it works"
+
#: apps/cowswap-frontend/src/modules/yield/containers/YieldWidget/elements.tsx
msgid "any token into CoW AMM pools to start benefiting from attractive APRs."
msgstr "any token into CoW AMM pools to start benefiting from attractive APRs."
@@ -1133,6 +1232,11 @@ msgstr "Custom"
msgid "Est. partial fill price"
msgstr "Est. partial fill price"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "That code is unavailable. Try another."
+msgstr "That code is unavailable. Try another."
+
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ActiveOrdersWithAffectedPermit/ActiveOrdersWithAffectedPermit.tsx
#~ msgid "Partial approval may block <0>{ordersWithPermitLenght}0> other orders"
#~ msgstr "Partial approval may block <0>{ordersWithPermitLenght}0> other orders"
@@ -1149,6 +1253,10 @@ msgstr "Insufficient <0/> balance"
#~ msgid "The Claim LlamaPay Vesting Hook is a powerful and user-friendly feature designed to streamline the process of claiming funds from LlamaPay vesting contracts. This tool empowers users to easily access and manage their vested tokens, ensuring a smooth and efficient experience in handling time-locked assets."
#~ msgstr "The Claim LlamaPay Vesting Hook is a powerful and user-friendly feature designed to streamline the process of claiming funds from LlamaPay vesting contracts. This tool empowers users to easily access and manage their vested tokens, ensuring a smooth and efficient experience in handling time-locked assets."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Unable to check"
+#~ msgstr "Unable to check"
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
msgid "Order Open"
msgstr "Order Open"
@@ -1180,6 +1288,10 @@ msgstr "This app/hook can only be used as a <0>{hookType}-hook0>"
msgid "Place limit order"
msgstr "Place limit order"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Become an affiliate"
+msgstr "Become an affiliate"
+
#: apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx
msgid "Insufficient {symbol} balance"
msgstr "Insufficient {symbol} balance"
@@ -1224,6 +1336,11 @@ msgstr "Claimable amount"
msgid "Proposal 1"
msgstr "Proposal 1"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Edit code"
+msgstr "Edit code"
+
#: apps/cowswap-frontend/src/modules/accountProxy/containers/AccountProxiesPage/index.tsx
msgid "Select an {accountProxyLabelString} to check for available refunds {chain}"
msgstr "Select an {accountProxyLabelString} to check for available refunds {chain}"
@@ -1256,6 +1373,11 @@ msgstr "Begin with TWAP Today!"
msgid "Gas-free"
msgstr "Gas-free"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Eligible volume"
+#~ msgstr "Eligible volume"
+
#: apps/cowswap-frontend/src/modules/limitOrders/pure/DeadlineSelector/deadlines.ts
msgid "5 Minutes"
msgstr "5 Minutes"
@@ -1276,6 +1398,14 @@ msgstr "Want to try out limit orders?"
msgid "smart contracts (e.g. Safe)"
msgstr "smart contracts (e.g. Safe)"
+#. placeholder {0}: programCopy.rewardAmount
+#. placeholder {1}: programCopy.rewardCurrency
+#. placeholder {2}: programCopy.triggerVolume
+#. placeholder {3}: programCopy.timeCapDays
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+msgid "Connect to verify eligibility. Code binds on your first eligible trade. Earn {0} {1} per {2} eligible volume in {3} days. Payouts happen on Ethereum mainnet."
+msgstr "Connect to verify eligibility. Code binds on your first eligible trade. Earn {0} {1} per {2} eligible volume in {3} days. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/CountDown.tsx
msgid "Quote refresh in"
msgstr "Quote refresh in"
@@ -1315,6 +1445,10 @@ msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
#~ msgid "Manage Token Lists"
#~ msgstr "Manage Token Lists"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+msgid "Already linked to a referral code"
+msgstr "Already linked to a referral code"
+
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "No results found"
msgstr "No results found"
@@ -1422,6 +1556,8 @@ msgstr "Please read more in this"
#~ msgstr "Bungee"
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/FilledField.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
msgid "of"
msgstr "of"
@@ -1473,6 +1609,10 @@ msgstr "Market price"
msgid "Max"
msgstr "Max"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Go back"
+msgstr "Go back"
+
#: apps/cowswap-frontend/src/modules/trade/hooks/useGetConfirmButtonLabel.ts
msgid "Approve, Swap & Bridge"
msgstr "Approve, Swap & Bridge"
@@ -1522,6 +1662,10 @@ msgstr "Switch to WETH"
msgid "Great!"
msgstr "Great!"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Download .SVG"
+#~ msgstr "Download .SVG"
+
#: apps/cowswap-frontend/src/modules/bridge/pure/ProxyAccountBanner/index.tsx
msgid "View your private {accountProxyLabelString}"
msgstr "View your private {accountProxyLabelString}"
@@ -1664,6 +1808,10 @@ msgstr "Approving <0/> <1><2/>1> for trading"
#~ msgid "Reduce or withdraw liquidity from a pool before a token swap integrating the process directly into the transaction flow. By adjusting your liquidity ahead of time, you gain more control over your assets without any extra steps. Optimize your position in a pool, all in one seamless action — no need for multiple transactions or added complexity."
#~ msgstr "Reduce or withdraw liquidity from a pool before a token swap integrating the process directly into the transaction flow. By adjusting your liquidity ahead of time, you gain more control over your assets without any extra steps. Optimize your position in a pool, all in one seamless action — no need for multiple transactions or added complexity."
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Goal"
+#~ msgstr "Goal"
+
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/EstimatedExecutionPrice.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderFillsAt/index.tsx
msgid "This price is taken from external sources and may not accurately reflect the current on-chain price."
@@ -1681,6 +1829,10 @@ msgstr "Swap order filled"
msgid "Let's slice some"
msgstr "Let's slice some"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Unable to load affiliate code right now."
+#~ msgstr "Unable to load affiliate code right now."
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/steps/FinishedStep.tsx
msgid "Cancellation failed"
msgstr "Cancellation failed"
@@ -1718,6 +1870,10 @@ msgstr "vCOW contract not present"
msgid "Unknown"
msgstr "Unknown"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Connect to verify code"
+msgstr "Connect to verify code"
+
#: apps/cowswap-frontend/src/common/pure/AddressInputPanel/index.tsx
msgid "Wallet Address or ENS name"
msgstr "Wallet Address or ENS name"
@@ -1794,6 +1950,10 @@ msgstr "Use Safe web app"
msgid "Receiver"
msgstr "Receiver"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Your referral traffic"
+msgstr "Your referral traffic"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
#~ msgid "An order’s actual execution price will vary based on the market price and network costs."
#~ msgstr "An order’s actual execution price will vary based on the market price and network costs."
@@ -1802,6 +1962,10 @@ msgstr "Receiver"
msgid "You have already claimed this airdrop"
msgstr "You have already claimed this airdrop"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Checking availability..."
+#~ msgstr "Checking availability..."
+
#: apps/cowswap-frontend/src/common/containers/MultipleOrdersCancellationModal/index.tsx
msgid "Cancel multiple orders: {ordersCount}"
msgstr "Cancel multiple orders: {ordersCount}"
@@ -1969,6 +2133,10 @@ msgstr "Refunding"
msgid "Failed to cancel order selling {sellTokenSymbol}"
msgstr "Failed to cancel order selling {sellTokenSymbol}"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Signature invalid. Please try again."
+msgstr "Signature invalid. Please try again."
+
#: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx
#~ msgid "You are solely responsible for complying with your local laws."
#~ msgstr "You are solely responsible for complying with your local laws."
@@ -2040,6 +2208,10 @@ msgstr "MEV Slicer"
msgid "From (incl. fees)"
msgstr "From (incl. fees)"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "View rewards"
+msgstr "View rewards"
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts
msgid "Cancelling..."
@@ -2170,6 +2342,10 @@ msgstr "Sign the {operationLabel} with your wallet."
msgid "Recipient"
msgstr "Recipient"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Enter a code with 5-20 characters (A-Z, 0-9, - or _)."
+msgstr "Enter a code with 5-20 characters (A-Z, 0-9, - or _)."
+
#: apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/swapTradeButtonsMap.tsx
#~ msgid "Swap with {symbol}"
#~ msgstr "Swap with {symbol}"
@@ -2198,10 +2374,19 @@ msgstr "to receiver"
msgid "Show progress"
msgstr "Show progress"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeNetworkBanner.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Please connect your wallet to one of our supported networks: {supportedNetworks}."
+msgstr "Please connect your wallet to one of our supported networks: {supportedNetworks}."
+
#: apps/cowswap-frontend/src/modules/application/containers/App/utils/cowSpeechBubbleTyping.ts
msgid "Mooo, we're hiring!"
msgstr "Mooo, we're hiring!"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
+msgid "Next payout"
+msgstr "Next payout"
+
#: apps/cowswap-frontend/src/modules/trade/pure/PartnerFeeRow/index.tsx
msgid "This fee helps pay for maintenance & improvements to the trade experience.<0/><1/>The fee is {partnerFeeBps} BPS ({feeAsPercent}%), applied only if the trade is executed."
msgstr "This fee helps pay for maintenance & improvements to the trade experience.<0/><1/>The fee is {partnerFeeBps} BPS ({feeAsPercent}%), applied only if the trade is executed."
@@ -2279,6 +2464,10 @@ msgstr "(v)COW token holders are eligible for a fee discount"
msgid "Error connecting"
msgstr "Error connecting"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Referral link"
+#~ msgstr "Referral link"
+
#: apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx
msgid "Trade"
msgstr "Trade"
@@ -2323,10 +2512,18 @@ msgstr "Debug Step:"
msgid "Your order may not fill exactly when the market price reaches your limit price."
msgstr "Your order may not fill exactly when the market price reaches your limit price."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Connect your wallet to create a code."
+msgstr "Connect your wallet to create a code."
+
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookTooltip/index.tsx
msgid "BEFORE"
msgstr "BEFORE"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+msgid "Connect to verify eligibility. Code binds on your first eligible trade. Earn rewards for eligible volume within the program window. Payouts happen on Ethereum mainnet."
+msgstr "Connect to verify eligibility. Code binds on your first eligible trade. Earn rewards for eligible volume within the program window. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/bridge/pure/ProxyAccountBanner/index.tsx
#~ msgid "View your private {ACCOUNT_PROXY_LABEL}"
#~ msgstr "View your private {ACCOUNT_PROXY_LABEL}"
@@ -2335,6 +2532,10 @@ msgstr "BEFORE"
msgid "BFF did not return a price for '{currencyAddress}' on chain '{currencyChainId}'"
msgstr "BFF did not return a price for '{currencyAddress}' on chain '{currencyChainId}'"
+#: apps/cowswap-frontend/src/modules/affiliate/components/ReferralCodeModal/content.tsx
+#~ msgid "Code binds on your first eligible trade. Earn 10 USDC per 50k eligible volume in 90 days. Payouts happen on Ethereum mainnet."
+#~ msgstr "Code binds on your first eligible trade. Earn 10 USDC per 50k eligible volume in 90 days. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
msgid "This tool helps you recover your funds."
msgstr "This tool helps you recover your funds."
@@ -2377,6 +2578,10 @@ msgstr "CoW Protocol"
msgid "Insufficient allowance granted for"
msgstr "Insufficient allowance granted for"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Lock this code to your wallet?"
+#~ msgstr "Lock this code to your wallet?"
+
#: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/CustomDappLoader/index.tsx
msgid "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again."
msgstr "Failed to fetch manifest from {manifestUrl}. Please verify the URL and try again."
@@ -2401,6 +2606,10 @@ msgstr "Confirmed"
msgid "place a new order"
msgstr "place a new order"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "This action is permanent. You won't be able to change it."
+#~ msgstr "This action is permanent. You won't be able to change it."
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/ConnectWalletContent.tsx
msgid "To use {orderType} orders, please connect your wallet <0/>to one of our supported networks."
msgstr "To use {orderType} orders, please connect your wallet <0/>to one of our supported networks."
@@ -2429,6 +2638,10 @@ msgstr "Transaction Settings"
msgid "Dismiss hiring message"
msgstr "Dismiss hiring message"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
+msgid "Referral rewards are for new wallets only."
+msgstr "Referral rewards are for new wallets only."
+
#: apps/cowswap-frontend/src/common/pure/Modal/index.tsx
msgid "dialog content"
msgstr "dialog content"
@@ -2449,6 +2662,10 @@ msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it
msgid "Filled on"
msgstr "Filled on"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/ReferralCodeModal/controller.helpers.tsx
+#~ msgid "Rewards for this code have ended."
+#~ msgstr "Rewards for this code have ended."
+
#: apps/cowswap-frontend/src/common/pure/CancellationModal/ModalTopContent.tsx
msgid "less"
msgstr "less"
@@ -2457,6 +2674,10 @@ msgstr "less"
msgid "Market"
msgstr "Market"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+msgid "Enter referral code"
+msgstr "Enter referral code"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/SurplusModal.tsx
msgid "You {orderKind} an extra"
msgstr "You {orderKind} an extra"
@@ -2498,6 +2719,10 @@ msgstr "Safe transaction"
msgid "Traded {inputAmount} for a total of {outputAmount}"
msgstr "Traded {inputAmount} for a total of {outputAmount}"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Enter your code"
+msgstr "Enter your code"
+
#: apps/cowswap-frontend/src/modules/hooksStore/containers/HookRegistryList/index.tsx
msgid "Hook description"
msgstr "Hook description"
@@ -2551,6 +2776,10 @@ msgstr "Expiration"
msgid "Unsupported Wallet"
msgstr "Unsupported Wallet"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "For every $50k eligible volume, <0/> you and the trader each earn $10."
+#~ msgstr "For every $50k eligible volume, <0/> you and the trader each earn $10."
+
#: apps/cowswap-frontend/src/common/utils/tradeSettingsTooltips.tsx
msgid "<0/><1/>Trades are protected from MEV, so your slippage can't be exploited!"
msgstr "<0/><1/>Trades are protected from MEV, so your slippage can't be exploited!"
@@ -2569,6 +2798,10 @@ msgstr "native currency"
#~ msgid "Couldn't verify {ACCOUNT_PROXY_LABEL}, please try later"
#~ msgstr "Couldn't verify {ACCOUNT_PROXY_LABEL}, please try later"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Use a referral code to earn 10 USDC for<0/>every $50k in eligible volume within 90 days.<1/>New wallets only."
+#~ msgstr "Use a referral code to earn 10 USDC for<0/>every $50k in eligible volume within 90 days.<1/>New wallets only."
+
#: apps/cowswap-frontend/src/modules/ethFlow/pure/EthFlowModalContent/configs.ts
msgid "Wrap operation failed."
msgstr "Wrap operation failed."
@@ -2629,6 +2862,10 @@ msgstr "Unsupported wallet detected"
#~ msgid "Tokens Overview"
#~ msgstr "Tokens Overview"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Next {rewardAmountLabel} reward"
+msgstr "Next {rewardAmountLabel} reward"
+
#: apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/getPendingText.ts
msgid "Confirm approval"
msgstr "Confirm approval"
@@ -2645,6 +2882,10 @@ msgstr "No order history"
msgid "Bridging without swapping is not yet supported. Let us know if you want this feature!"
msgstr "Bridging without swapping is not yet supported. Let us know if you want this feature!"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Rewards end"
+msgstr "Rewards end"
+
#: libs/common-utils/src/swapErrorToUserReadableMessage.tsx
#~ msgid "The input token cannot be transferred. There may be an issue with the input token."
#~ msgstr "The input token cannot be transferred. There may be an issue with the input token."
@@ -2669,6 +2910,10 @@ msgstr "Swap to"
msgid "Select a pool"
msgstr "Select a pool"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Download .WEBP"
+msgstr "Download .WEBP"
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/PermitHookApp/index.tsx
msgid "Token not permittable"
msgstr "Token not permittable"
@@ -2699,6 +2944,10 @@ msgstr "Liquidity pools on CoW AMM grow faster than on other AMMs because they d
msgid "TWAP"
msgstr "TWAP"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Rewards activity"
+#~ msgstr "Rewards activity"
+
#: apps/cowswap-frontend/src/modules/trade/pure/ProtocolFeeRow/index.tsx
msgid "Protocol fee ({protocolFeeAsPercent}%)"
msgstr "Protocol fee ({protocolFeeAsPercent}%)"
@@ -2732,6 +2981,10 @@ msgstr "Wrap <0/> and Swap and Bridge"
#~ msgid "Add liquidity to a Uniswap v2 pool after the swap"
#~ msgstr "Add liquidity to a Uniswap v2 pool after the swap"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Copy code"
+#~ msgstr "Copy code"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/steps/OrderIntent.tsx
msgid "{buyTokenPart} for at most {sellTokenPart}"
msgstr "{buyTokenPart} for at most {sellTokenPart}"
@@ -2772,6 +3025,10 @@ msgstr "Enter slippage percentage between {min}% and {max}%"
msgid "Learn how"
msgstr "Learn how"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Enter a referral code with 5 to 20 characters"
+msgstr "Enter a referral code with 5 to 20 characters"
+
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/BundleTxWrapBanner/index.tsx
#~ msgid "Token wrapping bundling"
#~ msgstr "Token wrapping bundling"
@@ -2784,6 +3041,10 @@ msgstr "Learn how"
msgid "Account overview"
msgstr "Account overview"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Affiliate payouts and registration happens on Ethereum mainnet."
+msgstr "Affiliate payouts and registration happens on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/pages/Account/Delegate.tsx
msgid "Delegate your"
msgstr "Delegate your"
@@ -2821,6 +3082,10 @@ msgstr "How much of the order has been filled."
msgid "Bridge costs <0/>"
msgstr "Bridge costs <0/>"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx
+msgid "Rewards code"
+msgstr "Rewards code"
+
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx
msgid "Reset"
msgstr "Reset"
@@ -2879,6 +3144,10 @@ msgstr "{prefix}-hooks allow you to automatically execute any action {position}
#~ msgid "Safe"
#~ msgstr "Safe"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx
+msgid "Add a referral code to earn rewards."
+msgstr "Add a referral code to earn rewards."
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/steps/ExpiredStep.tsx
msgid "Your order expired. This could be due to gas spikes, volatile prices, or problems with the network."
msgstr "Your order expired. This could be due to gas spikes, volatile prices, or problems with the network."
@@ -2892,6 +3161,10 @@ msgstr "Not now"
msgid "Unsupported network"
msgstr "Unsupported network"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "You and your referrals can earn a flat fee <0/> for the eligible volume done through the app. link."
+msgstr "You and your referrals can earn a flat fee <0/> for the eligible volume done through the app. link."
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/constants.ts
msgid "Batching orders"
msgstr "Batching orders"
@@ -2962,6 +3235,11 @@ msgstr "Trade alerts enabled"
#~ msgid "Your swap expires and will not execute if it is pending for longer than the selected duration.<0/><1/>{INPUT_OUTPUT_EXPLANATION}"
#~ msgstr "Your swap expires and will not execute if it is pending for longer than the selected duration.<0/><1/>{INPUT_OUTPUT_EXPLANATION}"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Add code"
+msgstr "Add code"
+
#: apps/cowswap-frontend/src/common/pure/NetworkCostsSuffix/index.tsx
#: apps/cowswap-frontend/src/modules/trade/pure/RowFeeContent/index.tsx
msgid "gas"
@@ -2989,6 +3267,10 @@ msgstr "Price protection"
msgid "Approve {wrappedSymbol} failed!"
msgstr "Approve {wrappedSymbol} failed!"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Track completed rewards over time."
+#~ msgstr "Track completed rewards over time."
+
#: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx
msgid "Hooks are interactions before/after order execution."
msgstr "Hooks are interactions before/after order execution."
@@ -3045,6 +3327,10 @@ msgstr "Cancel all"
msgid "Add Pre-hook"
msgstr "Add Pre-hook"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Switch to Ethereum"
+msgstr "Switch to Ethereum"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/constants.ts
msgid "Limit orders on CoW Swap are free to place and cancel. That's unique in DeFi!"
msgstr "Limit orders on CoW Swap are free to place and cancel. That's unique in DeFi!"
@@ -3057,6 +3343,10 @@ msgstr "safeBundleFlowContext is not set!"
msgid "Invalid URL: No manifest.json file found. Please check the URL and try again."
msgstr "Invalid URL: No manifest.json file found. Please check the URL and try again."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/ReferralCodeModal/controller.helpers.tsx
+#~ msgid "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for the program window."
+#~ msgstr "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for the program window."
+
#: apps/cowswap-frontend/src/pages/Account/LockedGnoVesting/hooks.ts
#: apps/cowswap-frontend/src/pages/Account/LockedGnoVesting/hooks.ts
msgid "COW token not found for chain {SupportedChainIdMAINNET}"
@@ -3147,6 +3437,10 @@ msgstr "There {areIs} <0>{ordersWithPermitLength}0> existing {orderWord} using
msgid "Technical details:"
msgstr "Technical details:"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "This code is invalid. Try another."
+msgstr "This code is invalid. Try another."
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
msgid "How do I recover my funds from {accountProxyLabel}?"
msgstr "How do I recover my funds from {accountProxyLabel}?"
@@ -3186,6 +3480,11 @@ msgstr "Order presigned"
msgid "Get support on Discord"
msgstr "Get support on Discord"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Last updated: {statsUpdatedDisplay}"
+msgstr "Last updated: {statsUpdatedDisplay}"
+
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
#~ msgid "Remove liquidity from a CoW AMM pool before the swap"
#~ msgstr "Remove liquidity from a CoW AMM pool before the swap"
@@ -3223,6 +3522,10 @@ msgstr "Buy per part"
msgid "No intermediate tokens found for the route"
msgstr "No intermediate tokens found for the route"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Share on X"
+msgstr "Share on X"
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/ClaimGnoHookApp/index.tsx
msgid "Total claimable rewards:"
msgstr "Total claimable rewards:"
@@ -3289,6 +3592,10 @@ msgstr "We are unable to calculate the price impact for this order.<0/><1/>You m
msgid "Off-chain cancellations require a signature and are free."
msgstr "Off-chain cancellations require a signature and are free."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Download .PNG"
+msgstr "Download .PNG"
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/AirdropHookApp/hooks/useClaimData.ts
msgid "You are not eligible for this airdrop"
msgstr "You are not eligible for this airdrop"
@@ -3311,6 +3618,10 @@ msgstr "Read more about unsupported tokens"
#~ msgid "Quote refresh in <0>{value} sec0>"
#~ msgstr "Quote refresh in <0>{value} sec0>"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Switch to base & claim"
+#~ msgstr "Switch to base & claim"
+
#: apps/cowswap-frontend/src/api/cowProtocol/errors/OperatorError.ts
msgid "cancelling"
msgstr "cancelling"
@@ -3446,6 +3757,10 @@ msgstr "CoW Swap dynamically adjusts your slippage tolerance to ensure your trad
msgid "CoW Swap's robust solver competition protects your slippage from being exploited by MEV bots."
msgstr "CoW Swap's robust solver competition protects your slippage from being exploited by MEV bots."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/ReferralCodeModal/controller.helpers.tsx
+#~ msgid "Rewards ended for this code. Try another."
+#~ msgstr "Rewards ended for this code. Try another."
+
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
#~ msgid "Aave Debt Swap Flashloan"
#~ msgstr "Aave Debt Swap Flashloan"
@@ -3465,10 +3780,29 @@ msgstr "Order"
msgid "TWAP orders require a one-time update to your Safe to enable automated execution of scheduled transactions."
msgstr "TWAP orders require a one-time update to your Safe to enable automated execution of scheduled transactions."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Unable to verify code"
+msgstr "Unable to verify code"
+
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+msgid "Pending"
+msgstr "Pending"
+
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Unable to generate QR code."
+#~ msgstr "Unable to generate QR code."
+
#: apps/cowswap-frontend/src/common/pure/OrderSubmittedContent/index.tsx
msgid "Order Submitted"
msgstr "Order Submitted"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Left to next {rewardAmountLabel}"
+msgstr "Left to next {rewardAmountLabel}"
+
#: apps/cowswap-frontend/src/pages/error/AnySwapAffectedUsers/index.tsx
msgid "You have given an allowance to <0>AnyswapV4Router0> which is affected by a critical vulnerability."
msgstr "You have given an allowance to <0>AnyswapV4Router0> which is affected by a critical vulnerability."
@@ -3485,6 +3819,10 @@ msgstr "This could have happened due to the lack of internet or the release of a
#~ msgid "No open orders"
#~ msgstr "No open orders"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "No rewards to claim"
+#~ msgstr "No rewards to claim"
+
#: libs/common-utils/src/tooltips.ts
#~ msgid "Maximum tokens you'll sell."
#~ msgstr "Maximum tokens you'll sell."
@@ -3531,6 +3869,8 @@ msgstr "Only the {limit} most recent orders were searched."
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/PermitHookApp/index.tsx
#: apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
msgid "Connect wallet"
msgstr "Connect wallet"
@@ -3554,6 +3894,10 @@ msgstr "is worth"
msgid "View account"
msgstr "View account"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Donut chart tracks eligible volume left to unlock the next reward."
+msgstr "Donut chart tracks eligible volume left to unlock the next reward."
+
#: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx
msgid "When you swap (sell) <0/>, solvers handle the transaction by purchasing the required tokens, depositing them into the pool, and issuing LP tokens to you in return — all in a gas-less operation."
msgstr "When you swap (sell) <0/>, solvers handle the transaction by purchasing the required tokens, depositing them into the pool, and issuing LP tokens to you in return — all in a gas-less operation."
@@ -3615,6 +3959,10 @@ msgstr "The order cannot be {msg}. Please, retry in a minute"
msgid "Request cancellations"
msgstr "Request cancellations"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Left to next $10"
+#~ msgstr "Left to next $10"
+
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowSlippageContent/index.tsx
msgid "Recommended"
msgstr "Recommended"
@@ -3630,6 +3978,10 @@ msgstr "Price change"
msgid "Yield"
msgstr "Yield"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "The code <0>{codeForDisplay}0> from your link wasn’t applied."
+msgstr "The code <0>{codeForDisplay}0> from your link wasn’t applied."
+
#: apps/cowswap-frontend/src/modules/tokensList/containers/ManageLists/index.tsx
msgid "Error importing token list"
msgstr "Error importing token list"
@@ -3668,6 +4020,10 @@ msgstr "Use the <0>Safe app0> for advanced trading."
msgid "Top"
msgstr "Top"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Referral QR code"
+#~ msgstr "Referral QR code"
+
#: apps/cowswap-frontend/src/modules/twap/containers/TwapFormWidget/tooltips.tsx
msgid "Estimated amount that will be sold in each part of the order."
msgstr "Estimated amount that will be sold in each part of the order."
@@ -3754,6 +4110,10 @@ msgstr "This token is not available in your region."
msgid "No signatures yet"
msgstr "No signatures yet"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Invalid format"
+#~ msgstr "Invalid format"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/constants.ts
msgid "Start bridging"
msgstr "Start bridging"
@@ -3790,6 +4150,14 @@ msgstr "Delegate"
msgid "cancel the order"
msgstr "cancel the order"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Signature request rejected."
+msgstr "Signature request rejected."
+
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
+msgid "The code <0>{incomingCode}0> from your link wasn't applied because this wallet has already traded on CoW Swap."
+msgstr "The code <0>{incomingCode}0> from your link wasn't applied because this wallet has already traded on CoW Swap."
+
#: apps/cowswap-frontend/src/modules/hooksStore/containers/HookRegistryList/index.tsx
msgid "Can't find a hook that you like?"
msgstr "Can't find a hook that you like?"
@@ -3824,6 +4192,10 @@ msgstr "Transaction was cancelled or sped up"
msgid "Custom Recipient"
msgstr "Custom Recipient"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Download QR"
+msgstr "Download QR"
+
#: apps/cowswap-frontend/src/modules/ethFlow/pure/EthFlowStepper/steps/Step3.tsx
msgid "Received {tokenLabel}"
msgstr "Received {tokenLabel}"
@@ -3836,6 +4208,10 @@ msgstr "Please, make sure your address follows the format <0>{addressPrefix}:{symbol}0> allowance"
msgstr "Reset <0>{symbol}0> allowance"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Create your referral code"
+msgstr "Create your referral code"
+
#: apps/cowswap-frontend/src/legacy/components/Badge/RangeBadge.tsx
msgid "The price of this pool is within your selected range. Your position is currently earning fees."
msgstr "The price of this pool is within your selected range. Your position is currently earning fees."
@@ -4142,6 +4541,10 @@ msgstr "more signatures are"
msgid "Current network costs make up <0><1>{formattedFeePercentage}%1>0> of your swap amount."
msgstr "Current network costs make up <0><1>{formattedFeePercentage}%1>0> of your swap amount."
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Switch network"
+msgstr "Switch network"
+
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/EstimatedExecutionPrice.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/EstimatedExecutionPrice.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/OrderWarning.tsx
@@ -4160,6 +4563,12 @@ msgstr "This token doesn't appear on the active token list(s). Make sure this is
msgid "Between currencies"
msgstr "Between currencies"
+#. Explains how much a referrer earns for referred trading volume within 90 days.
+#. js-lingui-explicit-id
+#: apps/cowswap-frontend/src/modules/affiliate/constants.ts
+#~ msgid "affiliate.referral.program.rules"
+#~ msgstr "Earn 10 USDC per 50k eligible volume in 90 days."
+
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SellNativeWarningBanner/index.tsx
msgid "wrapped native"
msgstr "wrapped native"
@@ -4235,6 +4644,14 @@ msgstr "The winner of the competition is now executing your order on-chain."
msgid "Network costs"
msgstr "Network costs"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Switch to Ethereum mainnet to create a code."
+msgstr "Switch to Ethereum mainnet to create a code."
+
+#: apps/cowswap-frontend/src/modules/affiliate/components/ReferralCodeModal/controller.helpers.tsx
+#~ msgid "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for 90 days."
+#~ msgstr "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for 90 days."
+
#: apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx
msgid "PRE"
msgstr "PRE"
@@ -4291,6 +4708,10 @@ msgstr "Now"
msgid "Mooove between <0/> any chain, hassle-free"
msgstr "Mooove between <0/> any chain, hassle-free"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Affiliate payouts happen on Ethereum mainnet."
+#~ msgstr "Affiliate payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SellNativeWarningBanner/index.tsx
msgid "Wrap {nativeSymbol} to {wrappedNativeSymbol}"
msgstr "Wrap {nativeSymbol} to {wrappedNativeSymbol}"
@@ -4331,6 +4752,10 @@ msgstr "Another order has used up the approval amount. Set a new token approval
msgid "Enter valid token address"
msgstr "Enter valid token address"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Type or generate a code (subject to availability). Saving locks this code to your wallet and can't be changed."
+#~ msgstr "Type or generate a code (subject to availability). Saving locks this code to your wallet and can't be changed."
+
#: apps/cowswap-frontend/src/modules/rwa/pure/RwaConsentModal/index.tsx
#~ msgid "Confirm and continue"
#~ msgstr "Confirm and continue"
@@ -4343,6 +4768,11 @@ msgstr "vCOW conversion"
msgid "Sorry, we were unable to load the requested page."
msgstr "Sorry, we were unable to load the requested page."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Available"
+msgstr "Available"
+
#: apps/cowswap-frontend/src/modules/ethFlow/pure/EthFlowStepper/steps/Step2.tsx
#: apps/cowswap-frontend/src/modules/ethFlow/pure/EthFlowStepper/steps/Step2.tsx
msgid "Order Creation Failed"
@@ -4354,9 +4784,18 @@ msgid "This wallet is not yet supported"
msgstr "This wallet is not yet supported"
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
msgid "Created"
msgstr "Created"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Wallet signer unavailable."
+msgstr "Wallet signer unavailable."
+
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for the entire program."
+msgstr "Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for the entire program."
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/ClaimGnoHookApp/index.tsx
msgid "Update Pre-hook"
msgstr "Update Pre-hook"
@@ -4376,6 +4815,11 @@ msgstr "Cancel"
msgid "Wrapping {amountStr} {native} to {wrapped}"
msgstr "Wrapping {amountStr} {native} to {wrapped}"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Your wallet is ineligible"
+msgstr "Your wallet is ineligible"
+
#: apps/cowswap-frontend/src/modules/orders/containers/FulfilledOrderInfo/index.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "Order surplus"
@@ -4503,6 +4947,10 @@ msgstr "Transaction {errorType}"
msgid "Price impact <0>unknown0> - trade carefully"
msgstr "Price impact <0>unknown0> - trade carefully"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Earn while you trade"
+msgstr "Earn while you trade"
+
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SettingsTab/index.tsx
msgid "Enable partial approvals"
msgstr "Enable partial approvals"
@@ -4533,6 +4981,7 @@ msgstr "versions of"
msgid "Edit partial approval"
msgstr "Edit partial approval"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/OrderContextMenu.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "Edit"
@@ -4643,6 +5092,10 @@ msgstr "Confirm Action"
msgid "more"
msgstr "more"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx
+msgid "Your referral code is saved. It will link after your first eligible trade."
+msgstr "Your referral code is saved. It will link after your first eligible trade."
+
#: apps/cowswap-frontend/src/modules/hooksStore/containers/HookSearchInput/index.tsx
msgid "Clear search input"
msgstr "Clear search input"
@@ -4665,6 +5118,8 @@ msgid "Recreate"
msgstr "Recreate"
#: apps/cowswap-frontend/src/modules/bridge/pure/contents/SwapResultContent/contents.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
msgid "Received"
msgstr "Received"
@@ -4750,6 +5205,14 @@ msgstr "Powered by"
msgid "Accept"
msgstr "Accept"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Links/codes don't reveal your wallet."
+msgstr "Links/codes don't reveal your wallet."
+
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Save & lock code"
+msgstr "Save & lock code"
+
#: apps/cowswap-frontend/src/modules/bridge/pure/BridgeActivitySummary/BridgeSummaryHeader.tsx
msgid "To at least"
msgstr "To at least"
@@ -4770,6 +5233,11 @@ msgstr "Quote expired. Refreshing..."
#~ msgid "Aave Repay"
#~ msgstr "Aave Repay"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+msgid "Code binds on your first eligible trade. Earn rewards for eligible volume within the program window. Payouts happen on Ethereum mainnet."
+msgstr "Code binds on your first eligible trade. Earn rewards for eligible volume within the program window. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/hooksStore/pure/AddCustomHookForm/constants.tsx
msgid "The URL provided does not return a valid manifest file<0/><1>The server returned an HTML page instead of the expected JSON manifest.json file. Please check if the URL is correct and points to a valid hook dapp.1>"
msgstr "The URL provided does not return a valid manifest file<0/><1>The server returned an HTML page instead of the expected JSON manifest.json file. Please check if the URL is correct and points to a valid hook dapp.1>"
@@ -4816,6 +5284,15 @@ msgstr "Add <0/> to {walletName}"
msgid "Orders on CoW Swap can either be market orders (which fill at the market price within the slippage tolerance you set) or limit orders (which fill at a price you specify)."
msgstr "Orders on CoW Swap can either be market orders (which fill at the market price within the slippage tolerance you set) or limit orders (which fill at a price you specify)."
+#. placeholder {0}: programCopy.rewardAmount
+#. placeholder {1}: programCopy.rewardCurrency
+#. placeholder {2}: programCopy.triggerVolume
+#. placeholder {3}: programCopy.timeCapDays
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
+msgid "Code binds on your first eligible trade. Earn {0} {1} per {2} eligible volume in {3} days. Payouts happen on Ethereum mainnet."
+msgstr "Code binds on your first eligible trade. Earn {0} {1} per {2} eligible volume in {3} days. Payouts happen on Ethereum mainnet."
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx
msgid "at least"
msgstr "at least"
@@ -4856,6 +5333,11 @@ msgstr "Lower minimal slippage (instead of {minEthFlowSlippageToSignificant}% mi
msgid "Submit"
msgstr "Submit"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Claimed"
+#~ msgstr "Claimed"
+
#: libs/common-const/src/common.ts
#~ msgid "Account Overview"
#~ msgstr "Account Overview"
@@ -4868,6 +5350,10 @@ msgstr "FREE order placement and cancellation"
msgid "No expired orders found"
msgstr "No expired orders found"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Use a referral code to earn a flat fee for<0/>the eligible volume done through the app.<1/>New wallets only."
+msgstr "Use a referral code to earn a flat fee for<0/>the eligible volume done through the app.<1/>New wallets only."
+
#: apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx
msgid "You cannot edit this field when selling"
msgstr "You cannot edit this field when selling"
@@ -4990,6 +5476,13 @@ msgstr "CoW Swap sets the standard for protecting against MEV attacks such as fr
msgid "You receive exactly"
msgstr "You receive exactly"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Checking"
+msgstr "Checking"
+
#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx
msgid "Enter an amount"
msgstr "Enter an amount"
@@ -5039,6 +5532,10 @@ msgstr "on {chainLabel}"
msgid "Invalid price. Try increasing input/output amount."
msgstr "Invalid price. Try increasing input/output amount."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Volume referred"
+msgstr "Volume referred"
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
#~ msgid "Since {ACCOUNT_PROXY_LABEL} is not an upgradeable smart-contract, it can be versioned and there are"
#~ msgstr "Since {ACCOUNT_PROXY_LABEL} is not an upgradeable smart-contract, it can be versioned and there are"
@@ -5133,11 +5630,25 @@ msgstr "Enter valid list location"
msgid "View"
msgstr "View"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Unavailable"
+#~ msgstr "Unavailable"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTableContainer/tableHeaders.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "Execution price"
msgstr "Execution price"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Linked"
+msgstr "Linked"
+
+#: apps/cowswap-frontend/src/pages/Account/index.tsx
+msgid "Rewards hub - Affiliate"
+msgstr "Rewards hub - Affiliate"
+
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
msgid "No tokens found"
msgstr "No tokens found"
@@ -5278,10 +5789,18 @@ msgstr "Add {hookTypeText}-hook"
msgid "Order filled"
msgstr "Order filled"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Invalid request."
+msgstr "Invalid request."
+
#: apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/getPendingText.ts
msgid "Confirm swap"
msgstr "Confirm swap"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Availability"
+#~ msgstr "Availability"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "Part of a {twapOrderN}-part TWAP order split"
msgstr "Part of a {twapOrderN}-part TWAP order split"
@@ -5339,11 +5858,27 @@ msgstr "Add"
msgid "The order will start when it is validated and executed in your Safe."
msgstr "The order will start when it is validated and executed in your Safe."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
+msgid "Paid weekly via airdrop."
+msgstr "Paid weekly via airdrop."
+
#: apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/ConfirmButton.tsx
#: apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormBlankButton/index.tsx
msgid "Confirm with your wallet"
msgstr "Confirm with your wallet"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Last updated: --"
+#~ msgstr "Last updated: --"
+
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+msgid "applied!"
+msgstr "applied!"
+
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Date"
+#~ msgstr "Date"
+
#: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx
#~ msgid "Swaps just got smarter"
#~ msgstr "Swaps just got smarter"
@@ -5376,6 +5911,10 @@ msgstr "Terms and Conditions"
msgid "From {PROTOCOL_FEE_START_DATETIME_UTC}, and pursuant to <0>CIP-740>, a <1>protocol fee1> will apply to all executed orders, including any limit and TWAP orders executed after this time, even if they were created earlier."
msgstr "From {PROTOCOL_FEE_START_DATETIME_UTC}, and pursuant to <0>CIP-740>, a <1>protocol fee1> will apply to all executed orders, including any limit and TWAP orders executed after this time, even if they were created earlier."
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Invite your friends <0/> and earn rewards"
+msgstr "Invite your friends <0/> and earn rewards"
+
#: apps/cowswap-frontend/src/modules/fortune/containers/FortuneWidget/index.tsx
msgid "Hide today's fortune cookie"
msgstr "Hide today's fortune cookie"
@@ -5393,6 +5932,10 @@ msgstr "Post Hooks"
msgid "Not compatible with current wallet type"
msgstr "Not compatible with current wallet type"
+#: apps/cowswap-frontend/src/pages/Account/index.tsx
+msgid "Rewards hub - My rewards"
+msgstr "Rewards hub - My rewards"
+
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/hooks/useWidgetMetadata.ts
msgid "Swap from"
msgstr "Swap from"
@@ -5417,6 +5960,10 @@ msgstr "Your trade will be executed in 2 stops. First, you swap on <0>{COW_PROTO
msgid "Wallet chainId differs from app chainId. Wallet: {networkString}, App: {chainId}. Action: {description}"
msgstr "Wallet chainId differs from app chainId. Wallet: {networkString}, App: {chainId}. Action: {description}"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Download referral QR code"
+msgstr "Download referral QR code"
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "Executes at"
msgstr "Executes at"
@@ -5449,10 +5996,22 @@ msgstr "Information"
msgid "The total surplus CoW Swap has generated for you in {nativeSymbol} across all your trades since {startDate}"
msgstr "The total surplus CoW Swap has generated for you in {nativeSymbol} across all your trades since {startDate}"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Type or generate a code (subject to availability). Saving locks this code to your wallet and can't be changed. Links/codes don't reveal your wallet."
+msgstr "Type or generate a code (subject to availability). Saving locks this code to your wallet and can't be changed. Links/codes don't reveal your wallet."
+
#: apps/cowswap-frontend/src/modules/trade/pure/ProtocolFeeRow/index.tsx
#~ msgid "Protocol fee applied to this trade.<0/><1/>The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed."
#~ msgstr "Protocol fee applied to this trade.<0/><1/>The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Valid"
+msgstr "Valid"
+
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/EstimatedExecutionPrice.tsx
msgid "For this order, network costs would be"
msgstr "For this order, network costs would be"
@@ -5462,6 +6021,13 @@ msgstr "For this order, network costs would be"
msgid "Canceling your order"
msgstr "Canceling your order"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Referral code"
+msgstr "Referral code"
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/steps/FinishedStep.tsx
msgid "{solversLength} out of {totalSolvers} solvers"
msgstr "{solversLength} out of {totalSolvers} solvers"
@@ -5486,6 +6052,10 @@ msgstr "Unknown Currency"
msgid "Receive {tokenLabel}"
msgstr "Receive {tokenLabel}"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
+msgid "Save to verify code"
+msgstr "Save to verify code"
+
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ChangeApproveAmountModal/ChangeApproveAmountModalPure.tsx
msgid "Set approval amount"
msgstr "Set approval amount"
@@ -5576,11 +6146,19 @@ msgstr "Cross-chain swaps are here"
msgid "Fee discount"
msgstr "Fee discount"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Only A-Z, 0-9, dashes, and underscores are allowed."
+msgstr "Only A-Z, 0-9, dashes, and underscores are allowed."
+
#: apps/cowswap-frontend/src/modules/erc20Approve/pure/ApproveButton/index.tsx
#: apps/cowswap-frontend/src/modules/erc20Approve/pure/LegacyApproveButton/index.tsx
msgid "Allow CoW Swap to use your <0/>"
msgstr "Allow CoW Swap to use your <0/>"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
+msgid "successfully"
+msgstr "successfully"
+
#: apps/cowswap-frontend/src/modules/twap/utils/deadlinePartsDisplay.ts
msgid "mo"
msgstr "mo"
@@ -5656,6 +6234,11 @@ msgstr "The order remains open. Execution requires sufficient"
msgid "Connect signer"
msgstr "Connect signer"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Referral link unavailable."
+msgstr "Referral link unavailable."
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/WalletNotConnected/index.tsx
msgid "Connect wallet banner"
msgstr "Connect wallet banner"
@@ -5741,6 +6324,10 @@ msgstr "Request is being rate limited"
#~ msgid "The Morpho Borrow hook enables users to seamlessly combine swaps with borrow-related actions. Users can enter new positions, leave existing ones, or move between different markets through a unified, streamlined process."
#~ msgstr "The Morpho Borrow hook enables users to seamlessly combine swaps with borrow-related actions. Users can enter new positions, leave existing ones, or move between different markets through a unified, streamlined process."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/ReferralIneligibleCopy.tsx
+#~ msgid "This wallet has already traded on CoW Swap. Referral rewards are for new wallets only."
+#~ msgstr "This wallet has already traded on CoW Swap. Referral rewards are for new wallets only."
+
#: apps/cowswap-frontend/src/modules/trade/const/common.ts
msgid "When enabled, the orders table will be displayed on the left side on desktop screens. On mobile, the orders table will always be stacked below."
msgstr "When enabled, the orders table will be displayed on the left side on desktop screens. On mobile, the orders table will always be stacked below."
@@ -5802,6 +6389,10 @@ msgstr "This price is {displayedPercent}% lower than current market price. You c
msgid "Continue swap with {wrappedSymbol}"
msgstr "Continue swap with {wrappedSymbol}"
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+msgid "Linked since"
+msgstr "Linked since"
+
#: apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx
msgid "Light mode"
msgstr "Light mode"
@@ -5871,6 +6462,10 @@ msgstr "required"
msgid "Fetching balances"
msgstr "Fetching balances"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "This code is taken. Generate another one."
+msgstr "This code is taken. Generate another one."
+
#: apps/cowswap-frontend/src/modules/wallet/pure/Web3StatusInner/index.tsx
msgid "Wallet"
msgstr "Wallet"
@@ -5902,6 +6497,10 @@ msgstr "The first part of your TWAP order will become active as soon as you conf
msgid "If CoW Swap cannot get this price or better (taking into account fees and price protection tolerance), your TWAP will not execute. CoW Swap will <0>always0> improve on this price if possible."
msgstr "If CoW Swap cannot get this price or better (taking into account fees and price protection tolerance), your TWAP will not execute. CoW Swap will <0>always0> improve on this price if possible."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
+msgid "How it works."
+msgstr "How it works."
+
#: apps/cowswap-frontend/src/pages/error/AnySwapAffectedUsers/index.tsx
msgid "In order to protect your funds, you will need to remove the approval on this contract."
msgstr "In order to protect your funds, you will need to remove the approval on this contract."
@@ -5914,6 +6513,7 @@ msgstr "Advanced"
msgid "Not yet supported"
msgstr "Not yet supported"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookListItem/index.tsx
msgid "Remove"
msgstr "Remove"
@@ -5974,6 +6574,10 @@ msgstr "Reload page"
msgid "Enter a valid recipient"
msgstr "Enter a valid recipient"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx
+msgid "Earn more by adding a referral code."
+msgstr "Earn more by adding a referral code."
+
#: apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/finalizeEthFlowTx.ts
msgid "Failed to place order selling {nativeCurrencySymbol}"
msgstr "Failed to place order selling {nativeCurrencySymbol}"
@@ -5991,6 +6595,10 @@ msgstr "Your order will execute when the market price is {displayedPercent}% bet
msgid "Recover funds"
msgstr "Recover funds"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Your referral code"
+msgstr "Your referral code"
+
#: apps/cowswap-frontend/src/common/pure/CancelButton/index.tsx
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/OrderContextMenu.tsx
msgid "Cancel order"
@@ -6005,6 +6613,11 @@ msgstr "Your order was created on this date & time. It will remain open until it
msgid "Confirm Swap"
msgstr "Confirm Swap"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+msgid "Save"
+msgstr "Save"
+
#: apps/cowswap-frontend/src/modules/bridge/pure/ProxyAccountBanner/index.tsx
msgid "Modified recipient address to"
msgstr "Modified recipient address to"
@@ -6236,10 +6849,18 @@ msgstr "Cancellation transaction"
msgid "{walletName} account changed"
msgstr "{walletName} account changed"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Download QR code"
+#~ msgstr "Download QR code"
+
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "Value"
msgstr "Value"
+#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx
+msgid "Your wallet is linked to this referral code."
+msgstr "Your wallet is linked to this referral code."
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/index.tsx
msgid "You sell"
msgstr "You sell"
@@ -6272,6 +6893,10 @@ msgstr "Delegate Now"
msgid "Try new limit orders now"
msgstr "Try new limit orders now"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Affiliate code created."
+#~ msgstr "Affiliate code created."
+
#: apps/cowswap-frontend/src/modules/tokensList/containers/ManageTokens/index.tsx
msgid "Custom Tokens"
msgstr "Custom Tokens"
@@ -6346,6 +6971,10 @@ msgstr "Lower overall network costs"
#~ msgid "An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If that does not work, there may be an incompatibility with the token you are trading. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3."
#~ msgstr "An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If that does not work, there may be an incompatibility with the token you are trading. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3."
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Next $10 reward"
+#~ msgstr "Next $10 reward"
+
#: apps/cowswap-frontend/src/modules/ordersTable/containers/OrderRow/OrderWarning.tsx
msgid "If the balance remains insufficient at creation time, this order portion will not be created. Add more"
msgstr "If the balance remains insufficient at creation time, this order portion will not be created. Add more"
@@ -6380,6 +7009,10 @@ msgstr "Convert vCOW to COW"
msgid "Exec. price"
msgstr "Exec. price"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
+msgid "This wallet has already traded on CoW Swap. <0/> Referral rewards are for new wallets only."
+msgstr "This wallet has already traded on CoW Swap. <0/> Referral rewards are for new wallets only."
+
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/steps/FinishedStep.tsx
msgid "You sold <0/>"
msgstr "You sold <0/>"
@@ -6416,6 +7049,10 @@ msgstr "Your order was successfully cancelled."
msgid "surplus cow"
msgstr "surplus cow"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#~ msgid "Copy link"
+#~ msgstr "Copy link"
+
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/BuildHookApp/index.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/dapps/PermitHookApp/index.tsx
#: apps/cowswap-frontend/src/modules/hooksStore/pure/HookTooltip/index.tsx
@@ -6469,6 +7106,11 @@ msgstr "Dark mode"
msgid "Bridge costs"
msgstr "Bridge costs"
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+#: apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
+#~ msgid "Claimable rewards"
+#~ msgstr "Claimable rewards"
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/StatusDetails.tsx
msgid "View cancellation"
msgstr "View cancellation"
@@ -6551,6 +7193,11 @@ msgstr "The {operationLabel} is signed."
msgid "The price impact is <0>{formattedPriceImpact}%0>. Consider breaking up your order using a <1>TWAP order1> and possibly get a better rate."
msgstr "The price impact is <0>{formattedPriceImpact}%0>. Consider breaking up your order using a <1>TWAP order1> and possibly get a better rate."
+#: apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
+#: apps/cowswap-frontend/src/pages/Account/Affiliate.tsx
+msgid "Referral codes contain 5-20 uppercase letters, numbers, dashes, or underscores"
+msgstr "Referral codes contain 5-20 uppercase letters, numbers, dashes, or underscores"
+
#: apps/cowswap-frontend/src/common/pure/ReceiveAmount/index.tsx
msgid "Receive (incl. fees)"
msgstr "Receive (incl. fees)"
@@ -6615,6 +7262,10 @@ msgstr "Entered amount is invalid"
#~ msgid "Mev Slicer"
#~ msgstr "Mev Slicer"
+#: apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
+msgid "The amount you should expect to receive at the next payout, if no further volume is generated."
+msgstr "The amount you should expect to receive at the next payout, if no further volume is generated."
+
#: apps/cowswap-frontend/src/utils/orderUtils/parseOrder.ts
msgid "Creation transaction"
msgstr "Creation transaction"
diff --git a/apps/cowswap-frontend/src/modules/account/containers/CopyHelper/index.tsx b/apps/cowswap-frontend/src/modules/account/containers/CopyHelper/index.tsx
index aa9e0db3edb..0b7b28f573a 100644
--- a/apps/cowswap-frontend/src/modules/account/containers/CopyHelper/index.tsx
+++ b/apps/cowswap-frontend/src/modules/account/containers/CopyHelper/index.tsx
@@ -1,11 +1,11 @@
import React, { useCallback } from 'react'
import { useCopyClipboard } from '@cowprotocol/common-hooks'
+import { LinkStyledButton } from '@cowprotocol/ui'
import { Trans } from '@lingui/react/macro'
import { CheckCircle, Copy } from 'react-feather'
import styled from 'styled-components/macro'
-import { LinkStyledButton } from 'theme'
const CopyIcon = styled(LinkStyledButton)`
color: ${({ color, theme }) => color || theme.info};
diff --git a/apps/cowswap-frontend/src/modules/affiliate/README.md b/apps/cowswap-frontend/src/modules/affiliate/README.md
new file mode 100644
index 00000000000..8b38237653f
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/README.md
@@ -0,0 +1,150 @@
+# Affiliate program (partners + traders)
+
+## 1) Purpose
+
+The affiliate program will amplify word-of-mouth marketing for CoW Swap by incentivizing referrals. Its mechanic will also facilitate a slew of other marketing tactics including KOL (influencer) and publisher activation, low funnel offers (e.g. with social and display ads), and high funnel measurement (e.g. with podcast and OOH ads).
+
+## 2) Actors
+
+- Traders
+- Partners or Affiliates (KOLs/influencers, publishers)
+- Program managers (Marketing Squad)
+- Accountants (Finance Squad)
+- Maintainers (Web Squad, DevOps Squad)
+
+## 3) Feature flag
+
+- Gated by a LaunchDarkly feature flag called `isAffiliateProgramEnabled`
+- When enabled:
+ 1. two new pages show up in `Account`, i.e. `My Rewards` and `Affiliate`
+ 2. the referral code input row shows up in the trade form alongside a Modal to input the code
+
+## 4) Data flow
+
+- CMS is source of truth for codes
+- Dune is the source of truth for volumes, payouts and eligibility
+- BFF checks CMS every 5 minutes for new/updated codes
+- Dune is used for accounting
+- Frontend relies on BFF and does not call Dune/CMS directly
+
+```mermaid
+flowchart TB
+ CMS[Strapi CMS]
+ BFF[BFF service]
+ FE[Frontend]
+ Dune
+
+ BFF -->|5-min polling| CMS
+ CMS -->|codes & params| BFF
+
+ BFF -->|codes & params| Dune
+ Dune -->|stats| BFF
+
+ BFF -->|/affiliate/:address| FE
+ BFF -->|/ref-codes/:code| FE
+ BFF -->|/affiliate/affiliate-stats/:address| FE
+ BFF -->|/affiliate/trader-stats/:address| FE
+
+```
+
+## 5) Codes
+
+- Immutable (except for the `enabled` flag) to keep accounting simple
+- Unique code per partner (1:1 mapping)
+- If a partner needs multiple codes, they must use multiple wallets
+- You can bypass the immutability by deleting and recreating the code, please avoid doing this!!
+
+## 5.1) Disabling codes
+
+- Program managers can soft-disable a code using the CMS Admin dashboard
+- This stops new sign-ups that go through our frontend
+- This does not affect historical payouts
+- It can be bypassed by traders that do not use our frontend
+
+## 5.2) Default params + updates
+
+- Maintainers can change the program defaults by updating the CMS environment variables:
+ 1. Tweak params in `/workspaces/infrastructure/cms/index.ts`
+ 2. Run `pulumi up` in `/workspaces/infrastructure/cms` (after ssologin, pulumi stack select)
+
+- CMS env defaults:
+ - `AFFILIATE_REWARD_AMOUNT` = `20`
+ - `AFFILIATE_TRIGGER_VOLUME` = `250000`
+ - `AFFILIATE_TIME_CAP_DAYS` = `90`
+ - `AFFILIATE_VOLUME_CAP` = `0` (unlimited)
+ - `AFFILIATE_REVENUE_SPLIT_AFFILIATE_PCT` = `50`
+ - `AFFILIATE_REVENUE_SPLIT_TRADER_PCT` = `50`
+ - `AFFILIATE_REVENUE_SPLIT_DAO_PCT` = `0`
+
+## 5.3) Special codes
+
+- Program managers can create a new code from the CMS dashboard with special parameters by specifying any of the following:
+ `REWARD_AMOUNT, TRIGGER_VOLUME, TIME_CAP_DAYS, VOLUME_CAP, REVENUE_SPLIT_AFFILIATE_PCT, REVENUE_SPLIT_TRADER_PCT, REVENUE_SPLIT_DAO_PCT`
+- Program managers can also a new code from the CMS dashboard with default parameters by leaving the above fields empty.
+- Important: if you create a code in CMS it might take up to 5 minutes to show up in the BFF due to polling interval.
+
+## 6) Partner privacy
+
+Goal: protect partner privacy by not leaking wallet addresses
+
+Audit:
+
+- Ensure FE uses a CMS key that cannot read the affiliate collection
+- Ensure BFF strips partner address from `/ref-codes/:code` (called by traders)
+- Ensure CMS disallows reading the affiliate collection without API key
+
+## 6.1) Revenue splits privacy
+
+Goal: protect revenue splits by not leaking them to traders
+
+Audit:
+
+- BFF strips revenue split fields from `/ref-codes/:code`, instead it only returns `traderRewardAmount`
+- This is calculated as `rewardAmount * revenueSplitTraderPct / 100`
+
+## 7) Eligibility (hard requirement)
+
+- New traders only; allowing existing traders is non-trivial
+- FE checks prior orders and informs users if they are eligible
+- Dune filters out ineligible traders
+
+## 8) Environments
+
+Staging:
+
+- BFF:
+- CMS:
+- FE:
+- DUNE_QUERY_ID_TRADER_STATS: `6648679`
+- DUNE_QUERY_ID_AFFILIATE_STATS: `6648689`
+- DUNE_AFFILIATE_PROGRAM_TABLE_NAME: `affiliate_program_data_staging`
+
+```sh
+curl -s "https://bff.barn.cow.fi/ref-codes/FOOBAR"
+curl -s "https://bff.barn.cow.fi/affiliate/0x6fc1Fb2e17DFf120fa8F838af139aF443070Fd0E"
+curl -s "https://bff.barn.cow.fi/affiliate/affiliate-stats/0x6fc1Fb2e17DFf120fa8F838af139aF443070Fd0E"
+curl -s "https://bff.barn.cow.fi/affiliate/trader-stats/0x6fc1Fb2e17DFf120fa8F838af139aF443070Fd0E"
+```
+
+Production:
+
+- BFF:
+- CMS:
+- FE:
+- DUNE_QUERY_ID_TRADER_STATS: `6560853`
+- DUNE_QUERY_ID_AFFILIATE_STATS: `6560325`
+- DUNE_AFFILIATE_PROGRAM_TABLE_NAME: `affiliate_program_data`
+
+## 9) Payouts
+
+- Payouts happen on Mainnet only, using USDC
+- Very very important: payouts must be done from 2 different wallets. One for partners and one for traders. Otherwise the math breaks.
+- Using a SafeWallet you can created Nested Safes and label them accordingly: `affiliate payouts` and `trader payouts`.
+- After having the payout wallet addresses you need to paste them in the Dune dashboards, both `Affiliate Overview` and `Traders Overview`
+- The dune dashboard will check USDC transfers from those wallets and compute what was already paid out and what is pending payout.
+- From two other dashboards, `Next payouts for affiliates` and `Next payouts for traders`, you can export a CSV file with the pending payouts with you can drop into Safe's CSV Airdrop app. (it takes a few minutes for the dashboards to reflect new payouts)
+
+## 10) File structure
+
+- Besides this README, there is also a `/apps/cowswap-frontend/src/modules/affiliate/SPEC.md` file with use-cases (useful for AGENTS).
+- The Dune queries are version controlled in `/apps/cowswap-frontend/src/modules/affiliate/misc`.
diff --git a/apps/cowswap-frontend/src/modules/affiliate/SPEC.md b/apps/cowswap-frontend/src/modules/affiliate/SPEC.md
new file mode 100644
index 00000000000..2cea9eaa6d4
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/SPEC.md
@@ -0,0 +1,86 @@
+# Affiliate program spec
+
+## Feature flag
+- As a program manager, I want the affiliate program gated by the `isAffiliateProgramEnabled` feature flag because we must be able to turn it off instantly.
+- As a developer, I want the affiliate UI/routes and referrer appData disabled when the flag is off because hidden must mean inactive.
+
+## Partner creation + binding
+- As a partner, I want to see if my wallet already has a code because I should not be able to change it after creation.
+- As a developer, I want `GET /affiliate/:address` to return an existing code because the UI must show a bound code state.
+- As a developer, I want `GET /affiliate/:address` to return flat params (including revenue splits) because the partner UI must display split terms.
+- As a partner, I want to submit a code and sign a message because binding must prove wallet ownership.
+- As a partner, I want to be forced to switch network to ethereum mainnet because payouts will be paid there and that ensures I bind the correct wallet.
+- As a developer, I want to validate signature, chainId (must be 1), and code format (5-20 A-Z0-9-_) because codes must be safe and predictable.
+- As a developer, I want to validate the signed message matches the wallet because binding must be authentic.
+- As a developer, I want to uppercase and enforce unique codes because codes are case-insensitive and must not collide.
+- As a developer, I want wallet addresses normalized to lowercase and codes uppercased before sign/verify/store because validation must be consistent.
+- As a developer, I want signing + verification to use the same code normalization because valid signatures should not fail.
+- As a developer, I want code + wallet immutable after create because partners should not be able to rotate codes.
+- As a partner, I want creating my code to never set my trader referral code because consumption is a separate flow.
+
+## Program manager (manual)
+- As a program manager, I want to create partners in Strapi because some codes are issued manually.
+- As a developer, I want manual creation to enforce code format/uniqueness and unique wallet because DB integrity is the final guardrail.
+- As a program manager, I want to set program params per code at creation time because some special codes need custom rewards/eligibility.
+- As a program manager, I want global default params defined in CMS code because 99% of codes should inherit defaults.
+- As a program manager, I want an easy export of codes+params (CSV/JSON) from CMS because I need to analyze and share the program.
+- As a program manager, I want code params immutable after create because issued terms must not change.
+- As a program manager, I want to toggle enabled on/off without violating immutable fields because I must pause codes safely.
+- As a program manager, I accept defaults require CMS deploy because global rules will evolve.
+
+## Trader referral consumption
+- As a trader, I want to enter a ref code via URL or form because I should be able to attribute a referral.
+- As a trader, I want to enter my code on any supported chain (eligible-volume list) because referrals should work across eligible networks.
+- As a trader, I want to be notified that payouts will happen on ethereum mainnet because rewards are paid there.
+- As a trader, I want invalid or disabled codes rejected because I should not be punished for mistakes.
+- As a trader, I want invalid/disabled codes not persisted locally because I should not get stuck with a bad code.
+- As a trader, I want my prior referrer to stick because switching after trading is not allowed.
+- As a trader, I want to be told I am ineligible if I have traded before on any network without a referrer because I cannot add one later.
+- As a trader, I want my prior referrer to override any new URL/code because the first referrer should win.
+- As a developer, I want new traders validated via `GET /ref-codes/:code` because we must only accept enabled codes.
+- As a developer, I want `GET /ref-codes/:code` to return flat params with `traderRewardAmount` (split applied) and no revenue splits because the UI should not expose split math.
+- As a trader, I want to be told the code is only stored locally until I place a trade because the wallet is not bound yet.
+- As a developer, I want to persist the code locally and include it in first APP_DATA because attribution happens on first trade.
+- As a developer, I want APP_DATA referrer payload compatible with the current referrer schema (`metadata.referrer.code`, 5-20 A-Z0-9-_) because invalid appData breaks attribution.
+
+## Error handling / edge cases
+- As a developer, I want duplicate code to return 409 because it is a conflict.
+- As a developer, I want duplicate wallet to return 409 because one wallet maps to one code.
+- As a developer, I want invalid payload/address/signature/chain to return 400/401/403/422 because clients must know why they failed.
+- As a developer, I want unsupported chains rejected with a clear error because partner codes are mainnet-only.
+- As a developer, I want disabled codes blocked in trader flow because incentives should stop immediately.
+- As a partner/trader, I want clear UI error states for validation failures because I need to fix my input fast.
+
+## Program parameters (per-code + defaults)
+- As a program manager, I want program params stored in Strapi because rules must be editable without code deploys.
+- As a program manager, I want per-code params set at creation time because params are immutable after create.
+- As a developer, I want CMS to apply code defaults when params are omitted on create because BFF-created codes should match current defaults.
+- As a developer, I want a read endpoint for per-code params because the partner UI may need to display terms.
+- As a program manager, I want to set reward amount because payouts must be configurable.
+- As a program manager, I want to set volume-to-reward trigger because eligibility depends on trade volume.
+- As a program manager, I want to set a time cap because rewards should expire.
+- As a program manager, I want to set a volume cap because payouts need a hard limit.
+- As a program manager, I want to set revenue split percentages for partner/trader/CoW DAO because rewards must be shared.
+- As a developer, I want trader reward amount computed from `rewardAmount * revenueSplitTraderPct` and only expose `traderRewardAmount` to traders.
+- As a developer, I don't want code parameters to be editable after creation to keep the program simple and predictable.
+
+## Dune dashboard (partner + trader rewards)
+- As a trader, I want trader metrics (trader_address, bound_referrer_code, linked_since, rewards_end, eligible_volume, left_to_next_rewards, trigger_volume, total_earned, paid_out, next_payout) because I need to see my rewards.
+- As a partner, I want partner metrics (affiliate_address, referrer_code, total_volume, trigger_volume, total_earned, paid_out, next_payout, left_to_next_reward, active_traders, total_traders) because I need to see my rewards.
+- As a program manager, I want a Dune dashboard for partner + trader rewards stats because the UI needs trusted analytics.
+- As a program manager, I want partner metrics (referrer_code, volume, trigger_volume, total_earned, paid_out, next_payout, left_to_next_reward, active_traders, total_traders) because partners need progress tracking.
+- As a program manager, I want rewards computed in USDC only because the program pays in USDC.
+- As a data analyst, I want a CMS export table in Dune (`affiliate_program_data`) because code params + enabled flags must join to trades.
+- As a data analyst, I want a payout_sources dashboard param (array of ETH addresses) because paid_out/payable depends on payout wallets.
+- As a data analyst, I want to parse `app_data.metadata.referrer.code` and normalize uppercase because referrer codes are case-insensitive.
+- As a data analyst, I want dashboard filters (blockchain, start_time, payout_sources) because analysis must slice by chain/time/payout wallet.
+- As a developer, I want to enforce eligibility: first-ever trade must be a ref trade; bound code is from first ref trade; unsupported chains excluded; time cap and volume cap enforced because reward rules are strict.
+- As a developer, I want a `traders_debug` view with columns (blockchain, block_time, tx_hash, trader_address, usd_value, referrer_code, bound_referrer_code, linked_since, days_since_bound, cum_volume_for_code, first_trade_time, first_ref_trade_time, is_first_trade, is_first_ref_trade, is_eligible, eligibility_reason, is_bound_to_code) because I need to debug eligibility and binding.
+
+## Rewards hub UI (partner + my rewards)
+- As a partner, I want clear connect/unlock CTAs because I cannot create a code without a wallet.
+- As a partner, I want a pre-generated code with availability feedback and a confirm-lock step because binding is permanent.
+- As a partner, I want the linked state to show my code, referral link, linked since, rewards end, and share actions because I need to promote it.
+- As a partner, I want referral traffic + payable rewards cards with a payout CTA when payable > 0 because I need to track and claim rewards.
+- As a trader, I want empty vs linked states, next reward progress, and a rewards activity table because I need to see my status.
+- As a trader, I want payable rewards shown without a payout CTA because payouts are automatic.
diff --git a/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts
new file mode 100644
index 00000000000..92772d0a4f6
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/api/bffAffiliateApi.ts
@@ -0,0 +1,205 @@
+import { BFF_BASE_URL } from '@cowprotocol/common-const'
+
+import { AFFILIATE_API_TIMEOUT_MS } from '../config/constants'
+import {
+ PartnerCodeResponse,
+ PartnerCreateRequest,
+ PartnerStatsResponse,
+ TraderReferralCodeResponse,
+ TraderReferralCodeVerificationResponse,
+ TraderReferralCodeVerificationRequest,
+ TraderStatsResponse,
+ TraderWalletReferralCodeStatusRequest,
+ TraderWalletReferralCodeStatusResponse,
+} from '../model/partner-trader-types'
+
+const TIMEOUT_ERROR_MESSAGE = 'Unable to reach referral service'
+const JSON_HEADERS = {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+}
+
+type FetchJsonResponse = {
+ response: Response
+ data?: T
+ text: string
+}
+
+function buildReferralError(status: number, text: string, data?: { message?: string }): Error {
+ const message = data?.message || text || `Referral service error (${status})`
+ const error = new Error(message)
+ ;(error as Error & { status?: number }).status = status
+ return error
+}
+
+class BffAffiliateApi {
+ private readonly baseUrl: string
+ private readonly timeoutMs: number
+
+ constructor(baseUrl: string, timeoutMs: number = AFFILIATE_API_TIMEOUT_MS) {
+ this.baseUrl = baseUrl.replace(/\/$/, '')
+ this.timeoutMs = timeoutMs
+ }
+ async verifyReferralCode(
+ request: TraderReferralCodeVerificationRequest,
+ ): Promise {
+ const { code } = request
+ const url = this.buildUrl(`ref-codes/${encodeURIComponent(code)}`)
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'GET',
+ headers: JSON_HEADERS,
+ })
+
+ return {
+ status: response.status,
+ ok: response.ok,
+ data,
+ text,
+ }
+ }
+ async getWalletReferralStatus(
+ request: TraderWalletReferralCodeStatusRequest,
+ ): Promise {
+ const { account } = request
+ const url = this.buildUrl(`affiliate/${account}`)
+
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'GET',
+ headers: JSON_HEADERS,
+ })
+
+ if (response.ok) {
+ return {
+ wallet: {
+ linkedCode: data?.code,
+ },
+ }
+ }
+
+ if (response.status === 404) {
+ return { wallet: {} }
+ }
+
+ throw buildReferralError(response.status, text, data as { message?: string } | undefined)
+ }
+ async getAffiliateCode(account: string): Promise {
+ const url = this.buildUrl(`affiliate/${account}`)
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'GET',
+ headers: JSON_HEADERS,
+ })
+
+ if (response.ok) {
+ if (!data) {
+ throw buildReferralError(response.status, text, { message: 'Affiliate response missing' })
+ }
+ return data
+ }
+
+ if (response.status === 404) {
+ return null
+ }
+
+ throw buildReferralError(response.status, text, data as { message?: string } | undefined)
+ }
+ async getTraderStats(account: string): Promise {
+ const url = this.buildUrl(`affiliate/trader-stats/${account}`)
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'GET',
+ headers: JSON_HEADERS,
+ })
+
+ if (response.ok) {
+ if (!data) {
+ throw buildReferralError(response.status, text, { message: 'Trader stats response missing' })
+ }
+ return data
+ }
+
+ if (response.status === 404) {
+ return null
+ }
+
+ throw buildReferralError(response.status, text, data as { message?: string } | undefined)
+ }
+ async getAffiliateStats(account: string): Promise {
+ const url = this.buildUrl(`affiliate/affiliate-stats/${account}`)
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'GET',
+ headers: JSON_HEADERS,
+ })
+
+ if (response.ok) {
+ if (!data) {
+ throw buildReferralError(response.status, text, { message: 'Affiliate stats response missing' })
+ }
+ return data
+ }
+
+ if (response.status === 404) {
+ return null
+ }
+
+ throw buildReferralError(response.status, text, data as { message?: string } | undefined)
+ }
+ async createAffiliateCode(request: PartnerCreateRequest): Promise {
+ const url = this.buildUrl(`affiliate/${request.walletAddress}`)
+ const body = JSON.stringify(request)
+
+ const { response, data, text } = await this.fetchJsonResponse(url, {
+ method: 'POST',
+ headers: JSON_HEADERS,
+ body,
+ })
+
+ if (response.ok) {
+ if (!data) {
+ throw buildReferralError(response.status, text, { message: 'Affiliate response missing' })
+ }
+ return data
+ }
+
+ throw buildReferralError(response.status, text, data as { message?: string } | undefined)
+ }
+ private buildUrl(path: string): string {
+ const normalizedPath = path.replace(/^\//, '')
+ return `${this.baseUrl}/${normalizedPath}`
+ }
+ private async fetchJsonResponse(input: string, init: RequestInit): Promise> {
+ const response = await this.fetchWithTimeout(input, init)
+
+ const text = await response.text().catch(() => '')
+ let data: T | undefined
+
+ if (text) {
+ try {
+ data = JSON.parse(text) as T
+ } catch {
+ data = undefined
+ }
+ }
+
+ return { response, data, text }
+ }
+ private async fetchWithTimeout(input: RequestInfo, init: RequestInit): Promise {
+ if (!this.timeoutMs) {
+ return fetch(input, init)
+ }
+
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs)
+
+ try {
+ return await fetch(input, { ...init, signal: controller.signal })
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw new Error(TIMEOUT_ERROR_MESSAGE)
+ }
+ throw error
+ } finally {
+ clearTimeout(timeoutId)
+ }
+ }
+}
+
+export const bffAffiliateApi = new BffAffiliateApi(BFF_BASE_URL, AFFILIATE_API_TIMEOUT_MS)
diff --git a/apps/cowswap-frontend/src/modules/affiliate/config/constants.ts b/apps/cowswap-frontend/src/modules/affiliate/config/constants.ts
new file mode 100644
index 00000000000..f0eba898d23
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/config/constants.ts
@@ -0,0 +1,31 @@
+import { CHAIN_INFO } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+// https://dune.com/queries/6434876
+export const AFFILIATE_SUPPORTED_CHAIN_IDS: readonly SupportedChainId[] = [
+ SupportedChainId.MAINNET,
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.AVALANCHE,
+ SupportedChainId.POLYGON,
+ SupportedChainId.LENS,
+ SupportedChainId.BNB,
+ SupportedChainId.LINEA,
+ SupportedChainId.SEPOLIA,
+ SupportedChainId.PLASMA,
+] as const
+
+export const AFFILIATE_TRADER_STORAGE_KEY = 'cowswap:affiliate-trader:v2'
+
+export const AFFILIATE_SUPPORTED_NETWORK_NAMES = AFFILIATE_SUPPORTED_CHAIN_IDS.map(
+ (chainId) => CHAIN_INFO[chainId].label,
+)
+
+// Timeout applied to referral service requests so UI fails fast on network issues
+export const AFFILIATE_API_TIMEOUT_MS = 10_000
+
+// TODO: replace placeholder URL once the referral docs are provisioned
+export const AFFILIATE_HOW_IT_WORKS_URL = 'https://docs.cow.fi'
+
+export const AFFILIATE_REWARDS_CURRENCY = 'USDC'
diff --git a/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.test.ts b/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.test.ts
new file mode 100644
index 00000000000..0e0dfa22c49
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.test.ts
@@ -0,0 +1,34 @@
+import { sanitizeReferralCode, isReferralCodeLengthValid } from './affiliate-program-utils'
+
+describe('sanitizeReferralCode', () => {
+ it('uppercases and trims whitespace', () => {
+ expect(sanitizeReferralCode(' abc ')).toBe('ABC')
+ })
+
+ it('removes unsupported characters but keeps dashes and underscores', () => {
+ expect(sanitizeReferralCode('a!b@c#1$2%3-_')).toBe('ABC123-_')
+ })
+
+ it('limits length to 20 characters', () => {
+ expect(sanitizeReferralCode('ABCDEFGHIJKLMNOPQRSTUV')).toBe('ABCDEFGHIJKLMNOPQRST')
+ })
+
+ it('returns empty string for falsy input', () => {
+ expect(sanitizeReferralCode('')).toBe('')
+ })
+})
+
+describe('isReferralCodeLengthValid', () => {
+ it('accepts lengths between 5 and 20 inclusive', () => {
+ expect(isReferralCodeLengthValid('ABCDE')).toBe(true)
+ expect(isReferralCodeLengthValid('ABCDEFGHIJKLMNOPQRST')).toBe(true)
+ })
+
+ it('rejects codes shorter than 5 characters', () => {
+ expect(isReferralCodeLengthValid('ABCD')).toBe(false)
+ })
+
+ it('rejects codes longer than 20 characters', () => {
+ expect(isReferralCodeLengthValid('ABCDEFGHIJKLMNOPQRSTU')).toBe(false)
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.ts b/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.ts
new file mode 100644
index 00000000000..8991a5fea11
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/lib/affiliate-program-utils.ts
@@ -0,0 +1,143 @@
+import { formatDateWithTimezone, formatLocaleNumber } from '@cowprotocol/common-utils'
+import type { TypedDataField } from '@ethersproject/abstract-signer'
+
+import { i18n } from '@lingui/core'
+
+import { AFFILIATE_REWARDS_CURRENCY, AFFILIATE_SUPPORTED_CHAIN_IDS } from '../config/constants'
+import { TraderReferralCodeVerificationStatus } from '../model/partner-trader-types'
+
+export const AFFILIATE_TYPED_DATA_DOMAIN = {
+ name: 'CoW Swap Affiliate',
+ version: '1',
+} as const
+
+export const AFFILIATE_TYPED_DATA_TYPES: Record = {
+ AffiliateCode: [
+ { name: 'walletAddress', type: 'address' },
+ { name: 'code', type: 'string' },
+ { name: 'chainId', type: 'uint256' },
+ ],
+}
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export function buildPartnerTypedData(params: { walletAddress: string; code: string; chainId: number }) {
+ return {
+ domain: {
+ ...AFFILIATE_TYPED_DATA_DOMAIN,
+ },
+ types: AFFILIATE_TYPED_DATA_TYPES,
+ message: {
+ walletAddress: params.walletAddress,
+ code: params.code,
+ chainId: params.chainId,
+ },
+ }
+}
+
+const CODE_ALLOWED_REGEX = /[A-Z0-9_-]/
+
+export function sanitizeReferralCode(raw: string): string {
+ if (!raw) {
+ return ''
+ }
+
+ const next = raw
+ .trim()
+ .toUpperCase()
+ .split('')
+ .filter((char) => CODE_ALLOWED_REGEX.test(char))
+ .join('')
+
+ return next.slice(0, 20)
+}
+
+export function isReferralCodeLengthValid(code: string): boolean {
+ return code.length >= 5 && code.length <= 20
+}
+
+export type PartnerProgramParams = {
+ traderRewardAmount: number
+ triggerVolumeUsd: number
+ timeCapDays: number
+ volumeCapUsd: number
+}
+
+export function getPartnerProgramCopyValues(params: PartnerProgramParams): {
+ rewardAmount: string
+ rewardCurrency: string
+ triggerVolume: string
+ timeCapDays: number
+} {
+ return {
+ rewardAmount: formatInteger(params.traderRewardAmount),
+ rewardCurrency: AFFILIATE_REWARDS_CURRENCY,
+ triggerVolume: formatInteger(params.triggerVolumeUsd),
+ timeCapDays: params.timeCapDays,
+ }
+}
+
+function formatInteger(value: number): string {
+ return value.toLocaleString(i18n.locale, { maximumFractionDigits: 0 })
+}
+
+export function formatCompactNumber(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '-'
+ }
+
+ return formatLocaleNumber({
+ number: value,
+ locale: i18n.locale,
+ options: { notation: 'compact', maximumFractionDigits: 2 },
+ })
+}
+
+export function formatUsdCompact(value: number | null | undefined): string {
+ const formatted = formatCompactNumber(value)
+ return formatted === '-' ? '-' : `$${formatted}`
+}
+
+export function formatUsdcCompact(value: number | null | undefined): string {
+ const formatted = formatCompactNumber(value)
+ return formatted === '-' ? '-' : `${formatted} USDC`
+}
+
+export function getIncomingIneligibleCode(
+ incomingCode: string | undefined,
+ verification: TraderReferralCodeVerificationStatus,
+): string | undefined {
+ if (incomingCode) {
+ return incomingCode
+ }
+
+ if (verification.kind === 'ineligible') {
+ return verification.code
+ }
+
+ return undefined
+}
+
+export function formatUpdatedAt(value: Date | null): string {
+ if (!value) {
+ return '-'
+ }
+
+ return formatDateWithTimezone(value) ?? '-'
+}
+
+export function generateSuggestedCode(): string {
+ const suffix = randomDigits(6)
+ return `COW-${suffix}`
+}
+
+function randomDigits(length: number): string {
+ return `${Math.floor(Math.random() * Math.pow(10, length))}`.padStart(length, '0')
+}
+
+export function isSupportedReferralNetwork(chainId: number | undefined | null): boolean {
+ if (!chainId) {
+ return false
+ }
+
+ return AFFILIATE_SUPPORTED_CHAIN_IDS.includes(chainId as number)
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/misc/affiliates.sql b/apps/cowswap-frontend/src/modules/affiliate/misc/affiliates.sql
new file mode 100644
index 00000000000..f8f025c384c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/misc/affiliates.sql
@@ -0,0 +1,168 @@
+with
+params as (
+ select
+ split('{{blockchain}}', ',') as blockchains,
+ case when '{{is_staging_env}}' = 'true' then true else false end as is_staging_env,
+ case
+ when '{{affiliate_payout_sources}}' in ('', 'default value') then cast(array[] as array(varchar))
+ else transform(split('{{affiliate_payout_sources}}', ','), x -> lower(trim(x)))
+ end as affiliate_payout_sources
+),
+affiliate_program_data as (
+ select
+ cast(affiliate_address as varchar) as affiliate_address,
+ cast(code as varchar) as code,
+ trigger_volume,
+ time_cap_days,
+ volume_cap,
+ reward_amount,
+ revenue_split_affiliate_pct,
+ revenue_split_trader_pct
+ from dune.cowprotocol.dataset_affiliate_program_data
+ where not (select is_staging_env from params)
+ union all
+ select
+ cast(affiliate_address as varchar) as affiliate_address,
+ cast(code as varchar) as code,
+ trigger_volume,
+ time_cap_days,
+ volume_cap,
+ reward_amount,
+ revenue_split_affiliate_pct,
+ revenue_split_trader_pct
+ from dune.cowprotocol.dataset_affiliate_program_data_staging
+ where (select is_staging_env from params)
+),
+trades_with_referrer as (
+ select
+ dune.cowprotocol.result_fac_trades.blockchain,
+ dune.cowprotocol.result_fac_trades.block_time,
+ dune.cowprotocol.result_fac_trades.tx_hash,
+ dune.cowprotocol.result_fac_trades.trader as trader,
+ dune.cowprotocol.result_fac_trades.usd_value as usd_value,
+ dune.cowprotocol.result_fac_trades.referrer_code as referrer_code
+ from dune.cowprotocol.result_fac_trades
+ cross join params
+ where
+ if(array_position(params.blockchains, '-=All=-') > 0, true, array_position(params.blockchains, dune.cowprotocol.result_fac_trades.blockchain) > 0)
+),
+first_trade as (
+ select trader, min(block_time) as first_trade_time
+ from trades_with_referrer
+ group by 1
+),
+first_ref_trade as (
+ select trader, min(block_time) as first_ref_trade_time
+ from trades_with_referrer
+ where referrer_code is not null
+ group by 1
+),
+bound_ref as (
+ select
+ trades_with_referrer.trader,
+ trades_with_referrer.referrer_code as bound_code,
+ trades_with_referrer.block_time as bound_time
+ from trades_with_referrer
+ join first_ref_trade
+ on first_ref_trade.trader = trades_with_referrer.trader
+ and first_ref_trade.first_ref_trade_time = trades_with_referrer.block_time
+),
+eligible_trades as (
+ select
+ trades_with_referrer.*,
+ affiliate_program_data.affiliate_address,
+ affiliate_program_data.trigger_volume,
+ affiliate_program_data.time_cap_days,
+ affiliate_program_data.volume_cap,
+ bound_ref.bound_time,
+ sum(trades_with_referrer.usd_value)
+ over (partition by trades_with_referrer.trader, trades_with_referrer.referrer_code order by trades_with_referrer.block_time) as cum_volume
+ from trades_with_referrer
+ join bound_ref
+ on bound_ref.trader = trades_with_referrer.trader
+ and bound_ref.bound_code = trades_with_referrer.referrer_code
+ join first_trade on first_trade.trader = trades_with_referrer.trader
+ join first_ref_trade on first_ref_trade.trader = trades_with_referrer.trader
+ join affiliate_program_data
+ on affiliate_program_data.code = trades_with_referrer.referrer_code
+ where
+ first_trade.first_trade_time = first_ref_trade.first_ref_trade_time
+ and trades_with_referrer.block_time <= bound_ref.bound_time + affiliate_program_data.time_cap_days * interval '1' day
+),
+capped_trades as (
+ select
+ *,
+ case
+ when volume_cap = 0 then usd_value
+ when cum_volume <= volume_cap then usd_value
+ when cum_volume - usd_value >= volume_cap then 0
+ else volume_cap - (cum_volume - usd_value)
+ end as eligible_volume
+ from eligible_trades
+),
+affiliate_rewards as (
+ select
+ referrer_code as code,
+ sum(eligible_volume) as referral_volume,
+ count(*) as swaps,
+ count(distinct trader) as traders,
+ sum(case when (bound_time + time_cap_days * interval '1' day) > now() and (volume_cap = 0 or cum_volume < volume_cap) then 1 else 0 end) as active_referrals
+ from capped_trades
+ group by 1
+),
+payouts as (
+ select
+ "to" as recipient,
+ cast(round(sum(value) / 1e6, 6) as decimal(18, 6)) as paid_out
+ from erc20_ethereum.evt_transfer
+ cross join params
+ cross join unnest(params.affiliate_payout_sources) as ps(payout_source)
+ where lower(to_hex("from")) = replace(ps.payout_source, '0x', '')
+ and contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
+ group by 1
+)
+select
+ affiliate_program_data.affiliate_address,
+ affiliate_program_data.code as referrer_code,
+ coalesce(affiliate_rewards.referral_volume, 0) as total_volume,
+ affiliate_program_data.trigger_volume as trigger_volume,
+ cast(
+ round(
+ floor(coalesce(affiliate_rewards.referral_volume, 0) / affiliate_program_data.trigger_volume)
+ * (
+ cast(affiliate_program_data.reward_amount as decimal(18, 6))
+ * cast(affiliate_program_data.revenue_split_affiliate_pct as decimal(18, 0))
+ / cast(100 as decimal(18, 6))
+ ),
+ 6
+ )
+ as decimal(18, 6)
+ ) as total_earned,
+ cast(coalesce(payouts.paid_out, 0) as decimal(18, 6)) as paid_out,
+ cast(
+ round(
+ (
+ floor(coalesce(affiliate_rewards.referral_volume, 0) / affiliate_program_data.trigger_volume)
+ * (
+ cast(affiliate_program_data.reward_amount as decimal(18, 6))
+ * cast(affiliate_program_data.revenue_split_affiliate_pct as decimal(18, 0))
+ / cast(100 as decimal(18, 6))
+ )
+ ) - coalesce(payouts.paid_out, 0),
+ 6
+ )
+ as decimal(18, 6)
+ ) as next_payout,
+ case
+ when (coalesce(affiliate_rewards.referral_volume, 0) % affiliate_program_data.trigger_volume) = 0
+ then affiliate_program_data.trigger_volume
+ else affiliate_program_data.trigger_volume -
+ (coalesce(affiliate_rewards.referral_volume, 0) % affiliate_program_data.trigger_volume)
+ end as left_to_next_reward,
+ coalesce(affiliate_rewards.active_referrals, 0) as active_traders,
+ coalesce(affiliate_rewards.traders, 0) as total_traders
+from affiliate_program_data
+left join affiliate_rewards on affiliate_rewards.code = affiliate_program_data.code
+left join payouts
+ on lower(cast(payouts.recipient as varchar)) = lower(cast(affiliate_program_data.affiliate_address as varchar))
+order by total_volume desc;
diff --git a/apps/cowswap-frontend/src/modules/affiliate/misc/payouts.sql b/apps/cowswap-frontend/src/modules/affiliate/misc/payouts.sql
new file mode 100644
index 00000000000..c4eb07cbfda
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/misc/payouts.sql
@@ -0,0 +1,43 @@
+with
+params as (
+ select
+ case when '{{is_staging_env}}' = 'true' then true else false end as is_staging_env,
+ lower('{{payout_type}}') as payout_type
+),
+affiliate_base as (
+ select * from "query_6560325()" -- prod affiliates
+ where not (select is_staging_env from params)
+ union all
+ select * from "query_6648689()" -- staging affiliates
+ where (select is_staging_env from params)
+),
+trader_base as (
+ select * from "query_6560853()" -- prod traders
+ where not (select is_staging_env from params)
+ union all
+ select * from "query_6648679()" -- staging traders
+ where (select is_staging_env from params)
+),
+base as (
+ select
+ 'affiliate' as payout_type,
+ lower(cast(affiliate_address as varchar)) as receiver,
+ cast(next_payout as decimal(18, 6)) as next_payout
+ from affiliate_base
+ union all
+ select
+ 'trader' as payout_type,
+ lower(cast(trader_address as varchar)) as receiver,
+ cast(next_payout as decimal(18, 6)) as next_payout
+ from trader_base
+)
+select
+ 'erc20' as token_type,
+ 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 as token_address,
+ receiver,
+ cast(sum(next_payout) as decimal(18, 6)) as amount
+from base
+where next_payout > 0
+ and payout_type = (select payout_type from params)
+group by 1,2,3
+order by amount desc;
diff --git a/apps/cowswap-frontend/src/modules/affiliate/misc/payouts_debug.sql b/apps/cowswap-frontend/src/modules/affiliate/misc/payouts_debug.sql
new file mode 100644
index 00000000000..dc8197899de
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/misc/payouts_debug.sql
@@ -0,0 +1,16 @@
+with params as (
+ select
+ case
+ when '{{payout_sources}}' in ('', 'default value') then cast(array[] as array(varchar))
+ else transform(split('{{payout_sources}}', ','), x -> lower(trim(x)))
+ end as payout_sources
+)
+select
+ "to" as recipient,
+ cast(round(sum(value) / 1e6, 6) as decimal(18, 6)) as paid_out
+from erc20_ethereum.evt_transfer
+cross join params
+cross join unnest(params.payout_sources) as ps(payout_source)
+where lower(to_hex("from")) = replace(ps.payout_source, '0x', '')
+ and contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
+group by 1;
diff --git a/apps/cowswap-frontend/src/modules/affiliate/misc/traders.sql b/apps/cowswap-frontend/src/modules/affiliate/misc/traders.sql
new file mode 100644
index 00000000000..d428deaa6e9
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/misc/traders.sql
@@ -0,0 +1,162 @@
+with
+params as (
+ select
+ split('{{blockchain}}', ',') as blockchains,
+ case when '{{is_staging_env}}' = 'true' then true else false end as is_staging_env,
+ case
+ when '{{trader_payout_sources}}' in ('', 'default value') then cast(array[] as array(varchar))
+ else transform(split('{{trader_payout_sources}}', ','), x -> lower(trim(x)))
+ end as trader_payout_sources
+),
+affiliate_program_data as (
+ select
+ cast(affiliate_address as varchar) as affiliate_address,
+ cast(code as varchar) as code,
+ trigger_volume,
+ time_cap_days,
+ volume_cap,
+ reward_amount,
+ revenue_split_affiliate_pct,
+ revenue_split_trader_pct
+ from dune.cowprotocol.dataset_affiliate_program_data
+ where not (select is_staging_env from params)
+ union all
+ select
+ cast(affiliate_address as varchar) as affiliate_address,
+ cast(code as varchar) as code,
+ trigger_volume,
+ time_cap_days,
+ volume_cap,
+ reward_amount,
+ revenue_split_affiliate_pct,
+ revenue_split_trader_pct
+ from dune.cowprotocol.dataset_affiliate_program_data_staging
+ where (select is_staging_env from params)
+),
+trades_with_referrer as (
+ select
+ dune.cowprotocol.result_fac_trades.blockchain,
+ dune.cowprotocol.result_fac_trades.block_time,
+ dune.cowprotocol.result_fac_trades.tx_hash,
+ dune.cowprotocol.result_fac_trades.trader as trader,
+ dune.cowprotocol.result_fac_trades.usd_value as usd_value,
+ dune.cowprotocol.result_fac_trades.referrer_code as referrer_code
+ from dune.cowprotocol.result_fac_trades
+ cross join params
+ where
+ if(array_position(params.blockchains, '-=All=-') > 0, true, array_position(params.blockchains, dune.cowprotocol.result_fac_trades.blockchain) > 0)
+),
+first_trade as (
+ select trader, min(block_time) as first_trade_time
+ from trades_with_referrer
+ group by 1
+),
+first_ref_trade as (
+ select trader, min(block_time) as first_ref_trade_time
+ from trades_with_referrer
+ where referrer_code is not null
+ group by 1
+),
+bound_ref as (
+ select
+ trades_with_referrer.trader,
+ trades_with_referrer.referrer_code as bound_code,
+ trades_with_referrer.block_time as bound_time
+ from trades_with_referrer
+ join first_ref_trade
+ on first_ref_trade.trader = trades_with_referrer.trader
+ and first_ref_trade.first_ref_trade_time = trades_with_referrer.block_time
+),
+eligible_trades as (
+ select
+ trades_with_referrer.*,
+ affiliate_program_data.trigger_volume,
+ affiliate_program_data.time_cap_days,
+ affiliate_program_data.volume_cap,
+ bound_ref.bound_time,
+ sum(trades_with_referrer.usd_value)
+ over (partition by trades_with_referrer.trader, trades_with_referrer.referrer_code order by trades_with_referrer.block_time) as cum_volume
+ from trades_with_referrer
+ join bound_ref
+ on bound_ref.trader = trades_with_referrer.trader
+ and bound_ref.bound_code = trades_with_referrer.referrer_code
+ join first_trade on first_trade.trader = trades_with_referrer.trader
+ join first_ref_trade on first_ref_trade.trader = trades_with_referrer.trader
+ join affiliate_program_data
+ on affiliate_program_data.code = trades_with_referrer.referrer_code
+ where
+ first_trade.first_trade_time = first_ref_trade.first_ref_trade_time
+ and trades_with_referrer.block_time <= bound_ref.bound_time + affiliate_program_data.time_cap_days * interval '1' day
+),
+capped_trades as (
+ select
+ *,
+ case
+ when volume_cap = 0 then usd_value
+ when cum_volume <= volume_cap then usd_value
+ when cum_volume - usd_value >= volume_cap then 0
+ else volume_cap - (cum_volume - usd_value)
+ end as eligible_volume
+ from eligible_trades
+),
+payouts as (
+ select
+ "to" as recipient,
+ cast(round(sum(value) / 1e6, 6) as decimal(18, 6)) as paid_out
+ from erc20_ethereum.evt_transfer
+ cross join params
+ cross join unnest(params.trader_payout_sources) as ps(payout_source)
+ where lower(to_hex("from")) = replace(ps.payout_source, '0x', '')
+ and contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
+ group by 1
+)
+select
+ capped_trades.trader as trader_address,
+ capped_trades.referrer_code as bound_referrer_code,
+ capped_trades.bound_time as linked_since,
+ capped_trades.bound_time + max(affiliate_program_data.time_cap_days) * interval '1' day as rewards_end,
+ sum(eligible_volume) as eligible_volume,
+ case
+ when (sum(eligible_volume) % max(affiliate_program_data.trigger_volume)) = 0
+ then max(affiliate_program_data.trigger_volume)
+ else max(affiliate_program_data.trigger_volume) -
+ (sum(eligible_volume) % max(affiliate_program_data.trigger_volume))
+ end as left_to_next_rewards,
+ max(affiliate_program_data.trigger_volume) as trigger_volume,
+ cast(
+ round(
+ cast(
+ floor(sum(eligible_volume) / max(affiliate_program_data.trigger_volume))
+ as decimal(18, 0)
+ )
+ * (
+ cast(max(affiliate_program_data.reward_amount) as decimal(18, 6))
+ * cast(max(affiliate_program_data.revenue_split_trader_pct) as decimal(18, 0))
+ / cast(100 as decimal(18, 6))
+ ),
+ 6
+ )
+ as decimal(18, 6)
+ ) as total_earned,
+ cast(coalesce(max(payouts.paid_out), cast(0 as decimal(18, 6))) as decimal(18, 6)) as paid_out,
+ cast(
+ round(
+ (
+ cast(
+ floor(sum(eligible_volume) / max(affiliate_program_data.trigger_volume))
+ as decimal(18, 0)
+ )
+ * (
+ cast(max(affiliate_program_data.reward_amount) as decimal(18, 6))
+ * cast(max(affiliate_program_data.revenue_split_trader_pct) as decimal(18, 0))
+ / cast(100 as decimal(18, 6))
+ )
+ ) - cast(coalesce(max(payouts.paid_out), cast(0 as decimal(18, 6))) as decimal(18, 6)),
+ 6
+ )
+ as decimal(18, 6)
+ ) as next_payout
+from capped_trades
+join affiliate_program_data on affiliate_program_data.code = capped_trades.referrer_code
+left join payouts on payouts.recipient = capped_trades.trader
+group by 1,2,3;
diff --git a/apps/cowswap-frontend/src/modules/affiliate/misc/traders_debug.sql b/apps/cowswap-frontend/src/modules/affiliate/misc/traders_debug.sql
new file mode 100644
index 00000000000..a466ca02f1f
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/misc/traders_debug.sql
@@ -0,0 +1,67 @@
+with
+params as (
+ select
+ split('{{blockchain}}', ',') as blockchains
+),
+trades as (
+ select
+ dune.cowprotocol.result_fac_trades.blockchain,
+ dune.cowprotocol.result_fac_trades.block_time,
+ dune.cowprotocol.result_fac_trades.tx_hash,
+ lower(cast(dune.cowprotocol.result_fac_trades.trader as varchar)) as trader,
+ dune.cowprotocol.result_fac_trades.usd_value as usd_value,
+ dune.cowprotocol.result_fac_trades.referrer_code as referrer_code
+ from dune.cowprotocol.result_fac_trades
+ cross join params
+ where
+ if(array_position(params.blockchains, '-=All=-') > 0, true, array_position(params.blockchains, dune.cowprotocol.result_fac_trades.blockchain) > 0)
+),
+first_trade as (
+ select trader, min(block_time) as first_trade_time
+ from trades
+ group by 1
+),
+first_ref_trade as (
+ select trader, min(block_time) as first_ref_trade_time
+ from trades
+ where referrer_code is not null
+ group by 1
+),
+bound_ref as (
+ select
+ trades.trader,
+ trades.referrer_code as bound_code,
+ trades.block_time as bound_time
+ from trades
+ join first_ref_trade
+ on first_ref_trade.trader = trades.trader
+ and first_ref_trade.first_ref_trade_time = trades.block_time
+)
+select
+ trades.blockchain,
+ trades.block_time,
+ trades.tx_hash,
+ trades.trader as trader_address,
+ trades.usd_value,
+ trades.referrer_code,
+ bound_ref.bound_code as bound_referrer_code,
+ date_diff('day', first_ref_trade.first_ref_trade_time, trades.block_time) as days_since_bound,
+ sum(trades.usd_value)
+ over (partition by trades.trader, trades.referrer_code order by trades.block_time) as cum_volume_for_code,
+ first_trade.first_trade_time,
+ first_ref_trade.first_ref_trade_time,
+ trades.block_time = first_trade.first_trade_time as is_first_trade,
+ trades.block_time = first_ref_trade.first_ref_trade_time as is_first_ref_trade,
+ first_trade.first_trade_time = first_ref_trade.first_ref_trade_time as is_eligible,
+ case
+ when first_ref_trade.first_ref_trade_time is null then 'no_ref_trade'
+ when first_trade.first_trade_time <> first_ref_trade.first_ref_trade_time then 'ref_after_first_trade'
+ else 'eligible'
+ end as eligibility_reason,
+ coalesce(bound_ref.bound_code = trades.referrer_code, false) as is_bound_to_code
+from trades
+left join first_trade on first_trade.trader = trades.trader
+left join first_ref_trade on first_ref_trade.trader = trades.trader
+left join bound_ref on bound_ref.trader = trades.trader
+where trades.referrer_code is not null
+order by trades.block_time desc;
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeController.tsx b/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeController.tsx
new file mode 100644
index 00000000000..17ab92fa74d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeController.tsx
@@ -0,0 +1,59 @@
+import { ReactNode, useMemo } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import { useWalletInfo } from '@cowprotocol/wallet'
+import { useWalletChainId } from '@cowprotocol/wallet-provider'
+
+import { useToggleWalletModal } from 'legacy/state/application/hooks'
+
+import { isSupportedReferralNetwork } from 'modules/affiliate/lib/affiliate-program-utils'
+
+import {
+ useTraderReferralCodeAutoVerification,
+ useTraderReferralCodeVerification,
+ usePendingReferralCodeVerificationHandler,
+} from './verificationEffects'
+
+import { useTraderReferralCode } from '../hooks/useTraderReferralCode'
+import { useTraderReferralCodeWalletSync } from '../hooks/useTraderReferralCodeWalletSync'
+
+export function TraderReferralCodeController(): ReactNode {
+ const traderReferralCode = useTraderReferralCode()
+ const { account } = useWalletInfo()
+ const chainId = useWalletChainId()
+ const analytics = useCowAnalytics()
+ const toggleWalletModal = useToggleWalletModal()
+
+ const supportedNetwork = useMemo(
+ () => (chainId !== undefined ? isSupportedReferralNetwork(chainId) : false),
+ [chainId],
+ )
+
+ useTraderReferralCodeWalletSync({
+ account,
+ chainId,
+ supportedNetwork,
+ actions: traderReferralCode.actions,
+ savedCode: traderReferralCode.savedCode,
+ })
+ const { runVerification } = useTraderReferralCodeVerification({
+ account,
+ chainId,
+ supportedNetwork,
+ traderReferralCode,
+ analytics,
+ toggleWalletModal,
+ })
+
+ useTraderReferralCodeAutoVerification({
+ account,
+ chainId,
+ supportedNetwork,
+ traderReferralCode,
+ runVerification,
+ })
+
+ usePendingReferralCodeVerificationHandler({ traderReferralCode, runVerification })
+
+ return null
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeDeepLinkHandler.tsx b/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeDeepLinkHandler.tsx
new file mode 100644
index 00000000000..a5812b55c4d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/containers/TraderReferralCodeDeepLinkHandler.tsx
@@ -0,0 +1,160 @@
+import { ReactNode, useEffect, useRef } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import type { CowAnalytics } from '@cowprotocol/analytics'
+import { useFeatureFlags } from '@cowprotocol/common-hooks'
+
+import { useLocation } from 'react-router'
+
+import { useNavigate } from 'common/hooks/useNavigate'
+
+import { isReferralCodeLengthValid, sanitizeReferralCode } from '../../lib/affiliate-program-utils'
+import { useTraderReferralCode } from '../hooks/useTraderReferralCode'
+import { useTraderReferralCodeActions } from '../hooks/useTraderReferralCodeActions'
+import { TraderReferralCodeContextValue } from '../partner-trader-types'
+
+export function TraderReferralCodeDeepLinkHandler(): ReactNode {
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
+ const actions = useTraderReferralCodeActions()
+ const traderReferralCode = useTraderReferralCode()
+ const location = useLocation()
+ const navigate = useNavigate()
+ const lastProcessedRef = useRef(null)
+ const analytics = useCowAnalytics()
+ const savedCode = traderReferralCode.savedCode
+ const verificationKind = traderReferralCode.verification.kind
+ const walletStatus = traderReferralCode.wallet.status
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search)
+ const codeParam = params.get('ref')
+ const stripReferralCodeFromUrl = (): void => {
+ params.delete('ref')
+ const nextSearch = params.toString()
+ navigate(
+ {
+ pathname: location.pathname,
+ search: nextSearch ? `?${nextSearch}` : '',
+ hash: location.hash,
+ },
+ { replace: true },
+ )
+ }
+
+ if (!codeParam) {
+ lastProcessedRef.current = null
+ return
+ }
+
+ if (!isAffiliateProgramEnabled) {
+ stripReferralCodeFromUrl()
+ lastProcessedRef.current = null
+ return
+ }
+
+ const sanitized = sanitizeReferralCode(codeParam)
+
+ if (!sanitized || !isReferralCodeLengthValid(sanitized)) {
+ stripReferralCodeFromUrl()
+ return
+ }
+
+ if (lastProcessedRef.current === sanitized) {
+ return
+ }
+
+ lastProcessedRef.current = sanitized
+ const snapshot: TraderReferralCodeSnapshot = {
+ savedCode,
+ verificationKind,
+ walletStatus,
+ }
+ processTraderReferralCode({ sanitized, snapshot, actions, analytics })
+ stripReferralCodeFromUrl()
+ }, [
+ actions,
+ analytics,
+ isAffiliateProgramEnabled,
+ location.hash,
+ location.pathname,
+ location.search,
+ navigate,
+ savedCode,
+ verificationKind,
+ walletStatus,
+ ])
+
+ return null
+}
+
+interface TraderReferralCodeSnapshot {
+ savedCode?: string
+ verificationKind: TraderReferralCodeContextValue['verification']['kind']
+ walletStatus: TraderReferralCodeContextValue['wallet']['status']
+}
+
+interface ProcessTraderReferralCodeParams {
+ sanitized: string
+ snapshot: TraderReferralCodeSnapshot
+ actions: TraderReferralCodeContextValue['actions']
+ analytics: CowAnalytics
+}
+
+function processTraderReferralCode(params: ProcessTraderReferralCodeParams): void {
+ // Snapshot-driven flow: we decide up front whether to reuse the existing code or
+ // verify the incoming one. Persistence happens only on successful verification.
+ const { sanitized, snapshot, actions, analytics } = params
+ const isAlreadyLinked = snapshot.walletStatus === 'linked' || snapshot.verificationKind === 'linked'
+ const isWalletIneligible = snapshot.walletStatus === 'ineligible'
+ const isSameAsSaved = snapshot.savedCode ? snapshot.savedCode === sanitized : false
+ const hasExistingCode = Boolean(snapshot.savedCode)
+ const verificationKind = snapshot.verificationKind
+ const verificationIsRecoverable = !['invalid', 'ineligible'].includes(verificationKind)
+ const hasRestorableCode = hasExistingCode && !isSameAsSaved && verificationIsRecoverable
+
+ actions.openModal('deeplink', { code: sanitized })
+
+ if (isAlreadyLinked) {
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'deeplink_discarded',
+ label: 'linked_wallet',
+ value: sanitized.length,
+ })
+ return
+ }
+
+ if (isWalletIneligible) {
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'deeplink_discarded',
+ label: 'ineligible_wallet',
+ value: sanitized.length,
+ })
+ return
+ }
+
+ if (hasRestorableCode) {
+ actions.setShouldAutoVerify(true)
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'deeplink_preserved',
+ label: 'preserve_existing',
+ value: sanitized.length,
+ })
+ return
+ }
+
+ if (!isSameAsSaved) {
+ actions.setShouldAutoVerify(true)
+ analytics.sendEvent({ category: 'referral', action: 'code_saved', label: 'deeplink', value: sanitized.length })
+ return
+ }
+
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'deeplink_repeat',
+ label: 'existing_code',
+ value: sanitized.length,
+ })
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationEffects.ts b/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationEffects.ts
new file mode 100644
index 00000000000..d09fc1359e0
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationEffects.ts
@@ -0,0 +1,250 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+import type { CowAnalytics } from '@cowprotocol/analytics'
+
+import { performVerification } from './verificationLogic'
+
+import { isReferralCodeLengthValid, sanitizeReferralCode } from '../../lib/affiliate-program-utils'
+import {
+ TraderReferralCodeContextValue,
+ TraderReferralCodeVerificationStatus,
+ TraderWalletReferralCodeState,
+} from '../partner-trader-types'
+
+interface VerificationParams {
+ traderReferralCode: TraderReferralCodeContextValue
+ account?: string
+ chainId?: number
+ supportedNetwork: boolean
+ analytics: CowAnalytics
+ toggleWalletModal: () => void
+}
+
+export interface TraderReferralCodeVerificationHandle {
+ runVerification(code: string): Promise
+ cancelVerification(): void
+}
+
+export function useTraderReferralCodeVerification(params: VerificationParams): TraderReferralCodeVerificationHandle {
+ const { traderReferralCode, account, chainId, supportedNetwork, analytics, toggleWalletModal } = params
+ const pendingVerificationRef = useRef(null)
+
+ const applyVerificationResult = useCallback(
+ (status: TraderReferralCodeVerificationStatus, walletState?: TraderWalletReferralCodeState) => {
+ traderReferralCode.actions.completeVerification(status)
+
+ if (walletState) {
+ traderReferralCode.actions.setWalletState(walletState)
+ }
+ },
+ [traderReferralCode.actions],
+ )
+
+ const trackVerifyResult = useCallback(
+ (result: string, eligible: boolean, extraLabel?: string) => {
+ const parts = [`result=${result}`, `eligible=${eligible ? 'yes' : 'no'}`]
+
+ if (extraLabel) {
+ parts.push(extraLabel)
+ }
+
+ analytics.sendEvent({ category: 'referral', action: 'verify_result', label: parts.join(';') })
+ },
+ [analytics],
+ )
+
+ const runVerification = useCallback(
+ (rawCode: string) =>
+ performVerification({
+ rawCode,
+ account,
+ chainId,
+ supportedNetwork,
+ toggleWalletModal,
+ actions: traderReferralCode.actions,
+ analytics,
+ pendingVerificationRef,
+ applyVerificationResult,
+ trackVerifyResult,
+ incomingCode: traderReferralCode.incomingCode,
+ savedCode: traderReferralCode.savedCode,
+ currentVerification: traderReferralCode.verification,
+ previousVerification: traderReferralCode.previousVerification,
+ }),
+ [
+ account,
+ analytics,
+ applyVerificationResult,
+ chainId,
+ traderReferralCode.actions,
+ traderReferralCode.incomingCode,
+ traderReferralCode.previousVerification,
+ traderReferralCode.savedCode,
+ traderReferralCode.verification,
+ supportedNetwork,
+ toggleWalletModal,
+ trackVerifyResult,
+ ],
+ )
+
+ const cancelVerification = useCallback(() => {
+ pendingVerificationRef.current = null
+ }, [])
+
+ useEffect(() => {
+ traderReferralCode.actions.registerCancelVerification(cancelVerification)
+
+ return () => {
+ traderReferralCode.actions.registerCancelVerification(() => undefined)
+ }
+ }, [cancelVerification, traderReferralCode.actions])
+
+ return { runVerification, cancelVerification }
+}
+
+interface AutoVerificationParams {
+ traderReferralCode: TraderReferralCodeContextValue
+ account?: string
+ chainId?: number
+ supportedNetwork: boolean
+ runVerification: (code: string) => Promise
+}
+
+export function useTraderReferralCodeAutoVerification(params: AutoVerificationParams): void {
+ const { traderReferralCode, account, chainId, supportedNetwork, runVerification } = params
+ const { shouldAutoVerify, savedCode, inputCode, incomingCode, verification } = traderReferralCode
+
+ useEffect(() => {
+ const { code, shouldDisable } = resolveAutoVerification({
+ shouldAutoVerify,
+ walletStatus: traderReferralCode.wallet.status,
+ verificationKind: verification.kind,
+ account,
+ supportedNetwork,
+ chainId,
+ incomingCode,
+ savedCode,
+ inputCode,
+ })
+
+ if (shouldDisable) {
+ traderReferralCode.actions.setShouldAutoVerify(false)
+ return
+ }
+
+ if (!code) {
+ return
+ }
+
+ runVerification(code)
+ }, [
+ account,
+ chainId,
+ inputCode,
+ incomingCode,
+ traderReferralCode.actions,
+ traderReferralCode.wallet.status,
+ runVerification,
+ savedCode,
+ shouldAutoVerify,
+ supportedNetwork,
+ verification.kind,
+ ])
+}
+
+interface ResolveAutoVerificationParams {
+ shouldAutoVerify: boolean
+ walletStatus: TraderWalletReferralCodeState['status']
+ verificationKind: TraderReferralCodeVerificationStatus['kind']
+ account?: string
+ supportedNetwork: boolean
+ chainId?: number
+ incomingCode?: string
+ savedCode?: string
+ inputCode: string
+}
+
+function resolveAutoVerification(params: ResolveAutoVerificationParams): { code?: string; shouldDisable: boolean } {
+ // Centralises the auto-verify decision tree so the effect remains declarative.
+ // Return `shouldDisable` when the modal must stop auto-verification (linked wallet),
+ // otherwise surface the next code candidate once all prerequisites are satisfied.
+ const { shouldAutoVerify } = params
+
+ if (!shouldAutoVerify) {
+ return { shouldDisable: false }
+ }
+
+ if (shouldDisableAutoVerification(params)) {
+ return { shouldDisable: true }
+ }
+
+ const candidate = pickAutoVerificationCandidate(params)
+
+ if (!candidate) {
+ return { shouldDisable: false }
+ }
+
+ return { code: candidate, shouldDisable: false }
+}
+
+function shouldDisableAutoVerification(params: ResolveAutoVerificationParams): boolean {
+ return (
+ params.walletStatus === 'linked' || params.walletStatus === 'ineligible' || params.verificationKind === 'linked'
+ )
+}
+
+function pickAutoVerificationCandidate(params: ResolveAutoVerificationParams): string | undefined {
+ const { account, supportedNetwork, chainId, verificationKind, incomingCode, savedCode, inputCode } = params
+ // If any prerequisite is missing we keep the modal idle and wait; the caller
+ // will retry once account/network state changes or the current check finishes.
+ if (!account || !supportedNetwork || chainId === undefined || verificationKind === 'checking') {
+ return undefined
+ }
+
+ const sanitized = sanitizeReferralCode(incomingCode ?? inputCode ?? savedCode)
+
+ if (!sanitized || !isReferralCodeLengthValid(sanitized)) {
+ return undefined
+ }
+
+ return sanitized
+}
+interface PendingVerificationParams {
+ traderReferralCode: TraderReferralCodeContextValue
+ runVerification: (code: string) => Promise
+}
+
+export function usePendingReferralCodeVerificationHandler(params: PendingVerificationParams): void {
+ const { traderReferralCode, runVerification } = params
+ const { pendingVerificationRequest, actions, inputCode, savedCode } = traderReferralCode
+
+ useEffect(() => {
+ if (!pendingVerificationRequest) {
+ return
+ }
+
+ const { id, code } = pendingVerificationRequest
+ const candidate = code ?? inputCode ?? savedCode
+
+ if (!candidate) {
+ actions.clearPendingVerification(id)
+ return
+ }
+
+ let cancelled = false
+
+ ;(async () => {
+ try {
+ await runVerification(candidate)
+ } finally {
+ if (!cancelled) {
+ actions.clearPendingVerification(id)
+ }
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ }
+ }, [actions, inputCode, pendingVerificationRequest, runVerification, savedCode])
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationLogic.ts b/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationLogic.ts
new file mode 100644
index 00000000000..348a6f85636
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/containers/verificationLogic.ts
@@ -0,0 +1,275 @@
+import { MutableRefObject } from 'react'
+
+import { CowAnalytics } from '@cowprotocol/analytics'
+import { isProdLike } from '@cowprotocol/common-utils'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { bffAffiliateApi } from 'modules/affiliate/api/bffAffiliateApi'
+
+import {
+ isReferralCodeLengthValid,
+ sanitizeReferralCode,
+ type PartnerProgramParams,
+} from '../../lib/affiliate-program-utils'
+import {
+ TraderReferralCodeResponse,
+ TraderReferralCodeContextValue,
+ TraderReferralCodeVerificationResponse,
+ TraderReferralCodeVerificationStatus,
+ TraderWalletReferralCodeState,
+} from '../partner-trader-types'
+
+export interface PerformVerificationParams {
+ rawCode: string
+ account?: string
+ chainId?: number
+ supportedNetwork: boolean
+ toggleWalletModal: () => void
+ actions: TraderReferralCodeContextValue['actions']
+ analytics: CowAnalytics
+ pendingVerificationRef: MutableRefObject
+ applyVerificationResult: (
+ status: TraderReferralCodeVerificationStatus,
+ walletState?: TraderWalletReferralCodeState,
+ ) => void
+ trackVerifyResult: (result: string, eligible: boolean, extraLabel?: string) => void
+ incomingCode?: string
+ savedCode?: string
+ currentVerification: TraderReferralCodeVerificationStatus
+ previousVerification?: TraderReferralCodeVerificationStatus
+}
+
+// eslint-disable-next-line complexity
+export async function performVerification(params: PerformVerificationParams): Promise {
+ const context = sanitizeAndValidate(params)
+
+ if (!context) {
+ return
+ }
+
+ const { sanitizedCode, baseParams } = context
+ const { actions, pendingVerificationRef, applyVerificationResult, trackVerifyResult } = baseParams
+
+ if (!ensurePrerequisites(baseParams)) {
+ return
+ }
+
+ const requestId = startVerificationRequest({ sanitizedCode, baseParams })
+
+ try {
+ const response = await bffAffiliateApi.verifyReferralCode({
+ code: sanitizedCode,
+ account: baseParams.account,
+ chainId: baseParams.chainId,
+ })
+
+ if (pendingVerificationRef.current !== requestId) {
+ return
+ }
+
+ const preserveExisting =
+ Boolean(baseParams.incomingCode) &&
+ shouldPreserveExistingCode(
+ baseParams.previousVerification ?? baseParams.currentVerification,
+ baseParams.savedCode,
+ )
+
+ handleCodeStatusResponse({
+ response,
+ sanitizedCode,
+ actions,
+ applyVerificationResult,
+ trackVerifyResult,
+ preserveExisting,
+ currentVerification: baseParams.previousVerification ?? baseParams.currentVerification,
+ })
+ pendingVerificationRef.current = null
+ } catch (error) {
+ await new Promise((resolve) => setTimeout(resolve, 5_000)) // artificial delay to limit API spam
+
+ if (pendingVerificationRef.current !== requestId) {
+ return
+ }
+
+ const status = (error as Error & { status?: number }).status
+ const errorType = status ? 'network' : 'unknown'
+ const message = 'Unable to check code right now.'
+
+ applyVerificationResult({ kind: 'error', code: sanitizedCode, errorType, message })
+ trackVerifyResult('error', false, `type=${errorType}`)
+ pendingVerificationRef.current = null
+
+ if (!isProdLike) {
+ console.warn('[ReferralCode] Verification failed', error)
+ }
+ }
+}
+
+interface SanitizedContext {
+ sanitizedCode: string
+ baseParams: PerformVerificationParams
+}
+
+function sanitizeAndValidate(params: PerformVerificationParams): SanitizedContext | null {
+ const sanitizedCode = sanitizeReferralCode(params.rawCode)
+
+ if (!sanitizedCode || !isReferralCodeLengthValid(sanitizedCode)) {
+ return null
+ }
+
+ return {
+ sanitizedCode,
+ baseParams: { ...params },
+ }
+}
+
+function ensurePrerequisites(
+ params: PerformVerificationParams,
+): params is PerformVerificationParams & { account: string; chainId: SupportedChainId } {
+ const { account, chainId, supportedNetwork, toggleWalletModal, actions } = params
+
+ if (!account) {
+ toggleWalletModal()
+ return false
+ }
+
+ if (!supportedNetwork || chainId === undefined) {
+ actions.setWalletState({ status: 'unsupported', chainId })
+ return false
+ }
+
+ return true
+}
+
+function startVerificationRequest(params: {
+ sanitizedCode: string
+ baseParams: PerformVerificationParams & { account: string; chainId: SupportedChainId }
+}): number {
+ const { sanitizedCode, baseParams } = params
+ const { actions, analytics, account, supportedNetwork, pendingVerificationRef } = baseParams
+
+ const requestId = Date.now()
+ pendingVerificationRef.current = requestId
+ actions.startVerification(sanitizedCode)
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'verify_started',
+ label: `hasWallet=${account ? 'yes' : 'no'};supported=${supportedNetwork ? 'yes' : 'no'}`,
+ value: sanitizedCode.length,
+ })
+
+ return requestId
+}
+
+function shouldPreserveExistingCode(
+ currentVerification: TraderReferralCodeVerificationStatus,
+ savedCode?: string,
+): boolean {
+ if (!savedCode) {
+ return false
+ }
+
+ return currentVerification.kind === 'valid' || currentVerification.kind === 'linked'
+}
+
+function handleCodeStatusResponse(params: {
+ response: TraderReferralCodeVerificationResponse
+ sanitizedCode: string
+ actions: TraderReferralCodeContextValue['actions']
+ applyVerificationResult: (
+ status: TraderReferralCodeVerificationStatus,
+ walletState?: TraderWalletReferralCodeState,
+ ) => void
+ trackVerifyResult: (result: string, eligible: boolean, extraLabel?: string) => void
+ preserveExisting: boolean
+ currentVerification: TraderReferralCodeVerificationStatus
+}): void {
+ const { response, sanitizedCode, actions, applyVerificationResult, trackVerifyResult, preserveExisting } = params
+ const { currentVerification } = params
+
+ if (!response.ok) {
+ if (response.status === 404 || response.status === 403) {
+ actions.setIncomingCodeReason('invalid')
+
+ if (preserveExisting) {
+ restoreExistingVerificationState({
+ currentVerification,
+ applyVerificationResult,
+ })
+ trackVerifyResult('invalid', false)
+ return
+ }
+
+ applyVerificationResult({ kind: 'invalid', code: sanitizedCode })
+ trackVerifyResult('invalid', false)
+ return
+ }
+
+ const errorType = response.status ? 'network' : 'unknown'
+ const message = 'Unable to check code right now.'
+ applyVerificationResult({ kind: 'error', code: sanitizedCode, errorType, message })
+ trackVerifyResult('error', false, `type=${errorType}`)
+ return
+ }
+
+ actions.setIncomingCodeReason(undefined)
+ actions.setSavedCode(sanitizedCode)
+ applyVerificationResult({
+ kind: 'valid',
+ code: sanitizedCode,
+ eligible: true,
+ programParams: toPartnerProgramParams(response.data),
+ })
+ trackVerifyResult('valid', true)
+}
+
+function toPartnerProgramParams(data?: TraderReferralCodeResponse): PartnerProgramParams | undefined {
+ if (!data) {
+ return undefined
+ }
+ const { traderRewardAmount, triggerVolume, timeCapDays, volumeCap } = data
+ if (
+ typeof traderRewardAmount !== 'number' ||
+ typeof triggerVolume !== 'number' ||
+ typeof timeCapDays !== 'number' ||
+ typeof volumeCap !== 'number'
+ ) {
+ return undefined
+ }
+ return {
+ traderRewardAmount,
+ triggerVolumeUsd: triggerVolume,
+ timeCapDays,
+ volumeCapUsd: volumeCap,
+ }
+}
+
+function restoreExistingVerificationState(params: {
+ currentVerification: TraderReferralCodeVerificationStatus
+ applyVerificationResult: (
+ status: TraderReferralCodeVerificationStatus,
+ walletState?: TraderWalletReferralCodeState,
+ ) => void
+}): void {
+ const { currentVerification, applyVerificationResult } = params
+
+ if (currentVerification.kind === 'linked') {
+ applyVerificationResult(
+ { kind: 'linked', code: currentVerification.code, linkedCode: currentVerification.linkedCode },
+ { status: 'linked', code: currentVerification.linkedCode },
+ )
+ return
+ }
+
+ if (currentVerification.kind === 'valid') {
+ applyVerificationResult({
+ kind: 'valid',
+ code: currentVerification.code,
+ eligible: currentVerification.eligible,
+ programParams: currentVerification.programParams,
+ })
+ return
+ }
+
+ applyVerificationResult(currentVerification)
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCode.ts b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCode.ts
new file mode 100644
index 00000000000..db7131bb0f1
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCode.ts
@@ -0,0 +1,6 @@
+import { TraderReferralCodeContextValue } from '../partner-trader-types'
+import { useTraderReferralCodeContext } from '../state/TraderReferralCodeContext'
+
+export function useTraderReferralCode(): TraderReferralCodeContextValue {
+ return useTraderReferralCodeContext()
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeActions.ts b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeActions.ts
new file mode 100644
index 00000000000..b2ea6574474
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeActions.ts
@@ -0,0 +1,6 @@
+import { TraderReferralCodeActions } from '../partner-trader-types'
+import { useTraderReferralCodeContext } from '../state/TraderReferralCodeContext'
+
+export function useTraderReferralCodeActions(): TraderReferralCodeActions {
+ return useTraderReferralCodeContext().actions
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeModalState.ts b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeModalState.ts
new file mode 100644
index 00000000000..18a4e962cc8
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeModalState.ts
@@ -0,0 +1,142 @@
+import { useMemo } from 'react'
+
+import { useTraderReferralCode } from './useTraderReferralCode'
+
+import { isReferralCodeLengthValid } from '../../lib/affiliate-program-utils'
+
+export type TraderReferralCodeModalUiState =
+ | 'empty'
+ | 'editing'
+ | 'pending'
+ | 'unsupported'
+ | 'checking'
+ | 'invalid'
+ | 'valid'
+ | 'linked'
+ | 'ineligible'
+ | 'error'
+
+type TraderReferralCodeSnapshot = ReturnType
+
+interface TraderReferralCodeModalState {
+ traderReferralCode: TraderReferralCodeSnapshot
+ uiState: TraderReferralCodeModalUiState
+ displayCode: string
+ savedCode?: string
+ inputCode: string
+ incomingCode?: string
+ hasCode: boolean
+ hasValidLength: boolean
+ isEditing: boolean
+ shouldAutoVerify: boolean
+ verification: TraderReferralCodeSnapshot['verification']
+ wallet: TraderReferralCodeSnapshot['wallet']
+}
+
+type TraderReferralCodeVerificationKind = TraderReferralCodeSnapshot['verification']['kind']
+type TraderReferralCodeWalletStatus = TraderReferralCodeSnapshot['wallet']['status']
+
+export function useTraderReferralCodeModalState(): TraderReferralCodeModalState {
+ const traderReferralCode = useTraderReferralCode()
+
+ return useMemo(() => buildTraderReferralCodeModalState(traderReferralCode), [traderReferralCode])
+}
+
+function buildTraderReferralCodeModalState(
+ traderReferralCode: TraderReferralCodeSnapshot,
+): TraderReferralCodeModalState {
+ const { inputCode, savedCode, verification, wallet, editMode, incomingCode, shouldAutoVerify } = traderReferralCode
+ const verificationCode = 'code' in verification ? verification.code : undefined
+ // Prefer the code the user is actively verifying (incoming/verification) so the UI
+ // reflects what the backend is checking even if a different value lives in storage.
+ const displayCode = editMode ? inputCode : (verificationCode ?? savedCode ?? inputCode)
+ const hasCode = hasAnyCode(verificationCode, incomingCode, savedCode, inputCode)
+ const hasValidLength = isReferralCodeLengthValid(displayCode || '')
+ const isEditing = editMode || (!savedCode && hasCode)
+ const uiState = deriveUiState({
+ verificationKind: verification.kind,
+ walletStatus: wallet.status,
+ hasCode,
+ isEditing,
+ editMode,
+ })
+
+ return {
+ traderReferralCode,
+ uiState,
+ displayCode,
+ savedCode,
+ inputCode,
+ incomingCode,
+ hasCode,
+ hasValidLength,
+ isEditing,
+ shouldAutoVerify,
+ verification,
+ wallet,
+ }
+}
+
+function hasAnyCode(verificationCode?: string, incomingCode?: string, savedCode?: string, inputCode?: string): boolean {
+ return Boolean(verificationCode || incomingCode || savedCode || inputCode)
+}
+
+interface DeriveUiStateParams {
+ verificationKind: TraderReferralCodeVerificationKind
+ walletStatus: TraderReferralCodeWalletStatus
+ hasCode: boolean
+ isEditing: boolean
+ editMode: boolean
+}
+
+function deriveUiState(params: DeriveUiStateParams): TraderReferralCodeModalUiState {
+ const { verificationKind, walletStatus, hasCode, isEditing, editMode } = params
+
+ if (walletStatus === 'unsupported') {
+ // Unsupported network trumps every other state so the form/CTA are guaranteed to lock.
+ return 'unsupported'
+ }
+
+ if (!isEditing && (verificationKind === 'linked' || walletStatus === 'linked')) {
+ return 'linked'
+ }
+
+ if (editMode && hasCode) {
+ return 'editing'
+ }
+
+ const stateFromVerification = resolveVerificationState(verificationKind, walletStatus, hasCode)
+ if (stateFromVerification) {
+ return stateFromVerification
+ }
+
+ if (isEditing && hasCode) {
+ return 'editing'
+ }
+
+ return 'empty'
+}
+
+function resolveVerificationState(
+ verificationKind: TraderReferralCodeVerificationKind,
+ walletStatus: TraderReferralCodeWalletStatus,
+ hasCode: boolean,
+): TraderReferralCodeModalUiState | null {
+ const orderedConditions: Array<[boolean, TraderReferralCodeModalUiState]> = [
+ [verificationKind === 'checking', 'checking'],
+ [walletStatus === 'unsupported' && hasCode, 'unsupported'],
+ [verificationKind === 'invalid', 'invalid'],
+ [verificationKind === 'valid', 'valid'],
+ [verificationKind === 'ineligible' || walletStatus === 'ineligible', 'ineligible'],
+ [verificationKind === 'error', 'error'],
+ [verificationKind === 'pending', 'pending'],
+ ]
+
+ for (const [condition, state] of orderedConditions) {
+ if (condition) {
+ return state
+ }
+ }
+
+ return null
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeWalletSync.ts b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeWalletSync.ts
new file mode 100644
index 00000000000..26dc0b6a172
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/hooks/useTraderReferralCodeWalletSync.ts
@@ -0,0 +1,67 @@
+import { useLayoutEffect, useMemo } from 'react'
+
+import { areAddressesEqual } from '@cowprotocol/common-utils'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { useSelector } from 'react-redux'
+
+import { AppState } from 'legacy/state'
+import { flatOrdersStateNetwork } from 'legacy/state/orders/flatOrdersStateNetwork'
+import { getDefaultNetworkState, OrdersState } from 'legacy/state/orders/reducer'
+
+import { TraderReferralCodeContextValue } from '../partner-trader-types'
+
+interface WalletSyncParams {
+ account?: string
+ chainId?: number
+ supportedNetwork: boolean
+ actions: TraderReferralCodeContextValue['actions']
+ savedCode?: string
+}
+
+export function useTraderReferralCodeWalletSync(params: WalletSyncParams): void {
+ const { account, chainId, supportedNetwork, actions, savedCode } = params
+ const ordersState = useSelector((state) => state.orders)
+ const hasOrders = useMemo(() => {
+ if (!account) {
+ return false
+ }
+
+ if (!ordersState) {
+ return false
+ }
+
+ return Object.entries(ordersState).some(([networkId, networkState]) => {
+ const resolvedChainId = Number(networkId) as SupportedChainId
+ const fullState = { ...getDefaultNetworkState(resolvedChainId), ...(networkState || {}) }
+ const ordersMap = flatOrdersStateNetwork(fullState)
+
+ return Object.values(ordersMap).some(
+ (order) => order?.order.owner && areAddressesEqual(order.order.owner, account),
+ )
+ })
+ }, [account, ordersState])
+
+ useLayoutEffect(() => {
+ if (!account) {
+ actions.setWalletState({ status: 'disconnected' })
+ return
+ }
+
+ if (!supportedNetwork) {
+ actions.setWalletState({ status: 'unsupported', chainId })
+ return
+ }
+
+ if (hasOrders) {
+ if (savedCode) {
+ actions.setWalletState({ status: 'linked', code: savedCode })
+ } else {
+ actions.setWalletState({ status: 'ineligible', reason: 'This wallet already placed an order.' })
+ }
+ return
+ }
+
+ actions.setWalletState({ status: 'eligible' })
+ }, [account, actions, chainId, hasOrders, savedCode, supportedNetwork])
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/partner-trader-types.ts b/apps/cowswap-frontend/src/modules/affiliate/model/partner-trader-types.ts
new file mode 100644
index 00000000000..98294abc6ff
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/partner-trader-types.ts
@@ -0,0 +1,191 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { PartnerProgramParams } from '../lib/affiliate-program-utils'
+
+/**
+ * Flags how the referral modal was launched:
+ * - 'ui': user clicked through our surfaces (header CTA, rewards hub, etc.)
+ * - 'deeplink': modal auto-opened from a `?ref=` query
+ * - 'rewards': user visited the rewards dashboard and had no code yet
+ */
+export type TraderReferralCodeModalSource = 'ui' | 'deeplink' | 'rewards'
+
+/**
+ * Categorises referral verification failures so UI copy can react:
+ * - 'network': request failed or timed out
+ * - 'unknown': any other error case we can't classify yet
+ */
+export type TraderReferralCodeVerificationErrorType = 'network' | 'unknown'
+export type TraderReferralCodeIncomingReason = 'invalid' | 'ineligible'
+
+/**
+ * State machine describing the referral code lifecycle:
+ * - 'idle': modal hasn't captured a code yet
+ * - 'pending': code captured but we still need wallet/network before verifying
+ * - 'checking': verification request in flight
+ * - 'valid': backend accepted the code; `eligible` indicates next steps
+ * - 'invalid': backend rejected the code
+ * - 'linked': wallet already bound to `linkedCode`
+ * - 'ineligible': wallet can't use the code; optional `incomingCode` shows what triggered it
+ * - 'error': verification failed; includes error type + message for UI feedback
+ */
+export type TraderReferralCodeVerificationStatus =
+ | { kind: 'idle' }
+ | { kind: 'pending'; code: string }
+ | { kind: 'checking'; code: string }
+ | { kind: 'valid'; code: string; eligible: boolean; programParams?: PartnerProgramParams }
+ | { kind: 'invalid'; code: string }
+ | { kind: 'linked'; code: string; linkedCode: string }
+ | { kind: 'ineligible'; code: string; reason: string; incomingCode?: string }
+ | { kind: 'error'; code: string; errorType: TraderReferralCodeVerificationErrorType; message: string }
+
+/**
+ * Represents the wallet's relationship with the referral program:
+ * - 'unknown': we haven't fetched or inferred anything yet
+ * - 'disconnected': user lacks a connected wallet
+ * - 'unsupported': wallet network doesn't match supported IDs (optional `chainId` for display)
+ * - 'eligible': wallet can bind the provided code
+ * - 'linked': wallet already bound to the given `code`
+ * - 'ineligible': wallet rejected with `reason`; may also expose an existing `linkedCode`
+ */
+export type TraderWalletReferralCodeState =
+ | { status: 'unknown' }
+ | { status: 'disconnected' }
+ | { status: 'unsupported'; chainId?: SupportedChainId | number }
+ | { status: 'eligible' }
+ | { status: 'linked'; code: string }
+ | { status: 'ineligible'; reason: string; linkedCode?: string }
+
+export interface TraderReferralCodeState {
+ modalOpen: boolean
+ modalSource: TraderReferralCodeModalSource | null
+ editMode: boolean
+ inputCode: string
+ savedCode?: string
+ incomingCode?: string
+ incomingCodeReason?: TraderReferralCodeIncomingReason
+ previousVerification?: TraderReferralCodeVerificationStatus
+ verification: TraderReferralCodeVerificationStatus
+ wallet: TraderWalletReferralCodeState
+ shouldAutoVerify: boolean
+ lastVerificationRequest?: {
+ code: string
+ timestamp: number
+ }
+ pendingVerificationRequest?: {
+ id: number
+ code?: string
+ }
+}
+
+export interface TraderReferralCodeApiConfig {
+ baseUrl: string
+ timeoutMs?: number
+}
+
+export interface TraderReferralCodeVerificationRequest {
+ code: string
+ account: string
+ chainId: SupportedChainId
+}
+
+export interface TraderReferralCodeVerificationResponse {
+ status: number
+ ok: boolean
+ data?: TraderReferralCodeResponse
+ text: string
+}
+
+/**
+ * Shape returned by the referral verification endpoint.
+ */
+
+export interface TraderWalletReferralCodeStatusRequest {
+ account: string
+}
+
+export interface TraderWalletReferralCodeStatusResponse {
+ wallet: {
+ linkedCode?: string
+ ineligibleReason?: string
+ }
+}
+
+export interface TraderReferralCodeResponse {
+ code: string
+ traderRewardAmount?: number
+ triggerVolume?: number
+ timeCapDays?: number
+ volumeCap?: number
+}
+
+export interface PartnerCodeResponse {
+ code: string
+ createdAt: string
+ rewardAmount: number
+ triggerVolume: number
+ timeCapDays: number
+ volumeCap: number
+ revenueSplitAffiliatePct: number
+ revenueSplitTraderPct: number
+ revenueSplitDaoPct: number
+}
+
+export interface TraderStatsResponse {
+ trader_address: string
+ bound_referrer_code: string
+ linked_since: string
+ rewards_end: string
+ eligible_volume: number
+ left_to_next_rewards: number
+ trigger_volume: number
+ total_earned: number
+ paid_out: number
+ next_payout: number
+ lastUpdatedAt: string
+}
+
+export interface PartnerStatsResponse {
+ affiliate_address: string
+ referrer_code: string
+ total_volume: number
+ trigger_volume: number
+ total_earned: number
+ paid_out: number
+ next_payout: number
+ left_to_next_reward: number
+ active_traders: number
+ total_traders: number
+ lastUpdatedAt: string
+}
+
+export interface PartnerCreateRequest {
+ code: string
+ walletAddress: string
+ signedMessage: string
+}
+
+export interface TraderReferralCodeContextValue extends TraderReferralCodeState {
+ cancelVerification: () => void
+ actions: TraderReferralCodeActions
+}
+
+export interface TraderReferralCodeActions {
+ openModal(source: TraderReferralCodeModalSource, options?: { code?: string }): void
+ closeModal(): void
+ setInputCode(value: string): void
+ enableEditMode(): void
+ disableEditMode(): void
+ saveCode(code: string): void
+ removeCode(): void
+ setIncomingCode(code?: string): void
+ setIncomingCodeReason(reason?: TraderReferralCodeIncomingReason): void
+ setWalletState(state: TraderWalletReferralCodeState): void
+ startVerification(code: string): void
+ completeVerification(status: TraderReferralCodeVerificationStatus): void
+ setShouldAutoVerify(value: boolean): void
+ setSavedCode(code?: string): void
+ requestVerification(code?: string): void
+ clearPendingVerification(id: number): void
+ registerCancelVerification(handler: () => void): void
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/state/TraderReferralCodeContext.tsx b/apps/cowswap-frontend/src/modules/affiliate/model/state/TraderReferralCodeContext.tsx
new file mode 100644
index 00000000000..4e122b19885
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/state/TraderReferralCodeContext.tsx
@@ -0,0 +1,193 @@
+import { ReactNode, createContext, useCallback, useContext, useMemo, useState, Dispatch, SetStateAction } from 'react'
+
+import {
+ reduceClearPendingVerification,
+ reduceCloseModal,
+ reduceCompleteVerification,
+ reduceDisableEditMode,
+ reduceEnableEditMode,
+ reduceOpenModal,
+ reduceRemoveCode,
+ reduceRequestVerification,
+ reduceSaveCode,
+ reduceSetIncomingCode,
+ reduceSetIncomingCodeReason,
+ reduceSetInputCode,
+ reduceSetSavedCode,
+ reduceSetShouldAutoVerify,
+ reduceSetWalletState,
+ reduceStartVerification,
+} from './traderReferralCodeReducers'
+import {
+ useTraderReferralCodeHydration,
+ useTraderReferralCodePersistence,
+ useTraderReferralCodeStorageSync,
+} from './traderReferralCodeStorage'
+
+import {
+ TraderReferralCodeActions,
+ TraderReferralCodeContextValue,
+ TraderReferralCodeState,
+} from '../partner-trader-types'
+
+export interface TraderReferralCodeProviderProps {
+ children: ReactNode
+}
+
+const initialState: TraderReferralCodeState = {
+ modalOpen: false,
+ modalSource: null,
+ editMode: false,
+ inputCode: '',
+ savedCode: undefined,
+ incomingCode: undefined,
+ incomingCodeReason: undefined,
+ previousVerification: undefined,
+ verification: { kind: 'idle' },
+ wallet: { status: 'unknown' },
+ shouldAutoVerify: false,
+ lastVerificationRequest: undefined,
+ pendingVerificationRequest: undefined,
+}
+
+const noop = (): void => undefined
+
+const noopActions: TraderReferralCodeActions = {
+ openModal: noop,
+ closeModal: noop,
+ setInputCode: noop,
+ enableEditMode: noop,
+ disableEditMode: noop,
+ saveCode: noop,
+ removeCode: noop,
+ setIncomingCode: noop,
+ setIncomingCodeReason: noop,
+ setWalletState: noop,
+ startVerification: noop,
+ completeVerification: noop,
+ setShouldAutoVerify: noop,
+ setSavedCode: noop,
+ requestVerification: noop,
+ clearPendingVerification: noop,
+ registerCancelVerification: noop,
+}
+
+const TraderReferralCodeContext = createContext({
+ ...initialState,
+ cancelVerification: noop,
+ actions: noopActions,
+})
+
+export function TraderReferralCodeProvider({ children }: TraderReferralCodeProviderProps): ReactNode {
+ const value = useTraderReferralCodeStore()
+
+ return {children}
+}
+
+function useTraderReferralCodeStore(): TraderReferralCodeContextValue {
+ const [state, setState] = useState(initialState)
+ const [hasHydrated, setHasHydrated] = useState(false)
+ const [cancelVerification, setCancelVerification] = useState<() => void>(() => () => undefined)
+
+ useTraderReferralCodeHydration(setState, setHasHydrated)
+ useTraderReferralCodePersistence(state.savedCode, hasHydrated)
+ useTraderReferralCodeStorageSync(setState)
+
+ const actions = useTraderReferralCodeStoreActions(setState, setCancelVerification)
+
+ return useMemo(
+ () => ({
+ ...state,
+ cancelVerification,
+ actions,
+ }),
+ [actions, cancelVerification, state],
+ )
+}
+
+type SetTraderReferralCodeState = Dispatch>
+
+function useStateReducerAction(
+ setState: SetTraderReferralCodeState,
+ reducer: (state: TraderReferralCodeState, ...args: A) => TraderReferralCodeState,
+): (...args: A) => void {
+ return useCallback(
+ (...args: A) => {
+ setState((prev) => reducer(prev, ...args))
+ },
+ [reducer, setState],
+ )
+}
+
+function useTraderReferralCodeStoreActions(
+ setState: SetTraderReferralCodeState,
+ setCancelVerification: Dispatch void>>,
+): TraderReferralCodeActions {
+ const openModal = useStateReducerAction(setState, reduceOpenModal)
+ const closeModal = useStateReducerAction(setState, reduceCloseModal)
+ const setInputCode = useStateReducerAction(setState, reduceSetInputCode)
+ const enableEditMode = useStateReducerAction(setState, reduceEnableEditMode)
+ const disableEditMode = useStateReducerAction(setState, reduceDisableEditMode)
+ const saveCode = useStateReducerAction(setState, reduceSaveCode)
+ const removeCode = useStateReducerAction(setState, reduceRemoveCode)
+ const setIncomingCode = useStateReducerAction(setState, reduceSetIncomingCode)
+ const setIncomingCodeReason = useStateReducerAction(setState, reduceSetIncomingCodeReason)
+ const setWalletState = useStateReducerAction(setState, reduceSetWalletState)
+ const startVerification = useStateReducerAction(setState, reduceStartVerification)
+ const completeVerification = useStateReducerAction(setState, reduceCompleteVerification)
+ const setShouldAutoVerify = useStateReducerAction(setState, reduceSetShouldAutoVerify)
+ const setSavedCode = useStateReducerAction(setState, reduceSetSavedCode)
+ const requestVerification = useStateReducerAction(setState, reduceRequestVerification)
+ const clearPendingVerification = useStateReducerAction(setState, reduceClearPendingVerification)
+ const registerCancelVerification = useCallback(
+ (handler: () => void) => {
+ setCancelVerification(() => handler)
+ },
+ [setCancelVerification],
+ )
+
+ return useMemo(
+ () => ({
+ openModal,
+ closeModal,
+ setInputCode,
+ enableEditMode,
+ disableEditMode,
+ saveCode,
+ removeCode,
+ setIncomingCode,
+ setIncomingCodeReason,
+ setWalletState,
+ startVerification,
+ completeVerification,
+ setShouldAutoVerify,
+ setSavedCode,
+ requestVerification,
+ clearPendingVerification,
+ registerCancelVerification,
+ }),
+ [
+ clearPendingVerification,
+ closeModal,
+ completeVerification,
+ disableEditMode,
+ enableEditMode,
+ openModal,
+ removeCode,
+ requestVerification,
+ saveCode,
+ setIncomingCode,
+ setIncomingCodeReason,
+ setInputCode,
+ registerCancelVerification,
+ setSavedCode,
+ setShouldAutoVerify,
+ setWalletState,
+ startVerification,
+ ],
+ )
+}
+
+export function useTraderReferralCodeContext(): TraderReferralCodeContextValue {
+ return useContext(TraderReferralCodeContext)
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeReducers.ts b/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeReducers.ts
new file mode 100644
index 00000000000..13b99b73174
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeReducers.ts
@@ -0,0 +1,217 @@
+import { sanitizeReferralCode } from '../../lib/affiliate-program-utils'
+import {
+ TraderReferralCodeState,
+ TraderReferralCodeIncomingReason,
+ TraderReferralCodeModalSource,
+ TraderReferralCodeVerificationStatus,
+ TraderWalletReferralCodeState,
+} from '../partner-trader-types'
+
+export function reduceOpenModal(
+ prev: TraderReferralCodeState,
+ source: TraderReferralCodeModalSource,
+ options?: { code?: string },
+): TraderReferralCodeState {
+ const sanitizedIncoming = options?.code ? sanitizeReferralCode(options.code) : undefined
+ const isLinked = prev.wallet.status === 'linked' || prev.verification.kind === 'linked'
+
+ const nextInputCode = resolveInputCode(prev, sanitizedIncoming, isLinked)
+ const nextVerification = resolveVerification(prev, sanitizedIncoming, isLinked)
+ const nextPreviousVerification = sanitizedIncoming && !isLinked ? prev.verification : prev.previousVerification
+
+ return {
+ ...prev,
+ modalOpen: true,
+ modalSource: source,
+ editMode: false,
+ incomingCode: sanitizedIncoming,
+ incomingCodeReason: undefined,
+ previousVerification: nextPreviousVerification,
+ inputCode: nextInputCode,
+ verification: nextVerification,
+ }
+}
+
+function resolveInputCode(
+ prev: TraderReferralCodeState,
+ sanitizedIncoming: string | undefined,
+ isLinked: boolean,
+): string {
+ const candidate = isLinked
+ ? prev.savedCode || prev.inputCode || ''
+ : (sanitizedIncoming ?? prev.inputCode ?? prev.savedCode ?? '')
+
+ return sanitizeReferralCode(candidate) ?? ''
+}
+
+function resolveVerification(
+ prev: TraderReferralCodeState,
+ sanitizedIncoming: string | undefined,
+ isLinked: boolean,
+): TraderReferralCodeState['verification'] {
+ if (!sanitizedIncoming || sanitizedIncoming === prev.savedCode || isLinked) {
+ return prev.verification
+ }
+
+ return { kind: 'pending', code: sanitizedIncoming }
+}
+
+export function reduceCloseModal(prev: TraderReferralCodeState): TraderReferralCodeState {
+ return {
+ ...prev,
+ modalOpen: false,
+ modalSource: null,
+ editMode: false,
+ incomingCode: undefined,
+ incomingCodeReason: undefined,
+ previousVerification: undefined,
+ pendingVerificationRequest: undefined,
+ }
+}
+
+export function reduceSetInputCode(prev: TraderReferralCodeState, value: string): TraderReferralCodeState {
+ const sanitized = sanitizeReferralCode(value)
+
+ return {
+ ...prev,
+ inputCode: sanitized,
+ verification: prev.verification.kind === 'pending' ? prev.verification : { kind: 'idle' },
+ }
+}
+
+export const reduceEnableEditMode = (prev: TraderReferralCodeState): TraderReferralCodeState => ({
+ ...prev,
+ editMode: true,
+})
+
+export const reduceDisableEditMode = (prev: TraderReferralCodeState): TraderReferralCodeState => ({
+ ...prev,
+ editMode: false,
+})
+
+export function reduceSaveCode(prev: TraderReferralCodeState, value: string): TraderReferralCodeState {
+ const sanitized = sanitizeReferralCode(value)
+
+ if (!sanitized) {
+ return {
+ ...prev,
+ savedCode: undefined,
+ inputCode: '',
+ verification: { kind: 'idle' },
+ shouldAutoVerify: false,
+ }
+ }
+
+ return {
+ ...prev,
+ inputCode: sanitized,
+ verification: { kind: 'pending', code: sanitized },
+ editMode: false,
+ shouldAutoVerify: true,
+ previousVerification: undefined,
+ }
+}
+
+export const reduceRemoveCode = (prev: TraderReferralCodeState): TraderReferralCodeState => ({
+ ...prev,
+ savedCode: undefined,
+ inputCode: '',
+ incomingCode: undefined,
+ incomingCodeReason: undefined,
+ previousVerification: undefined,
+ verification: { kind: 'idle' },
+ shouldAutoVerify: false,
+})
+
+export const reduceSetIncomingCode = (prev: TraderReferralCodeState, code?: string): TraderReferralCodeState => ({
+ ...prev,
+ incomingCode: code ? sanitizeReferralCode(code) : undefined,
+ incomingCodeReason: undefined,
+})
+
+export const reduceSetIncomingCodeReason = (
+ prev: TraderReferralCodeState,
+ reason?: TraderReferralCodeIncomingReason,
+): TraderReferralCodeState => ({
+ ...prev,
+ incomingCodeReason: reason,
+})
+
+export const reduceSetWalletState = (
+ prev: TraderReferralCodeState,
+ walletState: TraderWalletReferralCodeState,
+): TraderReferralCodeState => ({
+ ...prev,
+ wallet: walletState,
+})
+
+export function reduceStartVerification(prev: TraderReferralCodeState, code: string): TraderReferralCodeState {
+ const sanitized = sanitizeReferralCode(code)
+
+ if (!sanitized) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ verification: { kind: 'checking', code: sanitized },
+ shouldAutoVerify: false,
+ lastVerificationRequest: { code: sanitized, timestamp: Date.now() },
+ }
+}
+
+export const reduceCompleteVerification = (
+ prev: TraderReferralCodeState,
+ status: TraderReferralCodeVerificationStatus,
+): TraderReferralCodeState => ({
+ ...prev,
+ verification: status,
+ previousVerification: undefined,
+ shouldAutoVerify: false,
+})
+
+export const reduceSetShouldAutoVerify = (prev: TraderReferralCodeState, value: boolean): TraderReferralCodeState => ({
+ ...prev,
+ shouldAutoVerify: value,
+})
+
+export function reduceSetSavedCode(prev: TraderReferralCodeState, value?: string): TraderReferralCodeState {
+ const sanitized = value ? sanitizeReferralCode(value) : undefined
+
+ if (!sanitized) {
+ return {
+ ...prev,
+ savedCode: undefined,
+ inputCode: '',
+ verification: { kind: 'idle' },
+ shouldAutoVerify: false,
+ previousVerification: undefined,
+ }
+ }
+
+ return {
+ ...prev,
+ savedCode: sanitized,
+ inputCode: sanitized,
+ verification: { kind: 'pending', code: sanitized },
+ shouldAutoVerify: true,
+ previousVerification: undefined,
+ }
+}
+
+export const reduceRequestVerification = (prev: TraderReferralCodeState, code?: string): TraderReferralCodeState => ({
+ ...prev,
+ pendingVerificationRequest: { id: Date.now(), code: code ? sanitizeReferralCode(code) : undefined },
+ shouldAutoVerify: false,
+})
+
+export const reduceClearPendingVerification = (prev: TraderReferralCodeState, id: number): TraderReferralCodeState => {
+ if (prev.pendingVerificationRequest?.id !== id) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ pendingVerificationRequest: undefined,
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeStorage.ts b/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeStorage.ts
new file mode 100644
index 00000000000..330db280653
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/model/state/traderReferralCodeStorage.ts
@@ -0,0 +1,128 @@
+import { Dispatch, SetStateAction, useEffect } from 'react'
+
+import { isProdLike } from '@cowprotocol/common-utils'
+
+import { reduceRemoveCode, reduceSetSavedCode } from './traderReferralCodeReducers'
+
+import { AFFILIATE_TRADER_STORAGE_KEY } from '../../config/constants'
+import { sanitizeReferralCode } from '../../lib/affiliate-program-utils'
+import { TraderReferralCodeState } from '../partner-trader-types'
+
+type SetTraderReferralCodeState = Dispatch>
+type SetHydratedState = Dispatch>
+
+export function useTraderReferralCodeHydration(
+ setState: SetTraderReferralCodeState,
+ setHydrated: SetHydratedState,
+): void {
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ setHydrated(true)
+ return
+ }
+
+ let shouldHydrate = true
+
+ try {
+ const stored = window.localStorage.getItem(AFFILIATE_TRADER_STORAGE_KEY)
+
+ if (stored) {
+ const sanitized = sanitizeReferralCode(stored)
+
+ if (sanitized) {
+ setState((prev) => reduceSetSavedCode(prev, sanitized))
+ }
+ }
+ } catch (error) {
+ shouldHydrate = true
+ if (!isProdLike) {
+ console.warn('[ReferralCode] Failed to read saved code from storage', error)
+ }
+ }
+
+ if (shouldHydrate) {
+ setHydrated(true)
+ }
+ }, [setHydrated, setState])
+}
+
+export function useTraderReferralCodePersistence(savedCode: string | undefined, hasHydrated: boolean): void {
+ useEffect(() => {
+ if (!hasHydrated || typeof window === 'undefined') {
+ return
+ }
+
+ try {
+ if (!savedCode) {
+ window.localStorage.removeItem(AFFILIATE_TRADER_STORAGE_KEY)
+ } else {
+ window.localStorage.setItem(AFFILIATE_TRADER_STORAGE_KEY, savedCode)
+ }
+ } catch (error) {
+ if (!isProdLike) {
+ console.warn('[ReferralCode] Failed to persist saved code', error)
+ }
+ }
+ }, [hasHydrated, savedCode])
+}
+
+export function useTraderReferralCodeStorageSync(setState: SetTraderReferralCodeState): void {
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const applyValue = (value: string | null): void => {
+ setState((prev) => {
+ const sanitized = value ? sanitizeReferralCode(value) : undefined
+
+ if (!sanitized) {
+ return prev.savedCode ? reduceRemoveCode(prev) : prev
+ }
+
+ if (prev.savedCode === sanitized) {
+ return prev
+ }
+
+ return reduceSetSavedCode(prev, sanitized)
+ })
+ }
+
+ const handleStorage = (event: StorageEvent): void => {
+ if (event.key !== AFFILIATE_TRADER_STORAGE_KEY) {
+ return
+ }
+
+ applyValue(event.newValue)
+ }
+
+ const handleFocus = (): void => {
+ try {
+ const current = window.localStorage.getItem(AFFILIATE_TRADER_STORAGE_KEY)
+ applyValue(current)
+ } catch (error) {
+ if (!isProdLike) {
+ console.warn('[ReferralCode] Failed to sync saved code from storage on focus', error)
+ }
+ }
+ }
+
+ const handleVisibility = (): void => {
+ if (document.visibilityState === 'visible') {
+ handleFocus()
+ }
+ }
+
+ window.addEventListener('storage', handleStorage)
+ window.addEventListener('focus', handleFocus)
+ document.addEventListener('visibilitychange', handleVisibility)
+
+ handleFocus()
+
+ return () => {
+ window.removeEventListener('storage', handleStorage)
+ window.removeEventListener('focus', handleFocus)
+ document.removeEventListener('visibilitychange', handleVisibility)
+ }
+ }, [setState])
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
new file mode 100644
index 00000000000..b896a5455dc
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeIneligibleCopy.tsx
@@ -0,0 +1,44 @@
+import { ReactNode } from 'react'
+
+import { Trans } from '@lingui/react/macro'
+
+import { HowItWorksLink } from './shared'
+
+import { AFFILIATE_HOW_IT_WORKS_URL } from '../config/constants'
+
+export interface TraderReferralCodeIneligibleCopyProps {
+ incomingCode?: string
+}
+
+export function TraderReferralCodeHowItWorksLink(): ReactNode {
+ return (
+
+ How it works.
+
+ )
+}
+
+export function TraderReferralCodeIneligibleCopy(props: TraderReferralCodeIneligibleCopyProps): ReactNode {
+ const { incomingCode } = props
+
+ return (
+ <>
+ {incomingCode ? (
+ <>
+
+ The code {incomingCode} from your link wasn't applied because this wallet has already
+ traded on CoW Swap.
+ {' '}
+ Referral rewards are for new wallets only.{' '}
+ >
+ ) : (
+ <>
+
+ This wallet has already traded on CoW Swap.
Referral rewards are for new wallets only.
+ {' '}
+ >
+ )}
+
+ >
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
new file mode 100644
index 00000000000..8aaa8fc7974
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/TraderReferralCodeInputRow.tsx
@@ -0,0 +1,173 @@
+import { FormEvent, ReactNode, RefObject } from 'react'
+
+import AlertIcon from '@cowprotocol/assets/cow-swap/alert-circle.svg'
+import CheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
+import PendingIcon from '@cowprotocol/assets/cow-swap/spinner.svg'
+import LinkedIcon from '@cowprotocol/assets/images/icon-locked-2.svg'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+import SVG from 'react-inlinesvg'
+
+import { InputWrapper, StyledInput, TrailingIcon, TrailingIconPlaceholder } from './styles'
+
+export type TrailingIconKind = 'error' | 'lock' | 'pending' | 'success'
+
+type IconKind = Exclude
+type IconLabelMap = Partial>
+type IconTitleMap = Partial>
+
+const DEFAULT_ICON_LABELS: Record ReactNode> = {
+ lock: () => Linked,
+ pending: () => Pending,
+ success: () => Valid,
+}
+
+function getDefaultLoadingLabel(): ReactNode {
+ return Checking
+}
+
+interface TraderReferralCodeInputRowProps {
+ displayCode: string
+ hasError: boolean
+ isInputDisabled: boolean
+ isEditing: boolean
+ isLinked: boolean
+ trailingIconKind?: TrailingIconKind
+ canSubmitSave: boolean
+ onChange(event: FormEvent): void
+ onPrimaryClick(): void
+ onSave(): void
+ inputRef: RefObject
+ isLoading?: boolean
+ inputId?: string
+ placeholder?: string
+ maxLength?: number
+ size?: 'default' | 'compact'
+ trailingIconLabels?: IconLabelMap
+ trailingIconTitles?: IconTitleMap
+}
+
+export function TraderReferralCodeInputRow(props: TraderReferralCodeInputRowProps): ReactNode {
+ const {
+ displayCode,
+ hasError,
+ isInputDisabled,
+ isEditing,
+ trailingIconKind,
+ isLinked,
+ canSubmitSave,
+ onChange,
+ onPrimaryClick,
+ onSave,
+ inputRef,
+ isLoading = false,
+ inputId = 'referral-code-input',
+ placeholder = t`ENTER CODE`,
+ maxLength = 20,
+ size = 'default',
+ trailingIconLabels,
+ trailingIconTitles,
+ } = props
+
+ return (
+
+ {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+
+ if (canSubmitSave) {
+ onSave()
+ return
+ }
+
+ onPrimaryClick()
+ }
+ }}
+ />
+
+ {isLoading ? (
+
+ {renderIconWithLabel(
+ PendingIcon,
+ trailingIconTitles?.pending ?? getDefaultLoadingTitle(),
+ trailingIconLabels?.pending ?? getDefaultLoadingLabel(),
+ )}
+
+ ) : trailingIconKind ? (
+
+ {renderTrailingIcon(trailingIconKind, trailingIconLabels, trailingIconTitles)}
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function renderTrailingIcon(kind: TrailingIconKind, labels?: IconLabelMap, titles?: IconTitleMap): ReactNode {
+ if (kind === 'lock') {
+ return renderIconWithLabel(LinkedIcon, getIconTitle('lock', titles), getIconLabel('lock', labels))
+ }
+
+ if (kind === 'success') {
+ return renderIconWithLabel(CheckIcon, getIconTitle('success', titles), getIconLabel('success', labels))
+ }
+
+ if (kind === 'pending') {
+ return renderIconWithLabel(PendingIcon, getIconTitle('pending', titles), getIconLabel('pending', labels))
+ }
+
+ return
+}
+
+function renderIconWithLabel(src: string, title: string, label: ReactNode): ReactNode {
+ return (
+ <>
+
+ {label}
+ >
+ )
+}
+
+function getIconLabel(kind: IconKind, labels?: IconLabelMap): ReactNode {
+ return labels?.[kind] ?? DEFAULT_ICON_LABELS[kind]()
+}
+
+function getIconTitle(kind: IconKind, titles?: IconTitleMap): string {
+ return titles?.[kind] ?? getDefaultIconTitle(kind)
+}
+
+function getDefaultIconTitle(kind: IconKind): string {
+ if (kind === 'lock') {
+ return t`Linked`
+ }
+
+ if (kind === 'pending') {
+ return t`Pending`
+ }
+
+ return t`Valid`
+}
+
+function getDefaultLoadingTitle(): string {
+ return t`Checking`
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/index.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/index.ts
new file mode 100644
index 00000000000..632fac6bdf5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/index.ts
@@ -0,0 +1,3 @@
+export { TraderReferralCodeInputRow } from './TraderReferralCodeInputRow'
+export { TraderReferralCodeInputRow as PartnerReferralCodeInputRow } from './TraderReferralCodeInputRow'
+export type { TrailingIconKind } from './TraderReferralCodeInputRow'
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/styles.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/styles.ts
new file mode 100644
index 00000000000..a24c13eb8ef
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeInput/styles.ts
@@ -0,0 +1,165 @@
+import { Font, Media, UI } from '@cowprotocol/ui'
+
+import styled, { keyframes, css } from 'styled-components/macro'
+
+export const InputWrapper = styled.div<{
+ hasError?: boolean
+ disabled?: boolean
+ isEditing?: boolean
+ isLinked?: boolean
+ isLoading?: boolean
+ $size?: 'default' | 'compact'
+}>`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ border: 1px solid ${({ hasError }) => (hasError ? `var(${UI.COLOR_DANGER})` : `var(${UI.COLOR_BORDER})`)};
+ background: ${({ hasError, isEditing, isLinked }) =>
+ hasError
+ ? `var(${UI.COLOR_DANGER_BG})`
+ : isLinked
+ ? `var(${UI.COLOR_INFO_BG})`
+ : isEditing
+ ? `var(${UI.COLOR_PAPER})`
+ : `var(${UI.COLOR_PAPER_DARKER})`};
+ color: ${({ hasError, isLinked }) =>
+ hasError ? `var(${UI.COLOR_DANGER_TEXT})` : isLinked ? `var(${UI.COLOR_INFO_TEXT})` : `var(${UI.COLOR_TEXT})`};
+ border-radius: 9px;
+ padding: ${({ $size }) => ($size === 'compact' ? '10px 12px' : '12px 14px')};
+ transition: border 0.2s ease;
+ min-height: ${({ $size }) => ($size === 'compact' ? '48px' : '58px')};
+ position: relative;
+ overflow: hidden;
+
+ ${Media.upToSmall()} {
+ flex-flow: column wrap;
+ padding: 0;
+ gap: 0;
+ }
+
+ &:focus-within {
+ border-color: ${({ hasError }) => (hasError ? `var(${UI.COLOR_DANGER})` : `var(${UI.COLOR_PRIMARY_LIGHTER})`)};
+ }
+
+ ${({ isLoading, theme }) =>
+ isLoading &&
+ css`
+ input {
+ color: transparent;
+ text-shadow: 0 0 0 var(${UI.COLOR_TEXT});
+ }
+
+ &::after {
+ content: '';
+ ${theme.shimmer};
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ }
+ `}
+`
+
+export const StyledInput = styled.input<{ disabled?: boolean; $size?: 'default' | 'compact' }>`
+ flex: 1;
+ border: none;
+ background: transparent;
+ color: inherit;
+ position: relative;
+ width: 200px;
+ z-index: 1;
+ font-size: ${({ $size }) => ($size === 'compact' ? '16px' : '20px')};
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: uppercase;
+ font-family: ${Font.familyMono};
+ padding: 0;
+ margin: 0;
+ outline: none;
+ caret-color: var(${UI.COLOR_PRIMARY});
+
+ ${Media.upToSmall()} {
+ width: 100%;
+ padding: ${({ $size }) => ($size === 'compact' ? '10px' : '12px')};
+ min-height: ${({ $size }) => ($size === 'compact' ? '38px' : '42px')};
+ }
+
+ &:disabled {
+ color: inherit;
+ cursor: not-allowed;
+ }
+
+ &::placeholder {
+ color: var(${UI.COLOR_TEXT_OPACITY_50});
+ }
+
+ ${({ disabled }) => disabled && 'cursor: not-allowed;'}
+`
+
+const spin = keyframes`
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+`
+
+export const TrailingIcon = styled.div<{ kind: 'error' | 'lock' | 'pending' | 'success'; isSpinning?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ min-width: 96px;
+ flex: 0 0 auto;
+ position: relative;
+ z-index: 1;
+ font-size: 14px;
+ font-weight: 500;
+ color: ${({ kind }) =>
+ kind === 'error'
+ ? `var(${UI.COLOR_DANGER_TEXT})`
+ : kind === 'lock'
+ ? `var(${UI.COLOR_INFO_TEXT})`
+ : kind === 'pending'
+ ? `var(${UI.COLOR_ALERT_TEXT})`
+ : kind === 'success'
+ ? `var(${UI.COLOR_SUCCESS})`
+ : `var(${UI.COLOR_TEXT_OPACITY_70})`};
+
+ ${Media.upToSmall()} {
+ width: 100%;
+ background: var(${UI.COLOR_PAPER_DARKEST});
+ padding: 10px;
+ }
+
+ svg {
+ width: 18px;
+ height: 18px;
+ fill: ${({ kind }) => (kind === 'error' ? `var(${UI.COLOR_DANGER_TEXT})` : 'currentColor')};
+ }
+
+ svg > path {
+ fill: ${({ kind }) => (kind === 'error' ? `var(${UI.COLOR_DANGER_TEXT})` : 'currentColor')};
+ }
+
+ ${({ kind, isSpinning }) =>
+ kind === 'pending' &&
+ isSpinning &&
+ css`
+ svg {
+ animation: ${spin} 1.1s linear infinite;
+ }
+ `}
+`
+
+export const TrailingIconPlaceholder = styled.div`
+ min-width: 96px;
+ height: 18px;
+ flex: 0 0 auto;
+ visibility: hidden;
+
+ ${Media.upToSmall()} {
+ display: none;
+ }
+`
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal.tsx
new file mode 100644
index 00000000000..7ab5df1a750
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal.tsx
@@ -0,0 +1,50 @@
+import { ReactNode } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import { useWalletInfo } from '@cowprotocol/wallet'
+import { useWalletChainId } from '@cowprotocol/wallet-provider'
+
+import { useToggleWalletModal } from 'legacy/state/application/hooks'
+
+import { useNavigate } from 'common/hooks/useNavigate'
+import { CowModal } from 'common/pure/Modal'
+
+import { TraderReferralCodeModalContent } from './TraderReferralCodeModal/TraderReferralCodeModalContent'
+import { useTraderReferralCodeModalController } from './TraderReferralCodeModal/useTraderReferralCodeModalController'
+
+import { isSupportedReferralNetwork } from '../lib/affiliate-program-utils'
+import { useTraderReferralCodeActions } from '../model/hooks/useTraderReferralCodeActions'
+import { useTraderReferralCodeModalState } from '../model/hooks/useTraderReferralCodeModalState'
+
+export function TraderReferralCodeModal(): ReactNode {
+ const modalState = useTraderReferralCodeModalState()
+ const actions = useTraderReferralCodeActions()
+ const toggleWalletModal = useToggleWalletModal()
+ const { account } = useWalletInfo()
+ const chainId = useWalletChainId()
+ const navigate = useNavigate()
+ const analytics = useCowAnalytics()
+ const supportedNetwork = chainId === undefined ? true : isSupportedReferralNetwork(chainId)
+
+ const controller = useTraderReferralCodeModalController({
+ modalState,
+ actions,
+ account,
+ supportedNetwork,
+ toggleWalletModal,
+ navigate,
+ analytics,
+ })
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
new file mode 100644
index 00000000000..8fc9a45c86a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeForm.tsx
@@ -0,0 +1,318 @@
+import { FormEvent, ReactNode, RefObject } from 'react'
+
+import SaveIcon from '@cowprotocol/assets/images/icon-save.svg'
+import { Badge, HelpTooltip } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+import { Edit2 } from 'react-feather'
+import SVG from 'react-inlinesvg'
+
+import {
+ FormActions,
+ FormActionButton,
+ FormActionDanger,
+ FormGroup,
+ Label,
+ LabelAffordances,
+ LabelRow,
+ TagGroup,
+} from './styles'
+
+import { isReferralCodeLengthValid } from '../../lib/affiliate-program-utils'
+import { TraderReferralCodeModalUiState } from '../../model/hooks/useTraderReferralCodeModalState'
+import { TraderReferralCodeVerificationStatus } from '../../model/partner-trader-types'
+import { LabelContent } from '../shared'
+import { TraderReferralCodeInputRow, type TrailingIconKind } from '../TraderReferralCodeInput'
+
+const VERIFICATION_ERROR_KINDS: ReadonlySet = new Set([
+ 'invalid',
+ 'error',
+ 'ineligible',
+])
+
+export interface TraderReferralCodeFormProps {
+ uiState: TraderReferralCodeModalUiState
+ savedCode?: string
+ displayCode: string
+ verification: TraderReferralCodeVerificationStatus
+ onEdit(): void
+ onRemove(): void
+ onSave(): void
+ onChange(event: FormEvent): void
+ onPrimaryClick(): void
+ inputRef: RefObject
+}
+
+// eslint-disable-next-line max-lines-per-function
+export function TraderReferralCodeForm(props: TraderReferralCodeFormProps): ReactNode {
+ const {
+ uiState,
+ savedCode,
+ displayCode,
+ verification,
+ onEdit,
+ onRemove,
+ onSave,
+ onChange,
+ onPrimaryClick,
+ inputRef,
+ } = props
+
+ const showPendingLabelInInput = shouldShowPendingLabel(verification)
+ const showValidLabelInInput = verification.kind === 'valid'
+ const showPendingTag = verification.kind === 'pending' && !showPendingLabelInInput
+ const showValidTag = verification.kind === 'valid' && !showValidLabelInInput
+ const isChecking = verification.kind === 'checking'
+ const {
+ hasError,
+ isEditing,
+ isInputDisabled,
+ trailingIconKind,
+ isSaveDisabled,
+ showEdit,
+ showRemove,
+ showSave,
+ canSubmitSave,
+ isLinked,
+ } = deriveFormFlags({
+ uiState,
+ verification,
+ savedCode,
+ displayCode,
+ showPendingLabelInInput,
+ showValidLabelInInput,
+ })
+ const submitAction = canSubmitSave ? onSave : onPrimaryClick
+
+ const tooltipCopy = t`Referral codes contain 5-20 uppercase letters, numbers, dashes, or underscores`
+
+ return (
+ {
+ event.preventDefault()
+ submitAction()
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface DeriveFormFlagsParams {
+ uiState: TraderReferralCodeModalUiState
+ verification: TraderReferralCodeVerificationStatus
+ savedCode?: string
+ displayCode: string
+ showPendingLabelInInput: boolean
+ showValidLabelInInput: boolean
+}
+
+interface FormFlags {
+ hasError: boolean
+ isEditing: boolean
+ isInputDisabled: boolean
+ trailingIconKind: TrailingIconKind | undefined
+ isSaveDisabled: boolean
+ showEdit: boolean
+ showRemove: boolean
+ showSave: boolean
+ canSubmitSave: boolean
+ isLinked: boolean
+}
+
+function deriveFormFlags(params: DeriveFormFlagsParams): FormFlags {
+ // Split the boolean soup into small helpers so the main render stays readable
+ // and lint-compliant. This also makes the unsupported-network rules explicit.
+ const base = computeBaseFlags(params)
+ const trailingIconKind = resolveTrailingIconKind({
+ isLinked: base.isLinked,
+ hasError: base.hasError,
+ showPendingLabelInInput: params.showPendingLabelInInput,
+ showValidLabelInInput: params.showValidLabelInInput,
+ })
+
+ return {
+ ...base,
+ trailingIconKind,
+ }
+}
+
+interface BaseFlags {
+ hasError: boolean
+ isEditing: boolean
+ isInputDisabled: boolean
+ isSaveDisabled: boolean
+ showEdit: boolean
+ showRemove: boolean
+ showSave: boolean
+ canSubmitSave: boolean
+ isLinked: boolean
+ isUnsupported: boolean
+}
+
+// eslint-disable-next-line complexity
+function computeBaseFlags(params: DeriveFormFlagsParams): BaseFlags {
+ const { uiState, verification, savedCode, displayCode } = params
+
+ // Unsupported network deliberately hides every edit affordance to avoid no-op buttons.
+ const isUnsupported = uiState === 'unsupported'
+ const isEditing = uiState === 'editing' || uiState === 'invalid' || uiState === 'ineligible' || uiState === 'error'
+ const isLinked = uiState === 'linked'
+ const hasError = VERIFICATION_ERROR_KINDS.has(verification.kind)
+ const isInputDisabled = isUnsupported || (!isEditing && uiState !== 'empty')
+ const isSaveDisabled = !isReferralCodeLengthValid(displayCode || '')
+ const canEdit = !isUnsupported && !isEditing && !isLinked && uiState !== 'empty'
+ const showEdit = canEdit && Boolean(savedCode)
+ const showRemove = !isUnsupported && isEditing && Boolean(savedCode)
+ const showSave = !isUnsupported && (isEditing || uiState === 'empty')
+ const canSubmitSave = showSave && !isSaveDisabled
+
+ return {
+ hasError,
+ isEditing,
+ isInputDisabled,
+ isSaveDisabled,
+ showEdit,
+ showRemove,
+ showSave,
+ canSubmitSave,
+ isLinked,
+ isUnsupported,
+ }
+}
+
+interface TraderReferralCodeTagsProps {
+ showPendingTag: boolean
+ showValidTag: boolean
+}
+
+function TraderReferralCodeTags({ showPendingTag, showValidTag }: TraderReferralCodeTagsProps): ReactNode {
+ const showTags = showPendingTag || showValidTag
+
+ if (!showTags) {
+ return null
+ }
+
+ return (
+
+ {showPendingTag && {t`Pending`}}
+ {showValidTag && {t`Valid`}}
+
+ )
+}
+
+interface TraderReferralCodeActionsProps {
+ showEdit: boolean
+ showRemove: boolean
+ showSave: boolean
+ onEdit(): void
+ onRemove(): void
+ onSave(): void
+ isSaveDisabled: boolean
+}
+
+function TraderReferralCodeActions({
+ showEdit,
+ showRemove,
+ showSave,
+ onEdit,
+ onRemove,
+ onSave,
+ isSaveDisabled,
+}: TraderReferralCodeActionsProps): ReactNode {
+ if (!showEdit && !showRemove && !showSave) {
+ return null
+ }
+
+ return (
+
+ {showRemove && (
+
+ Remove
+
+ )}
+ {showSave && (
+
+
+ Save
+
+ )}
+ {showEdit && (
+
+
+ Edit
+
+ )}
+
+ )
+}
+
+function shouldShowPendingLabel(verification: TraderReferralCodeVerificationStatus): boolean {
+ return verification.kind === 'pending'
+}
+
+function resolveTrailingIconKind(params: {
+ isLinked: boolean
+ hasError: boolean
+ showPendingLabelInInput: boolean
+ showValidLabelInInput: boolean
+}): TrailingIconKind | undefined {
+ if (params.isLinked) {
+ return 'lock'
+ }
+
+ if (params.hasError) {
+ return 'error'
+ }
+
+ if (params.showPendingLabelInInput) {
+ return 'pending'
+ }
+
+ if (params.showValidLabelInInput) {
+ return 'success'
+ }
+
+ return undefined
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
new file mode 100644
index 00000000000..26ec94c5df2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeModalContent.tsx
@@ -0,0 +1,152 @@
+import { ReactNode } from 'react'
+
+import EARN_AS_TRADER_ILLUSTRATION from '@cowprotocol/assets/images/earn-as-trader.svg'
+import { ButtonPrimary, ModalHeader } from '@cowprotocol/ui'
+
+import { Trans } from '@lingui/react/macro'
+
+import { Body, Footer, ModalContainer, Subtitle, Title } from './styles'
+import { TraderReferralCodeForm } from './TraderReferralCodeForm'
+import { TraderReferralCodeStatusMessages, getModalTitle } from './TraderReferralCodeStatusMessages'
+import { TraderReferralCodeModalContentProps } from './types'
+
+import { getPartnerProgramCopyValues } from '../../lib/affiliate-program-utils'
+import { TraderReferralCodeModalUiState } from '../../model/hooks/useTraderReferralCodeModalState'
+import { TraderReferralCodeVerificationStatus } from '../../model/partner-trader-types'
+import { TraderReferralCodeHowItWorksLink, TraderReferralCodeIneligibleCopy } from '../TraderReferralCodeIneligibleCopy'
+
+export function TraderReferralCodeModalContent(props: TraderReferralCodeModalContentProps): ReactNode {
+ const { uiState, onPrimaryClick, primaryCta, onDismiss, inputRef, ctaRef, linkedMessage, hasRejection } = props
+ const shouldShowForm = uiState !== 'ineligible'
+
+ return (
+
+
+ {null}
+
+
+
+
+ {getModalTitle(uiState, { hasRejection })}
+
+ {shouldShowForm && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+interface TraderReferralCodeSubtitleProps {
+ uiState: TraderReferralCodeModalUiState
+ linkedMessage?: ReactNode
+ hasRejection: boolean
+ verification: TraderReferralCodeVerificationStatus
+ incomingIneligibleCode?: string
+ isConnected: boolean
+}
+
+function TraderReferralCodeSubtitle({
+ uiState,
+ linkedMessage,
+ hasRejection,
+ verification,
+ incomingIneligibleCode,
+ isConnected,
+}: TraderReferralCodeSubtitleProps): ReactNode {
+ const programParams = verification.kind === 'valid' ? verification.programParams : undefined
+ const programCopy = programParams ? getPartnerProgramCopyValues(programParams) : null
+ if ((uiState === 'linked' || hasRejection) && linkedMessage) {
+ return (
+
+ {linkedMessage}
+
+ )
+ }
+
+ if (uiState === 'ineligible') {
+ return (
+
+
+
+ )
+ }
+
+ const isPostValidation = uiState === 'valid'
+
+ return (
+
+ {isPostValidation ? (
+ <>
+ {programCopy ? (
+
+ Code binds on your first eligible trade. Earn {programCopy.rewardAmount} {programCopy.rewardCurrency} per{' '}
+ {programCopy.triggerVolume} eligible volume in {programCopy.timeCapDays} days. Payouts happen on Ethereum
+ mainnet.
+
+ ) : (
+
+ Code binds on your first eligible trade. Earn rewards for eligible volume within the program window.
+ Payouts happen on Ethereum mainnet.
+
+ )}{' '}
+
+ >
+ ) : (
+ <>
+ {programCopy ? (
+ isConnected ? (
+
+ Code binds on your first eligible trade. Earn {programCopy.rewardAmount} {programCopy.rewardCurrency}{' '}
+ per {programCopy.triggerVolume} eligible volume in {programCopy.timeCapDays} days. Payouts happen on
+ Ethereum mainnet.
+
+ ) : (
+
+ Connect to verify eligibility. Code binds on your first eligible trade. Earn {programCopy.rewardAmount}{' '}
+ {programCopy.rewardCurrency} per {programCopy.triggerVolume} eligible volume in{' '}
+ {programCopy.timeCapDays} days. Payouts happen on Ethereum mainnet.
+
+ )
+ ) : isConnected ? (
+
+ Code binds on your first eligible trade. Earn rewards for eligible volume within the program window.
+ Payouts happen on Ethereum mainnet.
+
+ ) : (
+
+ Connect to verify eligibility. Code binds on your first eligible trade. Earn rewards for eligible volume
+ within the program window. Payouts happen on Ethereum mainnet.
+
+ )}{' '}
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
new file mode 100644
index 00000000000..78612f8a2f1
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/TraderReferralCodeStatusMessages.tsx
@@ -0,0 +1,61 @@
+import { ReactNode } from 'react'
+
+import { StatusColorVariant } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+
+import { InlineAlert, StatusMessage, TitleAccent } from './styles'
+
+import { TraderReferralCodeModalUiState } from '../../model/hooks/useTraderReferralCodeModalState'
+
+export interface TraderReferralCodeStatusMessagesProps {
+ infoMessage: string
+ shouldShowInfo: boolean
+}
+
+export function TraderReferralCodeStatusMessages(props: TraderReferralCodeStatusMessagesProps): ReactNode {
+ const { infoMessage, shouldShowInfo } = props
+
+ return (
+
+ {shouldShowInfo && (
+
+ {infoMessage}
+
+ )}
+
+ )
+}
+
+export function getModalTitle(
+ uiState: TraderReferralCodeModalUiState,
+ options: { hasRejection?: boolean } = {},
+): ReactNode {
+ const { hasRejection = false } = options
+
+ if (uiState === 'linked' || (uiState === 'valid' && hasRejection)) {
+ return t`Already linked to a referral code`
+ }
+
+ if (uiState === 'valid') {
+ return (
+ <>
+ Referral code
+
+
+ successfully{' '}
+
+ applied!
+
+
+ >
+ )
+ }
+
+ if (uiState === 'ineligible') {
+ return t`Your wallet is ineligible`
+ }
+
+ return t`Enter referral code`
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/form.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/form.ts
new file mode 100644
index 00000000000..ab549b8ae01
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/form.ts
@@ -0,0 +1,72 @@
+import { UI, Badge } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+export const TagGroup = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ ${Badge} {
+ font-size: 11px;
+ padding: 4px 10px;
+ border-radius: 14px;
+ }
+`
+
+export const LabelAffordances = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`
+
+export const FormActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`
+
+export const FormActionButton = styled.button<{ variant: 'outline' | 'filled' }>`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(${UI.FONT_SIZE_SMALL});
+ color: ${({ variant }) => (variant === 'filled' ? `var(${UI.COLOR_INFO_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)};
+ background: ${({ variant }) => (variant === 'filled' ? `var(${UI.COLOR_INFO_BG})` : `var(${UI.COLOR_PAPER})`)};
+ border: ${({ variant }) => (variant === 'outline' ? `1px solid var(${UI.COLOR_BORDER})` : 'none')};
+ cursor: pointer;
+ padding: 4px 12px;
+ border-radius: 999px;
+ font-weight: 600;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &:hover:enabled {
+ opacity: 0.8;
+ }
+`
+
+export const FormActionDanger = styled.button`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(${UI.FONT_SIZE_SMALL});
+ font-weight: 600;
+ color: var(${UI.COLOR_DANGER_TEXT});
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &:hover:enabled {
+ opacity: 0.8;
+ }
+`
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/index.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/index.ts
new file mode 100644
index 00000000000..0000ffd517b
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/index.ts
@@ -0,0 +1,3 @@
+export * from './layout'
+export * from './form'
+export * from './status'
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/layout.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/layout.ts
new file mode 100644
index 00000000000..8fa1d32a334
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/layout.ts
@@ -0,0 +1,89 @@
+import { Media, UI } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+export const ModalContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-height: 100%;
+ background: var(${UI.COLOR_PAPER});
+ color: var(${UI.COLOR_TEXT_PAPER});
+`
+
+export const Body = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 10px 10px 0;
+ gap: 10px;
+ overflow-y: auto;
+
+ > img {
+ width: 100%;
+ max-width: 180px;
+ height: auto;
+ align-self: center;
+ }
+
+ ${Media.upToSmall()} {
+ > img {
+ max-width: 150px;
+ }
+ }
+`
+
+export const Title = styled.h2`
+ margin: 0 auto 16px;
+ width: 100%;
+ max-width: 75%;
+ padding: 0 10px;
+ font-size: 28px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+ text-align: center;
+
+ ${Media.upToSmall()} {
+ font-size: 24px;
+ max-width: 100%;
+ }
+`
+
+export const TitleAccent = styled.span`
+ color: var(${UI.COLOR_SUCCESS_TEXT});
+`
+
+export const Subtitle = styled.p`
+ margin: 0 auto 24px;
+ width: 100%;
+ max-width: 80%;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ text-align: center;
+
+ ${Media.upToSmall()} {
+ font-size: 14px;
+ max-width: 90%;
+ }
+`
+
+export const FormGroup = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`
+
+export const LabelRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 0 0 8px;
+ width: 100%;
+`
+
+export const Label = styled.label`
+ font-size: var(${UI.FONT_SIZE_NORMAL});
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+`
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/status.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/status.ts
new file mode 100644
index 00000000000..ec4ad2ae647
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/styles/status.ts
@@ -0,0 +1,38 @@
+import { UI, InlineBanner } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+export const StatusMessage = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin: 0 0 10px;
+`
+
+export const SpinnerRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 9px;
+ background: var(${UI.COLOR_NEUTRAL_98});
+ color: var(${UI.COLOR_TEXT});
+`
+
+export const Footer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 0 10px 10px;
+`
+
+export const InlineAlert = styled(InlineBanner)`
+ border-radius: 9px;
+ padding: 12px 16px;
+ text-align: center;
+`
+
+export const ErrorInline = styled(InlineBanner)`
+ border-radius: 9px;
+ padding: 12px 16px;
+`
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
new file mode 100644
index 00000000000..9ba5adb88a2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/traderReferralCodeModal.helpers.tsx
@@ -0,0 +1,224 @@
+import { ReactNode, RefObject, useEffect, useMemo, useRef } from 'react'
+
+import { CowAnalytics } from '@cowprotocol/analytics'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+
+import { PrimaryCta, StatusCopyResult } from './types'
+
+import {
+ useTraderReferralCodeModalState,
+ TraderReferralCodeModalUiState,
+} from '../../model/hooks/useTraderReferralCodeModalState'
+import {
+ TraderReferralCodeIncomingReason,
+ TraderReferralCodeVerificationStatus,
+ TraderWalletReferralCodeState,
+} from '../../model/partner-trader-types'
+
+type VerificationKind = ReturnType['verification']['kind']
+type WalletStatus = TraderWalletReferralCodeState['status']
+
+export function computePrimaryCta(params: {
+ uiState: TraderReferralCodeModalUiState
+ hasValidLength: boolean
+ hasCode: boolean
+ verification: TraderReferralCodeVerificationStatus
+ verificationKind: VerificationKind
+ walletStatus: WalletStatus
+}): PrimaryCta {
+ const { uiState, hasValidLength, hasCode, verification, verificationKind, walletStatus } = params
+
+ if (uiState === 'editing') {
+ return disabledCta(
+ hasValidLength && hasCode ? t`Save to verify code` : t`Enter a referral code with 5 to 20 characters`,
+ )
+ }
+
+ if (uiState === 'valid' || uiState === 'linked') {
+ return { label: t`View rewards`, disabled: false, action: 'viewRewards' }
+ }
+
+ if (uiState === 'ineligible') {
+ return { label: t`Go back`, disabled: false, action: 'goBack' }
+ }
+
+ if (uiState === 'error') {
+ const label =
+ verification.kind === 'error' && verification.message ? verification.message : t`Unable to verify code`
+ return { label, disabled: true, action: 'none' }
+ }
+
+ const staticDisabledLabels: Partial> = {
+ empty: t`Provide a valid referral code`,
+ unsupported: t`Unsupported Network`,
+ checking: t`Checking code…`,
+ invalid: t`This code is invalid. Try another.`,
+ }
+
+ const disabledLabel = staticDisabledLabels[uiState]
+
+ if (disabledLabel) {
+ return disabledCta(disabledLabel)
+ }
+
+ return verifyCta(hasValidLength, hasCode, verificationKind, walletStatus)
+}
+
+function disabledCta(label: string): PrimaryCta {
+ return { label, disabled: true, action: 'none' }
+}
+
+function verifyCta(
+ hasValidLength: boolean,
+ hasCode: boolean,
+ verificationKind: VerificationKind,
+ walletStatus: WalletStatus,
+): PrimaryCta {
+ if (walletStatus === 'unsupported') {
+ return disabledCta(t`Unsupported Network`)
+ }
+
+ const requiresConnection = walletStatus === 'disconnected' || walletStatus === 'unknown'
+
+ return {
+ label: requiresConnection ? t`Connect to verify code` : t`Verify code`,
+ disabled: !hasValidLength || !hasCode || verificationKind === 'checking',
+ action: 'verify',
+ }
+}
+
+export function getStatusCopy(
+ verification: TraderReferralCodeVerificationStatus,
+ timeCapDays?: number,
+): StatusCopyResult {
+ return {
+ shouldShowInfo: verification.kind === 'valid' && verification.eligible,
+ infoMessage: timeCapDays
+ ? t`Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for ${timeCapDays} days.`
+ : t`Your wallet is eligible for rewards. After your first trade, the referral code will bind and stay active for the entire program.`,
+ }
+}
+
+export function useTraderReferralCodeModalFocus(
+ isOpen: boolean,
+ uiState: TraderReferralCodeModalUiState,
+ inputRef: RefObject,
+ ctaRef: RefObject,
+): void {
+ useEffect(() => {
+ if (!isOpen) {
+ return
+ }
+
+ if (uiState === 'valid' || uiState === 'linked' || uiState === 'ineligible') {
+ ctaRef.current?.focus()
+ return
+ }
+
+ if (uiState === 'invalid' || uiState === 'editing' || uiState === 'empty') {
+ inputRef.current?.focus()
+ }
+ }, [ctaRef, inputRef, isOpen, uiState])
+}
+
+export function useTraderReferralCodeModalAnalytics(
+ traderReferralCode: ReturnType['traderReferralCode'],
+ uiState: TraderReferralCodeModalUiState,
+ analytics: CowAnalytics,
+): void {
+ const wasOpenRef = useRef(false)
+ const lastUiStateRef = useRef(null)
+
+ useEffect(() => {
+ if (traderReferralCode.modalOpen && !wasOpenRef.current) {
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'modal_opened',
+ label: traderReferralCode.modalSource ?? 'unknown',
+ })
+ }
+
+ wasOpenRef.current = traderReferralCode.modalOpen
+
+ if (!traderReferralCode.modalOpen) {
+ lastUiStateRef.current = null
+ }
+ }, [analytics, traderReferralCode.modalOpen, traderReferralCode.modalSource])
+
+ useEffect(() => {
+ if (!traderReferralCode.modalOpen) {
+ return
+ }
+
+ if (uiState === 'linked' && lastUiStateRef.current !== 'linked') {
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'view_linked',
+ label: traderReferralCode.modalSource ?? 'unknown',
+ })
+ }
+
+ if (uiState === 'ineligible' && lastUiStateRef.current !== 'ineligible') {
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'view_ineligible',
+ label: traderReferralCode.modalSource ?? 'unknown',
+ })
+ }
+
+ lastUiStateRef.current = uiState
+ }, [analytics, traderReferralCode.modalOpen, traderReferralCode.modalSource, uiState])
+}
+
+export function useTraderReferralCodeMessages(
+ codeForDisplay?: string,
+ reason?: TraderReferralCodeIncomingReason,
+): {
+ linkedMessage: ReactNode
+} {
+ return useMemo(() => {
+ if (!codeForDisplay) {
+ return {
+ linkedMessage: Your wallet is already linked to a referral code.,
+ }
+ }
+
+ return {
+ linkedMessage: (
+ <>
+
+ The code {codeForDisplay} from your link wasn’t applied.
+
+ {renderRejectionReason(reason)}
+ >
+ ),
+ }
+ }, [codeForDisplay, reason])
+}
+
+function renderRejectionReason(reason?: TraderReferralCodeIncomingReason): ReactNode {
+ if (!reason) {
+ return null
+ }
+
+ switch (reason) {
+ case 'invalid':
+ return (
+ <>
+ {' '}
+ It isn’t a valid referral code.
+ >
+ )
+ case 'ineligible':
+ return (
+ <>
+ {' '}
+ This wallet isn’t eligible for that code.
+ >
+ )
+ default:
+ return null
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/types.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/types.ts
new file mode 100644
index 00000000000..8cf805b4310
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/types.ts
@@ -0,0 +1,39 @@
+import { FormEvent, ReactNode, RefObject } from 'react'
+
+import { TraderReferralCodeModalUiState } from '../../model/hooks/useTraderReferralCodeModalState'
+import { TraderReferralCodeVerificationStatus } from '../../model/partner-trader-types'
+
+export type FocusableElement = HTMLElement | HTMLInputElement | HTMLButtonElement | null
+
+export interface PrimaryCta {
+ label: string
+ disabled: boolean
+ action: 'none' | 'verify' | 'viewRewards' | 'goBack'
+}
+
+export interface TraderReferralCodeModalContentProps {
+ uiState: TraderReferralCodeModalUiState
+ isConnected: boolean
+ savedCode?: string
+ displayCode: string
+ verification: TraderReferralCodeVerificationStatus
+ incomingIneligibleCode?: string
+ onPrimaryClick(): void
+ onEdit(): void
+ onRemove(): void
+ onSave(): void
+ onChange(event: FormEvent): void
+ primaryCta: PrimaryCta
+ linkedMessage: ReactNode
+ hasRejection: boolean
+ infoMessage: string
+ shouldShowInfo: boolean
+ inputRef: RefObject
+ ctaRef: RefObject
+ onDismiss(): void
+}
+
+export interface StatusCopyResult {
+ shouldShowInfo: boolean
+ infoMessage: string
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/useTraderReferralCodeModalController.ts b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/useTraderReferralCodeModalController.ts
new file mode 100644
index 00000000000..a450a082f7c
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeModal/useTraderReferralCodeModalController.ts
@@ -0,0 +1,320 @@
+import { FormEvent, RefObject, useCallback, useMemo, useRef } from 'react'
+
+import { CowAnalytics } from '@cowprotocol/analytics'
+
+import { Routes } from 'common/constants/routes'
+import { NavigateFunction } from 'common/hooks/useNavigate'
+
+import {
+ computePrimaryCta,
+ getStatusCopy,
+ useTraderReferralCodeMessages,
+ useTraderReferralCodeModalAnalytics,
+ useTraderReferralCodeModalFocus,
+} from './traderReferralCodeModal.helpers'
+import { FocusableElement, PrimaryCta, TraderReferralCodeModalContentProps } from './types'
+
+import { isReferralCodeLengthValid, getIncomingIneligibleCode } from '../../lib/affiliate-program-utils'
+import { useTraderReferralCodeActions } from '../../model/hooks/useTraderReferralCodeActions'
+import { useTraderReferralCodeModalState } from '../../model/hooks/useTraderReferralCodeModalState'
+import { TraderReferralCodeVerificationStatus } from '../../model/partner-trader-types'
+
+export interface TraderReferralCodeModalControllerParams {
+ modalState: ReturnType
+ actions: ReturnType
+ account?: string
+ supportedNetwork: boolean
+ toggleWalletModal: () => void
+ navigate: NavigateFunction
+ analytics: CowAnalytics
+}
+
+export interface TraderReferralCodeModalControllerResult {
+ traderReferralCode: ReturnType['traderReferralCode']
+ handleClose(): void
+ initialFocusRef: RefObject
+ contentProps: Omit
+}
+
+// eslint-disable-next-line complexity, max-lines-per-function
+export function useTraderReferralCodeModalController(
+ params: TraderReferralCodeModalControllerParams,
+): TraderReferralCodeModalControllerResult {
+ const { modalState, actions, account, supportedNetwork, toggleWalletModal, navigate, analytics } = params
+ const {
+ traderReferralCode,
+ uiState,
+ displayCode,
+ savedCode,
+ hasCode,
+ hasValidLength,
+ verification,
+ incomingCode,
+ wallet,
+ } = modalState
+
+ const inputRef = useRef(null)
+ const ctaRef = useRef(null)
+ const effectiveWalletStatus = supportedNetwork ? wallet.status : 'unsupported'
+
+ const primaryCta = useMemo(
+ () =>
+ computePrimaryCta({
+ uiState,
+ hasValidLength,
+ hasCode,
+ verification,
+ verificationKind: verification.kind,
+ walletStatus: effectiveWalletStatus,
+ }),
+ [effectiveWalletStatus, hasCode, hasValidLength, uiState, verification],
+ )
+
+ useTraderReferralCodeModalFocus(traderReferralCode.modalOpen, uiState, inputRef, ctaRef)
+ useTraderReferralCodeModalAnalytics(traderReferralCode, uiState, analytics)
+
+ const handlers = useTraderReferralCodeModalHandlers({
+ actions,
+ analytics,
+ account,
+ displayCode,
+ primaryCta,
+ toggleWalletModal,
+ navigate,
+ inputRef,
+ cancelVerification: traderReferralCode.cancelVerification,
+ verificationKind: verification.kind,
+ pendingVerificationId: traderReferralCode.pendingVerificationRequest?.id,
+ })
+
+ const timeCapDays = verification.kind === 'valid' ? verification.programParams?.timeCapDays : undefined
+ const statusCopy = getStatusCopy(verification, timeCapDays)
+ const verificationCode = 'code' in verification ? verification.code : undefined
+ const codeForDisplay = incomingCode || verificationCode || savedCode || displayCode
+ const incomingIneligibleCode = getIncomingIneligibleCode(incomingCode, verification)
+ const { linkedMessage } = useTraderReferralCodeMessages(codeForDisplay, traderReferralCode.incomingCodeReason)
+ const hasRejection = Boolean(traderReferralCode.incomingCodeReason)
+
+ const initialFocusRef =
+ uiState === 'valid' || uiState === 'linked' || uiState === 'ineligible'
+ ? (ctaRef as RefObject)
+ : (inputRef as RefObject)
+
+ return {
+ traderReferralCode,
+ handleClose: handlers.onClose,
+ initialFocusRef,
+ contentProps: {
+ uiState,
+ isConnected: Boolean(account),
+ savedCode,
+ displayCode,
+ verification,
+ incomingIneligibleCode,
+ onPrimaryClick: handlers.onPrimaryClick,
+ onEdit: handlers.onEdit,
+ onRemove: handlers.onRemove,
+ onSave: handlers.onSave,
+ onChange: handlers.onChange,
+ primaryCta,
+ linkedMessage,
+ hasRejection,
+ infoMessage: statusCopy.infoMessage,
+ shouldShowInfo: statusCopy.shouldShowInfo,
+ inputRef,
+ ctaRef,
+ },
+ }
+}
+
+interface TraderReferralCodeModalHandlersParams {
+ actions: ReturnType
+ analytics: CowAnalytics
+ account?: string
+ displayCode: string
+ primaryCta: PrimaryCta
+ toggleWalletModal: () => void
+ navigate: NavigateFunction
+ inputRef: RefObject
+ cancelVerification: () => void
+ verificationKind: TraderReferralCodeVerificationStatus['kind']
+ pendingVerificationId?: number
+}
+
+interface TraderReferralCodeModalHandlers {
+ onClose(): void
+ onEdit(): void
+ onRemove(): void
+ onSave(): void
+ onPrimaryClick(): void
+ onChange(event: FormEvent): void
+}
+
+function useTraderReferralCodeModalHandlers(
+ params: TraderReferralCodeModalHandlersParams,
+): TraderReferralCodeModalHandlers {
+ const {
+ actions,
+ analytics,
+ account,
+ displayCode,
+ primaryCta,
+ toggleWalletModal,
+ navigate,
+ inputRef,
+ cancelVerification,
+ verificationKind,
+ pendingVerificationId,
+ } = params
+
+ const focusInput = useFocusInputRef(inputRef)
+ const cancelInFlightVerification = useCancelVerificationHandler({
+ actions,
+ cancelVerification,
+ pendingVerificationId,
+ verificationKind,
+ })
+
+ const onClose = useCallback(() => {
+ cancelInFlightVerification()
+ actions.disableEditMode()
+ actions.closeModal()
+ }, [actions, cancelInFlightVerification])
+
+ const onEdit = useCallback(() => {
+ cancelInFlightVerification()
+ actions.enableEditMode()
+ focusInput()
+ }, [actions, cancelInFlightVerification, focusInput])
+
+ const onRemove = useCallback(() => {
+ cancelInFlightVerification()
+ actions.removeCode()
+ focusInput()
+ }, [actions, cancelInFlightVerification, focusInput])
+
+ const onSave = useCallback(() => {
+ if (!displayCode || !isReferralCodeLengthValid(displayCode)) {
+ return
+ }
+
+ cancelInFlightVerification()
+ actions.saveCode(displayCode)
+ analytics.sendEvent({
+ category: 'referral',
+ action: 'code_saved',
+ label: 'manual',
+ value: displayCode.length,
+ })
+ }, [actions, analytics, cancelInFlightVerification, displayCode])
+
+ const onPrimaryClick = usePrimaryClickHandler({
+ primaryCta,
+ account,
+ toggleWalletModal,
+ analytics,
+ actions,
+ displayCode,
+ navigate,
+ onClose,
+ })
+
+ const onChange = useCallback(
+ (event: FormEvent) => {
+ actions.setInputCode(event.currentTarget.value)
+ },
+ [actions],
+ )
+
+ return {
+ onClose,
+ onEdit,
+ onRemove,
+ onSave,
+ onPrimaryClick,
+ onChange,
+ }
+}
+
+function useFocusInputRef(inputRef: RefObject): () => void {
+ return useCallback(() => {
+ setTimeout(() => inputRef.current?.focus(), 0)
+ }, [inputRef])
+}
+
+function useCancelVerificationHandler(params: {
+ actions: ReturnType
+ cancelVerification: () => void
+ pendingVerificationId?: number
+ verificationKind: TraderReferralCodeVerificationStatus['kind']
+}): () => void {
+ const { actions, cancelVerification, pendingVerificationId, verificationKind } = params
+
+ return useCallback(() => {
+ cancelVerification()
+ actions.setShouldAutoVerify(false)
+
+ if (pendingVerificationId !== undefined) {
+ actions.clearPendingVerification(pendingVerificationId)
+ }
+
+ if (verificationKind === 'checking') {
+ actions.completeVerification({ kind: 'idle' })
+ }
+
+ actions.setIncomingCodeReason(undefined)
+ }, [actions, cancelVerification, pendingVerificationId, verificationKind])
+}
+
+function usePrimaryClickHandler(params: {
+ primaryCta: PrimaryCta
+ account?: string
+ toggleWalletModal: () => void
+ analytics: CowAnalytics
+ actions: ReturnType
+ displayCode: string
+ navigate: NavigateFunction
+ onClose: () => void
+}): () => void {
+ const { primaryCta, account, toggleWalletModal, analytics, actions, displayCode, navigate, onClose } = params
+
+ return useCallback(() => {
+ if (primaryCta.disabled) {
+ return
+ }
+
+ if (!account && primaryCta.action === 'verify') {
+ toggleWalletModal()
+ analytics.sendEvent({ category: 'referral', action: 'cta_clicked', label: 'connect_to_verify' })
+ return
+ }
+
+ if (primaryCta.action === 'verify') {
+ analytics.sendEvent({ category: 'referral', action: 'cta_clicked', label: 'connect_to_verify' })
+ actions.requestVerification(displayCode)
+ return
+ }
+
+ if (primaryCta.action === 'viewRewards') {
+ analytics.sendEvent({ category: 'referral', action: 'cta_clicked', label: 'view_rewards' })
+ onClose()
+ navigate(Routes.ACCOUNT_MY_REWARDS)
+ return
+ }
+
+ if (primaryCta.action === 'goBack') {
+ analytics.sendEvent({ category: 'referral', action: 'cta_clicked', label: 'go_back' })
+ onClose()
+ }
+ }, [
+ account,
+ actions,
+ analytics,
+ displayCode,
+ navigate,
+ onClose,
+ primaryCta.action,
+ primaryCta.disabled,
+ toggleWalletModal,
+ ])
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeNetworkBanner.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeNetworkBanner.tsx
new file mode 100644
index 00000000000..88726ab603d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/TraderReferralCodeNetworkBanner.tsx
@@ -0,0 +1,86 @@
+import { ReactNode } from 'react'
+
+import { Media, UI } from '@cowprotocol/ui'
+
+import { Trans } from '@lingui/react/macro'
+import { AlertCircle } from 'react-feather'
+import styled from 'styled-components/macro'
+
+import { AFFILIATE_SUPPORTED_NETWORK_NAMES } from '../config/constants'
+import { useTraderReferralCode } from '../model/hooks/useTraderReferralCode'
+
+const Wrapper = styled.div`
+ position: fixed;
+ right: 20px;
+ top: 100px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 18px;
+ max-width: 360px;
+ border-radius: 12px;
+ border: 1px solid var(${UI.COLOR_DANGER});
+ background: var(${UI.COLOR_DANGER_BG});
+ color: var(${UI.COLOR_DANGER_TEXT});
+ box-shadow: var(${UI.BOX_SHADOW});
+ backdrop-filter: blur(12px);
+ z-index: 30;
+
+ ${Media.upToMedium()} {
+ left: 10px;
+ right: 10px;
+ top: auto;
+ bottom: 80px;
+ width: auto;
+ }
+`
+
+const IconWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ > svg {
+ color: var(${UI.COLOR_DANGER});
+ }
+`
+
+const Message = styled.div`
+ font-size: var(${UI.FONT_SIZE_NORMAL});
+ line-height: 1.4;
+`
+
+export interface TraderReferralCodeNetworkBannerProps {
+ forceVisible?: boolean
+ onlyWhenUnsupported?: boolean
+}
+
+export function TraderReferralCodeNetworkBanner(props: TraderReferralCodeNetworkBannerProps): ReactNode {
+ const { forceVisible = false, onlyWhenUnsupported = false } = props
+ const { modalOpen, wallet } = useTraderReferralCode()
+
+ if (!forceVisible && !modalOpen) {
+ return null
+ }
+
+ const shouldShow = onlyWhenUnsupported
+ ? wallet.status === 'unsupported'
+ : wallet.status === 'unsupported' || wallet.status === 'unknown' || wallet.status === 'disconnected'
+
+ if (!shouldShow) {
+ return null
+ }
+
+ const supportedNetworks = AFFILIATE_SUPPORTED_NETWORK_NAMES.join(', ')
+
+ return (
+
+
+
+
+
+ Please connect your wallet to one of our supported networks: {supportedNetworks}.
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx b/apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
new file mode 100644
index 00000000000..3ad515d7d46
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/affiliate/ui/shared.tsx
@@ -0,0 +1,728 @@
+import type { ReactElement, ReactNode } from 'react'
+
+import { ButtonPrimary, ButtonSecondary, Font, HelpTooltip, LinkStyledButton, Media, UI } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+import styled from 'styled-components/macro'
+
+import { AFFILIATE_HOW_IT_WORKS_URL } from 'modules/affiliate/config/constants'
+
+import { Card, ExtLink } from 'pages/Account/styled'
+
+export type BadgeTone = 'neutral' | 'info' | 'success' | 'error'
+
+export const RewardsWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`
+
+export const Form = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`
+
+export const LabelRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+`
+
+export const Label = styled.label`
+ font-size: 14px;
+ color: var(${UI.COLOR_TEXT});
+`
+
+export const LabelActions = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+`
+
+export const MiniAction = styled.button`
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 2px 6px;
+ border-radius: 999px;
+ border: 1px solid var(${UI.COLOR_BORDER});
+ background: var(${UI.COLOR_PAPER});
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ text-transform: lowercase;
+
+ &:hover:not(:disabled) {
+ background: var(${UI.COLOR_PAPER_DARKER});
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+`
+
+export const HelperText = styled.span`
+ font-size: 13px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ line-height: 1.5;
+ text-align: center;
+`
+
+export const InlineError = styled.span`
+ font-size: 12px;
+ color: var(${UI.COLOR_DANGER_TEXT});
+`
+
+export const PrimaryAction = styled(ButtonPrimary)`
+ width: 100%;
+`
+
+export const RewardsGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+`
+
+export const RewardsThreeColumnGrid = styled(RewardsGrid)`
+ grid-template-columns: minmax(0, 2fr) minmax(0, 2.5fr) minmax(0, 1.5fr);
+
+ ${Media.upToMedium()} {
+ grid-template-columns: 1fr;
+ }
+`
+
+export const HeroCard = styled(Card)`
+ max-width: 520px;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+`
+
+export const HeroContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ align-items: center;
+`
+
+export const HeroTitle = styled.h2`
+ margin: 0;
+ width: 100%;
+ padding: 0 10px;
+ font-size: 28px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+ text-align: center;
+
+ ${Media.upToSmall()} {
+ font-size: 24px;
+ max-width: 100%;
+ }
+`
+
+export const HeroSubtitle = styled.p`
+ margin: 0;
+ width: 100%;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ text-align: center;
+
+ ${Media.upToSmall()} {
+ font-size: 14px;
+ max-width: 90%;
+ }
+
+ a {
+ color: var(${UI.COLOR_LINK});
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+`
+
+export const HeroActions = styled.div`
+ display: flex;
+ justify-content: center;
+ min-width: 320px;
+`
+
+export const Separator = styled.span`
+ opacity: 0.6;
+`
+
+const LinksRow = styled.div<{ $align: 'inline' | 'center' }>`
+ display: ${({ $align }) => ($align === 'center' ? 'flex' : 'inline-flex')};
+ align-items: center;
+ justify-content: ${({ $align }) => ($align === 'center' ? 'center' : 'flex-start')};
+ gap: 8px;
+ width: ${({ $align }) => ($align === 'center' ? '100%' : 'auto')};
+ font-size: 12px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+`
+
+export function AffiliateTermsFaqLinks({ align = 'inline' }: { align?: 'inline' | 'center' }): ReactElement {
+ return (
+
+
+ Terms
+
+ •
+
+ FAQ
+
+
+ )
+}
+
+export const InlineNote = styled.p`
+ margin: 0;
+ font-size: 12px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+`
+
+export const CardStack = styled(Card)`
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 24px;
+`
+
+export const RewardsCol1Card = styled(CardStack)`
+ grid-column: 1 / 2;
+ grid-row: 1;
+ align-items: center;
+
+ ${Media.upToMedium()} {
+ grid-column: 1 / -1;
+ grid-row: auto;
+ }
+`
+
+export const RewardsCol2Card = styled(CardStack)`
+ grid-column: 2 / 3;
+ grid-row: 1;
+
+ ${Media.upToMedium()} {
+ grid-column: 1 / -1;
+ grid-row: auto;
+ min-height: unset;
+ }
+`
+
+export const RewardsCol3Card = styled(CardStack)`
+ grid-column: 3 / 4;
+ grid-row: 1;
+ align-items: center;
+
+ ${Media.upToMedium()} {
+ grid-column: 1 / -1;
+ grid-row: auto;
+ min-height: unset;
+ }
+`
+
+export const CardTitle = styled.h4`
+ margin: 0;
+ font-size: 16px;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ font-weight: 600;
+`
+
+export const LinkedCard = styled.div`
+ border: 1px solid var(${UI.COLOR_INFO_BG});
+ background: var(${UI.COLOR_PAPER});
+ border-radius: 9px;
+ overflow: hidden;
+ width: 100%;
+`
+
+export const LinkedCodeRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(${UI.COLOR_INFO_BG});
+ color: var(${UI.COLOR_INFO_TEXT});
+`
+
+export const LinkedCodeText = styled.span`
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ font-size: 18px;
+ white-space: nowrap;
+ color: inherit;
+ font-family: ${Font.familyMono};
+`
+
+export const LinkedBadge = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 14px;
+`
+
+export const LinkedMetaList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+ width: 100%;
+`
+
+export const RewardsHeader = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`
+
+export const ValidStatusBadge = styled(LinkedBadge)`
+ color: var(${UI.COLOR_SUCCESS_TEXT});
+
+ svg {
+ fill: currentColor;
+ }
+`
+
+export const LinkedCopy = styled.div`
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+`
+
+export const LinkedLinkRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px 12px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ border-top: 1px solid var(${UI.COLOR_BORDER});
+`
+
+export const LinkedLinkText = styled.span`
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`
+
+export const LinkedActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+`
+
+export const LinkedFooter = styled.div`
+ margin-top: auto;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+`
+
+export const LinkedActionButton = styled(ButtonSecondary)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+ border-radius: 12px;
+ border: 1px solid var(${UI.COLOR_BORDER});
+ background: var(${UI.COLOR_PAPER});
+ color: var(${UI.COLOR_TEXT});
+ font-weight: 600;
+ font-size: 14px;
+ padding: 8px 14px;
+ // min-height: 36px;
+`
+
+export const LinkedActionIcon = styled.span`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: inherit;
+`
+
+export const LinkedHeader = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+`
+
+export const CodeBadge = styled.span`
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ color: var(${UI.COLOR_TEXT});
+ font-weight: 600;
+ font-size: 14px;
+`
+
+export const Badge = styled.span<{ $tone: BadgeTone }>`
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ background: ${({ $tone }) => {
+ switch ($tone) {
+ case 'success':
+ return `var(${UI.COLOR_SUCCESS_BG})`
+ case 'error':
+ return `var(${UI.COLOR_DANGER_BG})`
+ case 'info':
+ return `var(${UI.COLOR_PRIMARY_OPACITY_10})`
+ default:
+ return `var(${UI.COLOR_PAPER_DARKER})`
+ }
+ }};
+ color: ${({ $tone }) => {
+ switch ($tone) {
+ case 'success':
+ return `var(${UI.COLOR_SUCCESS_TEXT})`
+ case 'error':
+ return `var(${UI.COLOR_DANGER_TEXT})`
+ case 'info':
+ return `var(${UI.COLOR_INFO})`
+ default:
+ return `var(${UI.COLOR_TEXT})`
+ }
+ }};
+`
+
+export const InfoList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`
+
+export const InfoItem = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 13px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+
+ > span:last-child {
+ color: var(${UI.COLOR_TEXT});
+ }
+`
+
+export const InlineActions = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+`
+
+export const MetricsRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ width: 100%;
+`
+
+export const MetricsList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`
+
+export const MetaRow = styled.div`
+ margin: 0;
+ font-size: 12px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ span[title] {
+ cursor: help;
+ }
+`
+
+export const IneligibleCard = styled(Card)`
+ max-width: 520px;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 20px;
+ position: relative;
+`
+
+export const IneligibleTitle = styled.h3`
+ margin: 0;
+ font-size: 22px;
+ color: var(${UI.COLOR_TEXT});
+`
+
+export const IneligibleSubtitle = styled.p`
+ margin: 0;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ max-width: 520px;
+
+ strong {
+ color: var(${UI.COLOR_TEXT});
+ }
+`
+
+export const UnsupportedNetworkCard = styled(Card)`
+ min-height: 300px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+`
+
+export const UnsupportedNetworkHeader = styled.h3`
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 20px;
+ color: var(${UI.COLOR_DANGER});
+`
+
+export const UnsupportedNetworkMessage = styled.p`
+ margin: 0;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+`
+
+export const LinkedFooterNote = styled(MetaRow)`
+ width: 100%;
+ justify-content: center;
+ text-align: center;
+`
+
+export const StatusText = styled.p<{ $variant: 'error' | 'success' }>`
+ margin: 0;
+ font-size: 14px;
+ color: ${({ $variant }) => ($variant === 'error' ? `var(${UI.COLOR_DANGER_TEXT})` : `var(${UI.COLOR_SUCCESS_TEXT})`)};
+`
+
+export const PayoutValue = styled.div`
+ font-size: 26px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`
+
+export const BottomMetaRow = styled(MetaRow)`
+ margin-top: auto;
+`
+
+const USDC_LOGO_URL = 'https://files.cow.fi/token-lists/images/1/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48/logo.png'
+
+export const TitleWithTooltip = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+`
+
+const PayoutNote = styled(InlineNote)`
+ margin-top: auto;
+`
+
+type NextPayoutCardProps = {
+ payoutLabel: ReactNode
+ showLoader?: boolean
+}
+
+export function NextPayoutCard({ payoutLabel, showLoader = false }: NextPayoutCardProps): ReactElement {
+ return (
+
+
+
+
+ Next payout
+
+
+
+
+
+
+ {payoutLabel}
+
+
+ Paid weekly via airdrop.
+
+
+ )
+}
+
+export const DonutValue = styled.div``
+
+const DonutRing = styled.svg`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+ z-index: 0;
+ --radius: calc(50 - var(--stroke-width) / 2);
+
+ circle {
+ fill: none;
+ stroke-width: var(--stroke-width);
+ cx: 50;
+ cy: 50;
+ r: var(--radius);
+ }
+
+ .donut-track {
+ stroke: var(${UI.COLOR_TEXT_OPACITY_10});
+ }
+
+ .donut-progress {
+ stroke: var(${UI.COLOR_INFO});
+ stroke-linecap: round;
+ stroke-dasharray: 100;
+ stroke-dashoffset: calc(100 - var(--value));
+ }
+
+ .donut-center {
+ fill: var(${UI.COLOR_PAPER});
+ stroke: none;
+ r: calc(50 - var(--stroke-width));
+ }
+`
+
+const DonutWrapper = styled.div<{ $value: number }>`
+ --size: 139px;
+ --thickness: 20px;
+ --stroke-width: 14.4;
+ --value: ${({ $value }) => $value};
+ width: var(--size);
+ height: var(--size);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ flex: 0 0 auto;
+ box-shadow: 0px 2.67px 5.33px 0px rgba(0, 0, 0, 0.12) inset;
+
+ ${Media.upToMedium()} {
+ --size: 169px;
+ --thickness: 18px;
+ --stroke-width: 10.6;
+ margin: 0 auto;
+ }
+
+ > div {
+ position: relative;
+ z-index: 1;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ text-align: center;
+
+ small {
+ font-size: 15px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ font-weight: 400;
+ }
+ }
+
+ ${DonutValue} {
+ > span {
+ font-size: 24px;
+ }
+
+ small {
+ font-size: 15px;
+ font-weight: 400;
+ }
+ }
+`
+
+type DonutProps = {
+ $value: number
+ children: ReactNode
+}
+
+export function Donut({ $value, children }: DonutProps): ReactElement {
+ const hasProgress = $value > 0
+
+ return (
+
+
+
+ {hasProgress ? : null}
+
+
+ {children}
+
+ )
+}
+
+export const RewardsMetricsRow = styled(MetricsRow)`
+ justify-content: space-between;
+
+ ${Media.upToMedium()} {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+`
+
+export const RewardsMetricsList = styled(MetricsList)`
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: 420px;
+`
+
+export const MetricItem = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ font-size: 13px;
+ font-weight: 500;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+
+ strong {
+ color: var(${UI.COLOR_TEXT});
+ text-align: right;
+ white-space: nowrap;
+ font-weight: 500;
+ }
+`
+
+export const LabelContent = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: 0px;
+`
+
+export const HowItWorksLink = styled(LinkStyledButton)`
+ color: var(${UI.COLOR_LINK});
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`
diff --git a/apps/cowswap-frontend/src/modules/appData/updater/AppDataInfoUpdater.ts b/apps/cowswap-frontend/src/modules/appData/updater/AppDataInfoUpdater.ts
index 13a8437e737..cec7113d6e8 100644
--- a/apps/cowswap-frontend/src/modules/appData/updater/AppDataInfoUpdater.ts
+++ b/apps/cowswap-frontend/src/modules/appData/updater/AppDataInfoUpdater.ts
@@ -2,7 +2,7 @@ import { useSetAtom } from 'jotai'
import { useEffect, useRef } from 'react'
import { UtmParams } from '@cowprotocol/common-utils'
-import { CowEnv, SupportedChainId } from '@cowprotocol/cow-sdk'
+import { CowEnv } from '@cowprotocol/cow-sdk'
import { AppCodeWithWidgetMetadata } from 'modules/injectedWidget/hooks/useAppCodeWidgetAware'
@@ -14,7 +14,6 @@ import { getAppData } from '../utils/fullAppData'
export type UseAppDataParams = {
appCodeWithWidgetMetadata: AppCodeWithWidgetMetadata | null
- chainId: SupportedChainId
slippageBips: number
isSmartSlippage?: boolean
orderClass: AppDataOrderClass
@@ -23,6 +22,7 @@ export type UseAppDataParams = {
volumeFee?: AppDataPartnerFee
replacedOrderUid?: string
userConsent?: UserConsentsMetadata
+ referrerCode?: string
}
/**
@@ -33,7 +33,6 @@ export type UseAppDataParams = {
export function AppDataInfoUpdater({
appCodeWithWidgetMetadata,
- chainId,
slippageBips,
isSmartSlippage,
orderClass,
@@ -42,6 +41,7 @@ export function AppDataInfoUpdater({
volumeFee,
replacedOrderUid,
userConsent,
+ referrerCode,
}: UseAppDataParams): void {
// AppDataInfo, from Jotai
const setAppDataInfo = useSetAtom(appDataInfoAtom)
@@ -57,7 +57,6 @@ export function AppDataInfoUpdater({
const { appCode, environment, widget } = appCodeWithWidgetMetadata
const params: BuildAppDataParams = {
- chainId,
slippageBips,
isSmartSlippage,
appCode,
@@ -69,6 +68,7 @@ export function AppDataInfoUpdater({
widget,
replacedOrderUid,
userConsent,
+ referrerCode,
}
const updateAppData = async (): Promise => {
@@ -88,7 +88,6 @@ export function AppDataInfoUpdater({
updateAppDataPromiseRef.current = updateAppDataPromiseRef.current.finally(updateAppData)
}, [
appCodeWithWidgetMetadata,
- chainId,
setAppDataInfo,
slippageBips,
orderClass,
@@ -98,6 +97,7 @@ export function AppDataInfoUpdater({
replacedOrderUid,
isSmartSlippage,
userConsent,
+ referrerCode,
])
}
diff --git a/apps/cowswap-frontend/src/modules/appData/updater/AppDataUpdater.tsx b/apps/cowswap-frontend/src/modules/appData/updater/AppDataUpdater.tsx
index 509afd7a651..5e8ad3c347f 100644
--- a/apps/cowswap-frontend/src/modules/appData/updater/AppDataUpdater.tsx
+++ b/apps/cowswap-frontend/src/modules/appData/updater/AppDataUpdater.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import { useWalletInfo } from '@cowprotocol/wallet'
+import { useTraderReferralCode } from 'modules/affiliate/model/hooks/useTraderReferralCode'
import { useAppCodeWidgetAware } from 'modules/injectedWidget/hooks/useAppCodeWidgetAware'
import { useReplacedOrderUid } from 'modules/trade/state/alternativeOrder'
import { useUtm } from 'modules/utm'
@@ -30,13 +31,14 @@ export const AppDataUpdater = React.memo(({ slippageBips, isSmartSlippage, order
const volumeFee = useVolumeFee()
const replacedOrderUid = useReplacedOrderUid()
const userConsent = useRwaConsentForAppData()
+ const traderReferralCode = useTraderReferralCode()
+ const referrerCode = getReferrerCode(traderReferralCode)
if (!chainId) return null
return (
)
})
@@ -55,3 +58,19 @@ const AppDataUpdaterMemo = React.memo((params: UseAppDataParams) => {
return null
})
+
+function getReferrerCode(traderReferralCode: ReturnType): string | undefined {
+ if (traderReferralCode.wallet.status === 'linked') {
+ return traderReferralCode.wallet.code
+ }
+
+ if (traderReferralCode.verification.kind === 'linked') {
+ return traderReferralCode.verification.linkedCode
+ }
+
+ if (traderReferralCode.verification.kind === 'valid' && traderReferralCode.savedCode) {
+ return traderReferralCode.savedCode
+ }
+
+ return undefined
+}
diff --git a/apps/cowswap-frontend/src/modules/appData/utils/buildAppData.ts b/apps/cowswap-frontend/src/modules/appData/utils/buildAppData.ts
index 2640b2c275a..6de562acf3f 100644
--- a/apps/cowswap-frontend/src/modules/appData/utils/buildAppData.ts
+++ b/apps/cowswap-frontend/src/modules/appData/utils/buildAppData.ts
@@ -1,5 +1,5 @@
import { UtmParams } from '@cowprotocol/common-utils'
-import { stringifyDeterministic, SupportedChainId } from '@cowprotocol/cow-sdk'
+import { stringifyDeterministic } from '@cowprotocol/cow-sdk'
import { metadataApiSDK } from 'cowSdk'
@@ -19,14 +19,20 @@ import {
TypedAppDataHooks,
} from '../types'
+const REFERRER_CODE_PATTERN = /^[A-Z0-9_-]{5,20}$/
+
+function normalizeReferrerCode(value: string): string | undefined {
+ const normalized = value.trim().toUpperCase()
+ return REFERRER_CODE_PATTERN.test(normalized) ? normalized : undefined
+}
+
export type BuildAppDataParams = {
appCode: string
environment?: string
- chainId: SupportedChainId
slippageBips: number
isSmartSlippage?: boolean
orderClass: AppDataOrderClass
- referrerAccount?: string
+ referrerCode?: string
utm: UtmParams | undefined
typedHooks?: TypedAppDataHooks
widget?: AppDataWidget
@@ -44,10 +50,9 @@ async function generateAppDataFromDoc(
}
export async function buildAppData({
- chainId,
slippageBips,
isSmartSlippage,
- referrerAccount,
+ referrerCode,
appCode,
environment,
orderClass: orderClassName,
@@ -58,7 +63,11 @@ export async function buildAppData({
replacedOrderUid,
userConsent,
}: BuildAppDataParams): Promise {
- const referrerParams = referrerAccount && chainId === SupportedChainId.MAINNET ? { code: referrerAccount } : undefined
+ const normalizedReferrerCode = referrerCode ? normalizeReferrerCode(referrerCode) : undefined
+
+ const referrerParams: AppDataRootSchema['metadata']['referrer'] = normalizedReferrerCode
+ ? { code: normalizedReferrerCode }
+ : undefined
const quoteParams = {
slippageBips,
diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx
index 62ab53a2453..c23e60b8c09 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/App/RoutesApp.tsx
@@ -9,6 +9,7 @@ import {
DUNE_DASHBOARD_LINK,
TWITTER_LINK,
} from '@cowprotocol/common-const'
+import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { Navigate, Route, Routes } from 'react-router'
@@ -42,6 +43,8 @@ const LegalExternal =
// Account
const AccountTokensOverview = lazy(() => import(/* webpackChunkName: "tokens_overview" */ 'pages/Account/Tokens'))
+const AccountAffiliate = lazy(() => import(/* webpackChunkName: "affiliate" */ 'pages/Account/Affiliate'))
+const AccountMyRewards = lazy(() => import(/* webpackChunkName: "rewards" */ 'pages/Account/MyRewards'))
const AccountNotFound = lazy(() => import(/* webpackChunkName: "affiliate" */ 'pages/error/NotFound'))
function ExternalRedirect({ url }: { url: string }): null {
@@ -77,12 +80,16 @@ const lazyRoutes: LazyRouteProps[] = [
]
export function RoutesApp(): ReactNode {
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
+
return (
{/*Account*/}
}>
} />
} />
+ {isAffiliateProgramEnabled && } />}
+ {isAffiliateProgramEnabled && } />}
} />
diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
index e6b35721903..6c017870b0a 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
@@ -15,7 +15,7 @@ import { Routes } from 'common/constants/routes'
export const PRODUCT_VARIANT = ProductVariant.CowSwap
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-const ACCOUNT_ITEM = (chainId: SupportedChainId) => ({
+const ACCOUNT_ITEM = (chainId: SupportedChainId, isAffiliateProgramEnabled: boolean) => ({
label: msg`Account`,
children: [
{
@@ -30,6 +30,18 @@ const ACCOUNT_ITEM = (chainId: SupportedChainId) => ({
href: `/${chainId}/account-proxy`,
label: ACCOUNT_PROXY_LABEL,
},
+ ...(isAffiliateProgramEnabled
+ ? [
+ {
+ href: Routes.ACCOUNT_MY_REWARDS,
+ label: msg`My rewards`,
+ },
+ {
+ href: Routes.ACCOUNT_AFFILIATE,
+ label: msg`Affiliate`,
+ },
+ ]
+ : []),
],
})
@@ -106,8 +118,8 @@ const MORE_ITEM = {
],
}
-export const NAV_ITEMS = (chainId: SupportedChainId): MenuItem[] => {
- const _ACCOUNT_ITEM = ACCOUNT_ITEM(chainId)
+export const NAV_ITEMS = (chainId: SupportedChainId, isAffiliateProgramEnabled: boolean): MenuItem[] => {
+ const _ACCOUNT_ITEM = ACCOUNT_ITEM(chainId, isAffiliateProgramEnabled)
const accountItem: MenuItem = {
label: i18n._(_ACCOUNT_ITEM.label),
children: _ACCOUNT_ITEM.children.map(({ href, label }) => ({
diff --git a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/index.tsx b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/index.tsx
index 8fec925307d..1ec26b43a67 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/index.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/index.tsx
@@ -12,6 +12,11 @@ import { URLWarning } from 'legacy/components/Header/URLWarning'
import { useDarkModeManager } from 'legacy/state/user/hooks'
import { OrdersPanel } from 'modules/account'
+import { TraderReferralCodeController } from 'modules/affiliate/model/containers/TraderReferralCodeController'
+import { TraderReferralCodeDeepLinkHandler } from 'modules/affiliate/model/containers/TraderReferralCodeDeepLinkHandler'
+import { TraderReferralCodeProvider } from 'modules/affiliate/model/state/TraderReferralCodeContext'
+import { TraderReferralCodeModal } from 'modules/affiliate/ui/TraderReferralCodeModal'
+import { TraderReferralCodeNetworkBanner } from 'modules/affiliate/ui/TraderReferralCodeNetworkBanner'
import { useInjectedWidgetMetaData } from 'modules/injectedWidget'
import { useInitializeUtm } from 'modules/utm'
@@ -43,6 +48,7 @@ export function AppContainer({ children }: AppContainerProps): ReactNode {
const { walletName } = useWalletDetails()
const cowAnalytics = useCowAnalytics()
const webVitals = useMemo(() => new WebVitalsAnalytics(cowAnalytics), [cowAnalytics])
+ const { isYieldEnabled, isAffiliateProgramEnabled = false } = useFeatureFlags()
useAnalyticsReporter({
account,
@@ -56,8 +62,6 @@ export function AppContainer({ children }: AppContainerProps): ReactNode {
})
useInitializeUtm()
-
- const { isYieldEnabled } = useFeatureFlags()
const isInjectedWidgetMode = isInjectedWidget()
const [darkMode] = useDarkModeManager()
const [pageBackgroundVariant, setPageBackgroundVariant] = useState('default')
@@ -87,7 +91,7 @@ export function AppContainer({ children }: AppContainerProps): ReactNode {
})
const showSnowfall = !isInjectedWidgetMode && isChristmasTheme
- return (
+ const appContent = (
@@ -116,6 +120,22 @@ export function AppContainer({ children }: AppContainerProps): ReactNode {
)
+
+ if (!isAffiliateProgramEnabled) {
+ return appContent
+ }
+
+ return (
+
+ <>
+
+
+
+
+ {appContent}
+ >
+
+ )
}
interface CowSpeechBubbleVisibilityParams {
diff --git a/apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx b/apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx
index 71a39813f7a..d251f2dbc15 100644
--- a/apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx
+++ b/apps/cowswap-frontend/src/modules/application/containers/AppMenu/index.tsx
@@ -1,7 +1,7 @@
import { PropsWithChildren, ReactNode, useMemo } from 'react'
import { SUPPORTED_LOCALES } from '@cowprotocol/common-const'
-import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { useFeatureFlags, useMediaQuery } from '@cowprotocol/common-hooks'
import { isInjectedWidget } from '@cowprotocol/common-utils'
import { Color, MenuBar, type CowSwapTheme } from '@cowprotocol/ui'
import { useWalletInfo } from '@cowprotocol/wallet'
@@ -42,6 +42,7 @@ export function AppMenu({ children, customTheme: overriddenCustomTheme }: AppMen
const { chainId } = useWalletInfo()
const isInjectedWidgetMode = isInjectedWidget()
const menuItems = useMenuItems()
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
const [darkMode, toggleDarkMode] = useDarkModeManager()
const { setLocale } = useUserLocaleManager()
const isMobile = useMediaQuery(isMobileQuery(false))
@@ -89,9 +90,9 @@ export function AppMenu({ children, customTheme: overriddenCustomTheme }: AppMen
}
}),
},
- ...NAV_ITEMS(chainId),
+ ...NAV_ITEMS(chainId, isAffiliateProgramEnabled),
]
- }, [t, menuItems, chainId, getTradeUrlParams])
+ }, [t, menuItems, chainId, getTradeUrlParams, isAffiliateProgramEnabled])
if (isInjectedWidgetMode) return null
diff --git a/apps/cowswap-frontend/src/modules/bridge/pure/contents/QuoteSwapContent/index.tsx b/apps/cowswap-frontend/src/modules/bridge/pure/contents/QuoteSwapContent/index.tsx
index 78856d075a0..20aef47765d 100644
--- a/apps/cowswap-frontend/src/modules/bridge/pure/contents/QuoteSwapContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/bridge/pure/contents/QuoteSwapContent/index.tsx
@@ -10,7 +10,7 @@ import { Trans } from '@lingui/react/macro'
import { ProxyRecipient } from 'modules/accountProxy'
import { ReceiveAmountTitle, TradeFeesAndCosts, ConfirmDetailsItem } from 'modules/trade'
import { BRIDGE_QUOTE_ACCOUNT } from 'modules/tradeQuote'
-import { RowSlippage } from 'modules/tradeWidgetAddons'
+import { RowRewards, RowSlippage, useIsRowRewardsVisible } from 'modules/tradeWidgetAddons'
import { QuoteSwapContext } from '../../../types'
import { ProxyAccountBanner } from '../../ProxyAccountBanner'
@@ -111,6 +111,13 @@ function createMinReceiveContent(
}
}
+function createRewardsContent(): ContentItem {
+ return {
+ withTimelineDot: true,
+ content: ,
+ }
+}
+
export function QuoteSwapContent({ context, hideRecommendedSlippage }: QuoteDetailsContentProps): ReactNode {
const {
receiveAmountInfo,
@@ -125,10 +132,12 @@ export function QuoteSwapContent({ context, hideRecommendedSlippage }: QuoteDeta
isSlippageModified,
} = context
const isBridgeQuoteRecipient = recipient === BRIDGE_QUOTE_ACCOUNT
+ const isRowRewardsVisible = useIsRowRewardsVisible()
const contents = [
createExpectedReceiveContent(expectedReceive, expectedReceiveUsdValue, slippage),
createSlippageContent(slippage, !!hideRecommendedSlippage, isSlippageModified),
!isBridgeQuoteRecipient && createRecipientContent(recipient, bridgeReceiverOverride, sellAmount.currency.chainId),
+ !isBridgeQuoteRecipient && isRowRewardsVisible && createRewardsContent(),
createMinReceiveContent(minReceiveAmount, minReceiveUsdValue),
]
diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx
index 17efa82dbde..934a91fe15d 100644
--- a/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx
+++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/TradeRateDetails/index.tsx
@@ -5,6 +5,7 @@ import { t } from '@lingui/core/macro'
import { TradeFees, TradeTotalCostsDetails } from 'modules/trade'
import { Box } from 'modules/trade/containers/TradeTotalCostsDetails/styled'
import { useTradeQuote, useTradeQuoteProtocolFee } from 'modules/tradeQuote'
+import { RowRewards } from 'modules/tradeWidgetAddons'
import { useUsdAmount } from 'modules/usdAmount'
import { useVolumeFee, useVolumeFeeTooltip } from 'modules/volumeFee'
@@ -50,7 +51,12 @@ export function TradeRateDetails({ rateInfoParams, alwaysExpanded = false }: Tra
)
if (!rateInfoParams) {
- return tradeFees
+ return (
+ <>
+ {tradeFees}
+
+ >
+ )
}
if (alwaysExpanded) {
@@ -64,7 +70,10 @@ export function TradeRateDetails({ rateInfoParams, alwaysExpanded = false }: Tra
fontSize={13}
fontBold
/>
- {tradeFees}
+
+ {tradeFees}
+
+
>
)
}
@@ -77,6 +86,7 @@ export function TradeRateDetails({ rateInfoParams, alwaysExpanded = false }: Tra
toggleAccordion={toggleAccordion}
>
{tradeFees}
+
)
}
diff --git a/apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx
index 402c9998f08..317ce88960a 100644
--- a/apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/swap/containers/SwapConfirmModal/index.tsx
@@ -29,7 +29,7 @@ import {
useCommonTradeConfirmContext,
} from 'modules/trade'
import { useTradeQuote } from 'modules/tradeQuote'
-import { HighFeeWarning, RowDeadline } from 'modules/tradeWidgetAddons'
+import { HighFeeWarning, RowDeadline, RowRewards, useIsRowRewardsVisible } from 'modules/tradeWidgetAddons'
import { useRateInfoParams } from 'common/hooks/useRateInfoParams'
import { CurrencyPreviewInfo } from 'common/pure/CurrencyAmountPreview'
@@ -78,6 +78,7 @@ export function SwapConfirmModal(props: SwapConfirmModalProps): ReactNode {
const rateInfoParams = useRateInfoParams(inputCurrencyInfo.amount, outputCurrencyInfo.amount)
const submittedContent =
const labelsAndTooltips = useLabelsAndTooltips()
+ const isRowRewardsVisible = useIsRowRewardsVisible()
const { values: balances } = useTokensBalancesCombined()
@@ -168,7 +169,10 @@ export function SwapConfirmModal(props: SwapConfirmModalProps): ReactNode {
hideUsdValues
withTimelineDot={false}
>
-
+ <>
+ {isRowRewardsVisible && }
+
+ >
)}
{restContent}
diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useResetStateWithSymbolDuplication.ts b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useResetStateWithSymbolDuplication.ts
index 566dfb6df96..19ffeed7b14 100644
--- a/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useResetStateWithSymbolDuplication.ts
+++ b/apps/cowswap-frontend/src/modules/trade/hooks/setupTradeState/useResetStateWithSymbolDuplication.ts
@@ -3,7 +3,7 @@ import { useEffect } from 'react'
import { useAreThereTokensWithSameSymbol } from '@cowprotocol/tokens'
import { useWalletInfo } from '@cowprotocol/wallet'
-import { t } from '@lingui/macro'
+import { t } from '@lingui/core/macro'
import { Nullish } from 'types'
import { getDefaultTradeRawState, TradeRawState } from '../../types/TradeRawState'
diff --git a/apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx b/apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx
index a39b1760409..1eafcbfc335 100644
--- a/apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx
@@ -2,7 +2,7 @@ import { ReactNode } from 'react'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
-import { t } from '@lingui/macro'
+import { t } from '@lingui/core/macro'
import { Nullish } from 'types'
import { ReviewOrderModalAmountRow } from '../ReviewOrderModalAmountRow'
diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx
new file mode 100644
index 00000000000..0ad0c4f95c4
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/RowRewards/index.tsx
@@ -0,0 +1,78 @@
+import { ReactNode } from 'react'
+
+import { useFeatureFlags } from '@cowprotocol/common-hooks'
+
+import { Trans } from '@lingui/react/macro'
+
+import { useTraderReferralCode } from 'modules/affiliate/model/hooks/useTraderReferralCode'
+import { useTraderReferralCodeActions } from 'modules/affiliate/model/hooks/useTraderReferralCodeActions'
+
+import { RowRewardsContent } from '../../pure/Row/RowRewards'
+
+export function useIsRowRewardsVisible(): boolean {
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
+ return isAffiliateProgramEnabled
+}
+
+export function RowRewards(): ReactNode {
+ const isRowRewardsVisible = useIsRowRewardsVisible()
+ const traderReferralCode = useTraderReferralCode()
+ const traderReferralCodeActions = useTraderReferralCodeActions()
+
+ const linkedCode = getLinkedCode(traderReferralCode)
+ const hasLinkedCode = Boolean(linkedCode)
+ const hasSavedValidCode = shouldShowSavedCode(traderReferralCode, hasLinkedCode)
+ const displayCode = linkedCode ?? (hasSavedValidCode ? traderReferralCode.savedCode : undefined)
+ const tooltipContent = getTooltipContent(hasLinkedCode, hasSavedValidCode)
+ const handleOpenModal = (): void => {
+ traderReferralCodeActions.openModal('rewards')
+ }
+
+ if (!isRowRewardsVisible) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+function getLinkedCode(traderReferralCode: ReturnType): string | undefined {
+ if (traderReferralCode.wallet.status === 'linked') {
+ return traderReferralCode.wallet.code
+ }
+
+ if (traderReferralCode.verification.kind === 'linked') {
+ return traderReferralCode.verification.linkedCode
+ }
+
+ return undefined
+}
+
+function shouldShowSavedCode(
+ traderReferralCode: ReturnType,
+ hasLinkedCode: boolean,
+): boolean {
+ if (hasLinkedCode) {
+ return false
+ }
+
+ return traderReferralCode.verification.kind === 'valid' && Boolean(traderReferralCode.savedCode)
+}
+
+function getTooltipContent(hasLinkedCode: boolean, hasSavedValidCode: boolean): ReactNode {
+ if (hasLinkedCode) {
+ return Your wallet is linked to this referral code.
+ }
+
+ if (hasSavedValidCode) {
+ return Your referral code is saved. It will link after your first eligible trade.
+ }
+
+ return Earn more by adding a referral code.
+}
diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/TradeRateDetails/index.tsx b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/TradeRateDetails/index.tsx
index aaa2c2bff99..daadb8c1bbf 100644
--- a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/TradeRateDetails/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/TradeRateDetails/index.tsx
@@ -23,6 +23,7 @@ import { RateInfoParams } from 'common/pure/RateInfo'
import { NetworkCostsTooltipSuffix } from '../../pure/NetworkCostsTooltipSuffix'
import { RowDeadline } from '../RowDeadline'
+import { RowRewards } from '../RowRewards'
import { RowSlippage } from '../RowSlippage'
interface TradeRateDetailsProps {
@@ -99,6 +100,7 @@ export function TradeRateDetails({
/>
{/* Always show slippage inside accordion */}
{slippageRow}
+
>
)
diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/index.ts b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/index.ts
index 6555e3aebc5..df6204132c0 100644
--- a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/index.ts
+++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/index.ts
@@ -1,6 +1,7 @@
export { RowDeadline } from './containers/RowDeadline'
export { RowSlippage } from './containers/RowSlippage'
export { TradeRateDetails } from './containers/TradeRateDetails'
+export { RowRewards, useIsRowRewardsVisible } from './containers/RowRewards'
export { SettingsTab } from './containers/SettingsTab'
export { HighFeeWarning } from './containers/HighFeeWarning'
export { MetamaskTransactionWarning } from './containers/MetamaskTransactionWarning'
diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx
new file mode 100644
index 00000000000..e1f0efcefae
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowRewards/index.tsx
@@ -0,0 +1,80 @@
+import { ReactNode } from 'react'
+
+import { HoverTooltip, LinkStyledButton, RowFixed, UI } from '@cowprotocol/ui'
+
+import { Trans } from '@lingui/react/macro'
+
+import { RowStyleProps, StyledInfoIcon, StyledRowBetween, TextWrapper } from '../styled'
+
+export interface RowRewardsContentProps {
+ onAddCode?: () => void
+ onManageCode?: () => void
+ tooltipContent?: ReactNode
+ linkedCode?: string
+ accountLink?: string
+ styleProps?: RowStyleProps
+}
+
+export function RowRewardsContent(props: RowRewardsContentProps): ReactNode {
+ const { onAddCode, onManageCode, tooltipContent, linkedCode, accountLink, styleProps } = props
+ const tooltip = tooltipContent ?? Add a referral code to earn rewards.
+
+ const renderAction = (): ReactNode => {
+ if (!linkedCode) {
+ return (
+
+ Add code
+
+ )
+ }
+
+ if (onManageCode) {
+ return (
+
+ {linkedCode}
+
+ )
+ }
+
+ return (
+
+ {linkedCode}
+
+ )
+ }
+
+ return (
+
+
+
+ Rewards code
+
+
+
+
+
+ {renderAction()}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx b/apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
index 6d7f75b58c6..7dc31d3b5c0 100644
--- a/apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/twap/containers/TwapConfirmModal/index.tsx
@@ -15,6 +15,7 @@ import {
} from 'modules/trade'
import { TradeBasicConfirmDetails } from 'modules/trade/containers/TradeBasicConfirmDetails'
import { DividerHorizontal } from 'modules/trade/pure/Row/styled'
+import { RowRewards, useIsRowRewardsVisible } from 'modules/tradeWidgetAddons'
import { useRateInfoParams } from 'common/hooks/useRateInfoParams'
import { NetworkCostsSuffix } from 'common/pure/NetworkCostsSuffix'
@@ -99,6 +100,7 @@ export function TwapConfirmModal() {
const priceImpact = useTradePriceImpact()
const fallbackHandlerIsNotSet = useIsFallbackHandlerRequired()
+ const isRowRewardsVisible = useIsRowRewardsVisible()
const inputCurrencyInfo = {
amount: inputCurrencyAmount,
@@ -161,6 +163,7 @@ export function TwapConfirmModal() {
}}
/>
)}
+ {isRowRewardsVisible && }
{!isWrapOrUnwrap && (
-
-
+ <>
+
-
-
+ {isRowRewardsVisible && }
+
+ >
)}
= {
+ black: { label: 'Black', fg: '#111111', bg: '#FFFFFF' },
+ white: { label: 'White', fg: '#FFFFFF', bg: '#111111' },
+ accent: { label: 'Accent', fg: '#1f5bd6', bg: '#FFFFFF' },
+}
+
+const QR_LOGOS: Record = {
+ black: COW_LOGO_BLACK,
+ white: COW_LOGO_WHITE,
+ accent: COW_LOGO_ACCENT,
+}
+
+type AvailabilityState = 'idle' | 'invalid' | 'checking' | 'available' | 'unavailable' | 'error'
+type QrColor = 'black' | 'white' | 'accent'
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, max-lines-per-function, complexity
+export default function AccountAffiliate() {
+ const { i18n } = useLingui()
+ const { account, chainId } = useWalletInfo()
+ const provider = useWalletProvider()
+ const toggleWalletModal = useToggleWalletModal()
+ const onSelectNetwork = useOnSelectNetwork()
+ const isMainnet = useMemo(() => chainId === SupportedChainId.MAINNET, [chainId])
+
+ const [codeLoading, setCodeLoading] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [existingCode, setExistingCode] = useState(null)
+ const [inputCode, setInputCode] = useState('')
+ const [availability, setAvailability] = useState('idle')
+ const [hasEdited, setHasEdited] = useState(false)
+ const [createdAt, setCreatedAt] = useState(null)
+ const [programParams, setProgramParams] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+ const [partnerStats, setPartnerStats] = useState(null)
+ const [statsUpdatedAt, setStatsUpdatedAt] = useState(null)
+ const [statsLoading, setStatsLoading] = useState(false)
+ const { isModalOpen: isQrOpen, openModal: openQrModal, closeModal: closeQrModal } = useModalState()
+ const [qrColor, setQrColor] = useState('accent')
+ const qrCodeRef = useRef(null)
+ const inputRef = useRef(null)
+
+ const normalizedCode = useMemo(() => sanitizeReferralCode(inputCode), [inputCode])
+ const hasInvalidChars = useMemo(() => inputCode.trim().toUpperCase() !== normalizedCode, [inputCode, normalizedCode])
+ const isCodeValid = useMemo(
+ () => isReferralCodeLengthValid(normalizedCode) && !hasInvalidChars,
+ [hasInvalidChars, normalizedCode],
+ )
+ const codeTooltip = t`Referral codes contain 5-20 uppercase letters, numbers, dashes, or underscores`
+ const referralTrafficTooltip = t`Donut chart tracks eligible volume left to unlock the next reward.`
+ const numberFormatter = useMemo(() => new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }), [])
+ const formatNumber = useCallback(
+ (value: number | null | undefined) => (value === null || value === undefined ? '-' : numberFormatter.format(value)),
+ [numberFormatter],
+ )
+ const formatUpdatedAt = useCallback((value: Date | null) => {
+ if (!value) {
+ return '-'
+ }
+
+ return formatDateWithTimezone(value) ?? '-'
+ }, [])
+
+ const isConnected = Boolean(account)
+ const isSignerAvailable = Boolean(provider)
+ const showCreateForm = isConnected && isMainnet && !existingCode && isSignerAvailable
+ const showLinkedFlow = isConnected && isMainnet && Boolean(existingCode)
+ const showUnsupported = isConnected && !isMainnet
+
+ const referralLink = useMemo(() => {
+ if (!existingCode) {
+ return ''
+ }
+
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'https://swap.cow.fi'
+ return `${origin}?ref=${existingCode}`
+ }, [existingCode])
+
+ const shareText = useMemo(() => {
+ if (!existingCode) {
+ return ''
+ }
+
+ return encodeURIComponent(`Trade on CoW Swap with my referral code ${existingCode}. ${referralLink} @CoWSwap`)
+ }, [existingCode, referralLink])
+
+ const qrPalette = useMemo(() => QR_COLORS[qrColor], [qrColor])
+ const qrError = !referralLink ? t`Referral link unavailable.` : null
+ const canDownloadQr = Boolean(referralLink)
+
+ const handleDownloadQr = useCallback(
+ (fileType: 'png' | 'jpg' | 'webp') => {
+ if (!qrCodeRef.current || !canDownloadQr) {
+ return
+ }
+
+ qrCodeRef.current.download(fileType, 'cow-referral')
+ },
+ [canDownloadQr],
+ )
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!account || !isMainnet) {
+ setExistingCode(null)
+ setCreatedAt(null)
+ setCodeLoading(false)
+ setPartnerStats(null)
+ setStatsUpdatedAt(null)
+ setProgramParams(null)
+ return
+ }
+
+ const loadCode = async (): Promise => {
+ setCodeLoading(true)
+ setErrorMessage(null)
+
+ try {
+ const [response] = await Promise.all([bffAffiliateApi.getAffiliateCode(account), delay(MIN_LOADING_MS)])
+ if (cancelled) {
+ return
+ }
+
+ if (response?.code) {
+ setStatsLoading(true)
+ setExistingCode(response.code)
+ const created = response.createdAt ? new Date(response.createdAt) : null
+ setCreatedAt(created && !Number.isNaN(created.getTime()) ? created : null)
+ setProgramParams(response)
+ } else {
+ setExistingCode(null)
+ setCreatedAt(null)
+ setProgramParams(null)
+ setStatsLoading(false)
+ }
+ } catch {
+ if (cancelled) {
+ return
+ }
+ setExistingCode(null)
+ setCreatedAt(null)
+ setProgramParams(null)
+ setStatsLoading(false)
+ setErrorMessage(t`Unable to reach the affiliate service.`)
+ }
+
+ setCodeLoading(false)
+ }
+
+ loadCode()
+
+ return () => {
+ cancelled = true
+ }
+ }, [account, isMainnet])
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!account || !isMainnet || !existingCode) {
+ setPartnerStats(null)
+ setStatsUpdatedAt(null)
+ setStatsLoading(false)
+ return
+ }
+
+ const loadStats = async (): Promise => {
+ setStatsLoading(true)
+ try {
+ const [stats] = await Promise.all([bffAffiliateApi.getAffiliateStats(account), delay(MIN_LOADING_MS)])
+ if (cancelled) {
+ return
+ }
+
+ setPartnerStats(stats)
+ const updated = stats?.lastUpdatedAt ? new Date(stats.lastUpdatedAt) : null
+ setStatsUpdatedAt(updated && !Number.isNaN(updated.getTime()) ? updated : null)
+ } catch {
+ if (cancelled) {
+ return
+ }
+ setPartnerStats(null)
+ setStatsUpdatedAt(null)
+ }
+
+ setStatsLoading(false)
+ }
+
+ loadStats()
+
+ return () => {
+ cancelled = true
+ }
+ }, [account, existingCode, isMainnet])
+
+ useEffect(() => {
+ if (!showCreateForm || hasEdited || inputCode) {
+ return
+ }
+
+ const suggested = generateSuggestedCode()
+ setInputCode(suggested)
+ }, [account, hasEdited, inputCode, showCreateForm])
+
+ useEffect(() => {
+ if (!showCreateForm) {
+ setAvailability('idle')
+ return
+ }
+
+ if (!inputCode) {
+ setAvailability('idle')
+ return
+ }
+
+ if (!isCodeValid) {
+ setAvailability('invalid')
+ return
+ }
+
+ if (!chainId) {
+ setAvailability('idle')
+ return
+ }
+
+ let active = true
+ setAvailability('checking')
+
+ const timer = setTimeout(() => {
+ bffAffiliateApi
+ .verifyReferralCode({
+ code: normalizedCode,
+ account: account || '0x0000000000000000000000000000000000000000',
+ chainId,
+ })
+ .then((response) => {
+ if (!active) {
+ return
+ }
+
+ if (response.ok) {
+ setAvailability('unavailable')
+ return
+ }
+
+ if (response.status === 404) {
+ setAvailability('available')
+ return
+ }
+
+ if (response.status === 403) {
+ setAvailability('unavailable')
+ return
+ }
+
+ setAvailability('error')
+ })
+ .catch(() => {
+ if (active) {
+ setAvailability('error')
+ }
+ })
+ }, 350)
+
+ return () => {
+ active = false
+ clearTimeout(timer)
+ }
+ }, [account, chainId, inputCode, isCodeValid, normalizedCode, showCreateForm])
+
+ // eslint-disable-next-line complexity
+ const handleCreate = useCallback(async () => {
+ if (!account) {
+ setErrorMessage(t`Connect your wallet to create a code.`)
+ return
+ }
+
+ if (!isMainnet) {
+ setErrorMessage(t`Switch to Ethereum mainnet to create a code.`)
+ return
+ }
+
+ if (!isCodeValid) {
+ setErrorMessage(t`Enter a code with 5-20 characters (A-Z, 0-9, - or _).`)
+ return
+ }
+
+ if (availability !== 'available') {
+ if (availability === 'unavailable' || availability === 'error') {
+ setErrorMessage(t`That code is unavailable. Try another.`)
+ }
+
+ return
+ }
+
+ if (!provider) {
+ setErrorMessage(t`Wallet signer unavailable.`)
+ return
+ }
+
+ setSubmitting(true)
+ setErrorMessage(null)
+ try {
+ const signer = provider.getSigner()
+ const typedData = buildPartnerTypedData({
+ walletAddress: account,
+ code: normalizedCode,
+ chainId: SupportedChainId.MAINNET,
+ })
+
+ const signedMessage = await signer._signTypedData(typedData.domain, typedData.types, typedData.message)
+
+ const response = await bffAffiliateApi.createAffiliateCode({
+ code: normalizedCode,
+ walletAddress: account,
+ signedMessage,
+ })
+
+ setExistingCode(response.code)
+ const created = response.createdAt ? new Date(response.createdAt) : null
+ setCreatedAt(created && !Number.isNaN(created.getTime()) ? created : null)
+ setProgramParams(response)
+ } catch (error) {
+ const err = error as Error & { status?: number; code?: number }
+
+ if (err.code === 4001) {
+ setErrorMessage(t`Signature request rejected.`)
+ } else if (err.status === 409) {
+ setAvailability('unavailable')
+ setErrorMessage(t`Code already taken or wallet already linked.`)
+ } else if (err.status === 401) {
+ setErrorMessage(t`Signature invalid. Please try again.`)
+ } else if (err.status === 403) {
+ setAvailability('unavailable')
+ setErrorMessage(t`That code is unavailable. Try another.`)
+ } else if (err.status === 422) {
+ setErrorMessage(t`Unsupported network.`)
+ } else if (err.status === 400) {
+ setErrorMessage(t`Invalid request.`)
+ } else {
+ setErrorMessage(err.message || t`Unable to create affiliate code.`)
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }, [account, availability, isCodeValid, isMainnet, normalizedCode, provider])
+
+ const handleConnect = useCallback(() => {
+ toggleWalletModal()
+ }, [toggleWalletModal])
+
+ const handleSwitchToMainnet = useCallback(() => {
+ onSelectNetwork(SupportedChainId.MAINNET)
+ }, [onSelectNetwork])
+
+ const handleInputChange = useCallback((event: FormEvent) => {
+ setHasEdited(true)
+ setErrorMessage(null)
+ setInputCode(event.currentTarget.value.toUpperCase())
+ }, [])
+
+ const handleGenerate = useCallback(() => {
+ setHasEdited(true)
+ setErrorMessage(null)
+ setInputCode(generateSuggestedCode())
+ }, [])
+
+ const handleOpenQr = useCallback(() => {
+ if (!referralLink) {
+ setErrorMessage(t`Referral link unavailable.`)
+ return
+ }
+
+ openQrModal()
+ }, [openQrModal, referralLink])
+
+ const canSave = showCreateForm && isCodeValid && availability === 'available' && !submitting
+ const showCodeUnavailable = availability === 'unavailable'
+ const showInvalidFormat = availability === 'invalid'
+ const trailingIconKind: TrailingIconKind | undefined =
+ availability === 'checking'
+ ? 'pending'
+ : availability === 'available'
+ ? 'success'
+ : availability === 'unavailable' || availability === 'invalid' || availability === 'error'
+ ? 'error'
+ : undefined
+ const statsReady = Boolean(partnerStats)
+ const statsLoadingCombined = statsLoading || codeLoading
+ const triggerVolume = typeof programParams?.triggerVolume === 'number' ? programParams.triggerVolume : null
+ const leftToNextReward = statsReady ? partnerStats?.left_to_next_reward : undefined
+
+ const progressToNextReward =
+ triggerVolume !== null && leftToNextReward !== undefined ? Math.max(triggerVolume - leftToNextReward, 0) : 0
+ const referralTrafficPercent = triggerVolume
+ ? Math.min(100, Math.round((progressToNextReward / triggerVolume) * 100))
+ : 0
+
+ const progressToNextRewardLabel =
+ triggerVolume !== null ? formatUsdCompact(progressToNextReward) : formatUsdCompact(0)
+ const hasTriggerVolume = triggerVolume !== null
+ const triggerVolumeLabel = hasTriggerVolume ? formatUsdCompact(triggerVolume) : formatUsdCompact(0)
+ const affiliateRewardAmount =
+ typeof programParams?.rewardAmount === 'number' && typeof programParams?.revenueSplitAffiliatePct === 'number'
+ ? programParams.rewardAmount * (programParams.revenueSplitAffiliatePct / 100)
+ : null
+ const rewardAmountLabel = affiliateRewardAmount !== null ? formatUsdCompact(affiliateRewardAmount) : 'reward'
+
+ const nextPayoutLabel = statsReady ? formatUsdcCompact(partnerStats?.next_payout) : formatUsdcCompact(0)
+ const totalEarnedLabel = statsReady ? formatUsdcCompact(partnerStats?.total_earned) : EMPTY_VALUE_LABEL
+ const paidOutLabel = statsReady ? formatUsdcCompact(partnerStats?.paid_out) : EMPTY_VALUE_LABEL
+ const leftToNextRewardLabel = statsReady ? formatUsdCompact(partnerStats?.left_to_next_reward) : EMPTY_VALUE_LABEL
+ const totalVolumeLabel = statsReady ? formatUsdCompact(partnerStats?.total_volume) : EMPTY_VALUE_LABEL
+ const activeReferralsLabel = statsReady ? formatNumber(partnerStats?.active_traders) : EMPTY_VALUE_LABEL
+
+ const showHero = !isConnected || showUnsupported || (isConnected && !isSignerAvailable && !showLinkedFlow)
+
+ const createdOnLabel = createdAt ? formatShortDate(createdAt) : '-'
+ const statsUpdatedLabel = useTimeAgo(statsUpdatedAt ?? undefined, 60_000)
+ const statsUpdatedAbsoluteLabel = formatUpdatedAt(statsUpdatedAt)
+ const statsUpdatedDisplay = statsUpdatedLabel || '-'
+ const statsUpdatedText = i18n._(t`Last updated: ${statsUpdatedDisplay}`)
+ const statsUpdatedTitle = statsUpdatedAbsoluteLabel !== '-' ? statsUpdatedAbsoluteLabel : undefined
+
+ return (
+
+
+
+ {showHero ? (
+
+
+
+
+
+ Invite your friends
and earn rewards
+
+
+
+
+ You and your referrals can earn a flat fee
for the eligible volume done through the app. link.
+
+
+
+ {!isConnected && (
+
+ Connect wallet
+
+ )}
+ {isConnected && showUnsupported && (
+
+ Switch to Ethereum
+
+ )}
+ {isConnected && !showUnsupported && !isSignerAvailable && !showLinkedFlow && (
+
+ Become an affiliate
+
+ )}
+
+
+ {showUnsupported && (
+
+ Affiliate payouts and registration happens on Ethereum mainnet.
+
+ )}
+
+
+ ) : (
+ <>
+
+
+ {showLinkedFlow && existingCode ? (
+ <>
+
+ Your referral code
+
+
+
+
+
+ {existingCode}
+
+
+
+ Created
+
+
+
+
+
+ {referralLink}
+
+
+
+
+
+
+
+ Created on
+
+ {createdOnLabel}
+
+
+
+
+
+ Links/codes don't reveal your wallet.
+
+
+
+
+
+
+ Share on X
+
+
+
+
+
+ Download QR
+
+
+
+ >
+ ) : (
+ <>
+
+ Create your referral code
+
+
+
+ Type or generate a code (subject to availability). Saving locks this code to your wallet and can't
+ be changed. Links/codes don't reveal your wallet.
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ Your referral traffic
+
+
+
+
+
+
+
+
+ Left to next {rewardAmountLabel}
+
+
+ {leftToNextRewardLabel}
+
+
+
+ Total earned
+
+ {totalEarnedLabel}
+
+
+
+ Received
+
+ {paidOutLabel}
+
+
+
+ Volume referred
+
+ {totalVolumeLabel}
+
+
+
+ Active referrals
+
+ {activeReferralsLabel}
+
+
+
+
+
+ {progressToNextRewardLabel}
+ {hasTriggerVolume && (
+
+ of {triggerVolumeLabel}
+
+ )}
+
+
+
+
+ {statsUpdatedText}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ Download referral QR code
+
+
+ {referralLink}
+
+ {referralLink ? (
+
+ ) : (
+
+ )}
+
+ {qrError && {qrError}}
+
+ {Object.entries(QR_COLORS).map(([key, color]) => (
+ setQrColor(key as QrColor)}
+ aria-label={color.label}
+ />
+ ))}
+
+
+ {
+ event.preventDefault()
+ handleDownloadQr('png')
+ }}
+ >
+ Download .PNG
+
+ {
+ event.preventDefault()
+ handleDownloadQr('webp')
+ }}
+ >
+ Download .WEBP
+
+
+
+
+
+
+ )
+}
+
+const ModalContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+`
+
+const ModalBody = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 10px;
+`
+
+const QrUrl = styled.p`
+ margin: 0 auto;
+ font-size: 13px;
+ color: var(${UI.COLOR_TEXT_OPACITY_60});
+ word-break: break-all;
+`
+
+const QrFrame = styled.div<{ $bg: string }>`
+ border-radius: 16px;
+ border: 1px solid var(${UI.COLOR_PAPER_DARKER});
+ background: ${({ $bg }) => $bg};
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto;
+`
+
+const QrPlaceholder = styled.div`
+ width: ${QR_SIZE_PX}px;
+ height: ${QR_SIZE_PX}px;
+ border-radius: 12px;
+ background: repeating-linear-gradient(
+ 45deg,
+ var(${UI.COLOR_PAPER}) 0,
+ var(${UI.COLOR_PAPER}) 12px,
+ var(${UI.COLOR_PAPER_DARKER}) 12px,
+ var(${UI.COLOR_PAPER_DARKER}) 24px
+ );
+`
+
+const QrPalette = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+`
+
+const ColorDot = styled.button<{ $active: boolean; $color: string }>`
+ width: 24px;
+ height: 24px;
+ border-radius: 999px;
+ border: 4px solid ${({ $active }) => ($active ? `var(${UI.COLOR_PRIMARY})` : `var(${UI.COLOR_BORDER})`)};
+ background: ${({ $color }) => $color};
+ cursor: pointer;
+`
+
+const QrActions = styled.div`
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+`
+
+const DownloadLink = styled.a<{ $disabled: boolean }>`
+ padding: 10px 16px;
+ border-radius: 12px;
+ border: 1px solid var(${UI.COLOR_BORDER});
+ text-decoration: none;
+ color: ${({ $disabled }) => ($disabled ? `var(${UI.COLOR_TEXT_OPACITY_50})` : `var(${UI.COLOR_TEXT_OPACITY_60})`)};
+ pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
+`
diff --git a/apps/cowswap-frontend/src/pages/Account/Menu.tsx b/apps/cowswap-frontend/src/pages/Account/Menu.tsx
index 37e702945b2..d43940339d1 100644
--- a/apps/cowswap-frontend/src/pages/Account/Menu.tsx
+++ b/apps/cowswap-frontend/src/pages/Account/Menu.tsx
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import { ACCOUNT_PROXY_LABEL } from '@cowprotocol/common-const'
+import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { useExtractText } from '@cowprotocol/common-utils'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'
@@ -18,22 +19,29 @@ interface MenuItem {
url: string
}
-const ACCOUNT_MENU_LINKS = (chainId: SupportedChainId): MenuItem[] => {
+const ACCOUNT_MENU_LINKS = (chainId: SupportedChainId, isAffiliateProgramEnabled: boolean): MenuItem[] => {
return [
{ title: msg`Overview`, url: '/account' },
{ title: msg`Tokens`, url: '/account/tokens' },
{ title: ACCOUNT_PROXY_LABEL, url: getProxyAccountUrl(chainId) },
+ ...(isAffiliateProgramEnabled
+ ? [
+ { title: msg`My rewards`, url: '/account/my-rewards' },
+ { title: msg`Affiliate`, url: '/account/affiliate' },
+ ]
+ : []),
]
}
export function AccountMenu(): ReactNode {
const { chainId } = useWalletInfo()
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
const { extractTextFromStringOrI18nDescriptor } = useExtractText()
return (
- {ACCOUNT_MENU_LINKS(chainId).map(({ title, url }) => (
+ {ACCOUNT_MENU_LINKS(chainId, isAffiliateProgramEnabled).map(({ title, url }) => (
-
(isActive ? 'active' : undefined)}>
{extractTextFromStringOrI18nDescriptor(title)}
diff --git a/apps/cowswap-frontend/src/pages/Account/MyRewards.tsx b/apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
new file mode 100644
index 00000000000..57783c1713f
--- /dev/null
+++ b/apps/cowswap-frontend/src/pages/Account/MyRewards.tsx
@@ -0,0 +1,349 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import CheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
+import EARN_AS_TRADER_ILLUSTRATION from '@cowprotocol/assets/images/earn-as-trader.svg'
+import LockedIcon from '@cowprotocol/assets/images/icon-locked-2.svg'
+import { PAGE_TITLES } from '@cowprotocol/common-const'
+import { useTimeAgo } from '@cowprotocol/common-hooks'
+import { delay, formatShortDate } from '@cowprotocol/common-utils'
+import { ButtonPrimary } from '@cowprotocol/ui'
+import { useWalletInfo } from '@cowprotocol/wallet'
+import { useWalletChainId } from '@cowprotocol/wallet-provider'
+
+import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
+import { useLingui } from '@lingui/react/macro'
+import { AlertCircle } from 'react-feather'
+import SVG from 'react-inlinesvg'
+
+import { useToggleWalletModal } from 'legacy/state/application/hooks'
+
+import { bffAffiliateApi } from 'modules/affiliate/api/bffAffiliateApi'
+import { AFFILIATE_SUPPORTED_NETWORK_NAMES } from 'modules/affiliate/config/constants'
+import {
+ formatUpdatedAt,
+ formatUsdcCompact,
+ formatUsdCompact,
+ getIncomingIneligibleCode,
+ isSupportedReferralNetwork,
+} from 'modules/affiliate/lib/affiliate-program-utils'
+import { useTraderReferralCode } from 'modules/affiliate/model/hooks/useTraderReferralCode'
+import { useTraderReferralCodeActions } from 'modules/affiliate/model/hooks/useTraderReferralCodeActions'
+import { TraderStatsResponse } from 'modules/affiliate/model/partner-trader-types'
+import {
+ AffiliateTermsFaqLinks,
+ BottomMetaRow,
+ CardTitle,
+ Donut,
+ DonutValue,
+ HeroActions,
+ HeroCard,
+ HeroContent,
+ HeroSubtitle,
+ HeroTitle,
+ IneligibleCard,
+ IneligibleSubtitle,
+ IneligibleTitle,
+ LinkedBadge,
+ LinkedCard,
+ LinkedCodeRow,
+ LinkedCodeText,
+ LinkedMetaList,
+ NextPayoutCard,
+ RewardsCol1Card,
+ RewardsCol2Card,
+ RewardsHeader,
+ RewardsMetricsList,
+ RewardsMetricsRow,
+ RewardsThreeColumnGrid,
+ RewardsWrapper,
+ MetricItem,
+ UnsupportedNetworkCard,
+ UnsupportedNetworkHeader,
+ UnsupportedNetworkMessage,
+ ValidStatusBadge,
+} from 'modules/affiliate/ui/shared'
+import { TraderReferralCodeIneligibleCopy } from 'modules/affiliate/ui/TraderReferralCodeIneligibleCopy'
+import { TraderReferralCodeNetworkBanner } from 'modules/affiliate/ui/TraderReferralCodeNetworkBanner'
+import { PageTitle } from 'modules/application/containers/PageTitle'
+
+const MIN_LOADING_MS = 200
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, max-lines-per-function, complexity
+export default function AccountMyRewards() {
+ const { i18n } = useLingui()
+ const { account } = useWalletInfo()
+ const chainId = useWalletChainId()
+ const toggleWalletModal = useToggleWalletModal()
+ const traderReferralCode = useTraderReferralCode()
+ const traderReferralCodeActions = useTraderReferralCodeActions()
+ const [traderStats, setTraderStats] = useState(null)
+ const [statsUpdatedAt, setStatsUpdatedAt] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const isConnected = Boolean(account)
+ const supportedNetwork = chainId === undefined ? true : isSupportedReferralNetwork(chainId)
+ const isUnsupportedNetwork = Boolean(account) && !supportedNetwork
+ const incomingIneligibleCode = getIncomingIneligibleCode(
+ traderReferralCode.incomingCode,
+ traderReferralCode.verification,
+ )
+
+ const numberFormatter = useMemo(() => new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }), [])
+ const formatNumber = useCallback(
+ (value: number | null | undefined) => (value === null || value === undefined ? '-' : numberFormatter.format(value)),
+ [numberFormatter],
+ )
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!account) {
+ setTraderStats(null)
+ setStatsUpdatedAt(null)
+ setLoading(false)
+ return
+ }
+
+ const loadStats = async (): Promise => {
+ setLoading(true)
+ try {
+ const [stats] = await Promise.all([bffAffiliateApi.getTraderStats(account), delay(MIN_LOADING_MS)])
+ if (cancelled) {
+ return
+ }
+
+ setTraderStats(stats)
+ const updated = stats?.lastUpdatedAt ? new Date(stats.lastUpdatedAt) : null
+ setStatsUpdatedAt(updated && !Number.isNaN(updated.getTime()) ? updated : null)
+ if (stats?.bound_referrer_code && traderReferralCode.savedCode !== stats.bound_referrer_code) {
+ traderReferralCodeActions.setSavedCode(stats.bound_referrer_code)
+ traderReferralCodeActions.setWalletState({ status: 'linked', code: stats.bound_referrer_code })
+ }
+ } catch {
+ if (cancelled) {
+ return
+ }
+ setTraderStats(null)
+ setStatsUpdatedAt(null)
+ }
+
+ setLoading(false)
+ }
+
+ loadStats()
+
+ return () => {
+ cancelled = true
+ }
+ }, [account, traderReferralCode.savedCode, traderReferralCodeActions])
+
+ const statsReady = Boolean(traderStats)
+ const statsLinkedCode = traderStats?.bound_referrer_code
+ const isLinked = Boolean(statsLinkedCode) || traderReferralCode.wallet.status === 'linked'
+ const isIneligible = traderReferralCode.wallet.status === 'ineligible' && isConnected && !statsLinkedCode
+ const programParams =
+ traderReferralCode.verification.kind === 'valid'
+ ? traderReferralCode.verification.programParams
+ : traderReferralCode.previousVerification?.kind === 'valid'
+ ? traderReferralCode.previousVerification.programParams
+ : undefined
+ const rewardAmountLabel = programParams ? formatUsdCompact(programParams?.traderRewardAmount) : 'reward'
+ const traderCode = isConnected
+ ? statsLinkedCode
+ ? statsLinkedCode
+ : isLinked
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (traderReferralCode as any).wallet.code
+ : (traderReferralCode.savedCode ??
+ (traderReferralCode.verification.kind === 'valid' ? traderReferralCode.verification.code : undefined))
+ : undefined
+ const traderHasCode = Boolean(traderCode)
+ const triggerVolume = typeof programParams?.triggerVolumeUsd === 'number' ? programParams.triggerVolumeUsd : null
+ const leftToNextRewards = statsReady ? traderStats?.left_to_next_rewards : undefined
+ const progressToNextReward =
+ triggerVolume !== null && leftToNextRewards !== undefined ? Math.max(triggerVolume - leftToNextRewards, 0) : 0
+ const rewardsProgressPercent = triggerVolume
+ ? Math.min(100, Math.round((progressToNextReward / triggerVolume) * 100))
+ : 0
+ const rewardsProgressLabel = triggerVolume !== null ? formatUsdCompact(progressToNextReward) : formatUsdCompact(0)
+ const hasTriggerVolume = triggerVolume !== null
+ const triggerVolumeLabel = hasTriggerVolume ? formatUsdCompact(triggerVolume) : formatUsdCompact(0)
+ const leftToNextRewardLabel = statsReady ? formatUsdCompact(leftToNextRewards) : '-'
+ const totalEarnedLabel = statsReady ? formatUsdcCompact(traderStats?.total_earned) : '-'
+ const claimedLabel = statsReady ? formatUsdcCompact(traderStats?.paid_out) : '-'
+ const nextPayoutValue = traderStats?.next_payout
+ const nextPayoutLabel =
+ statsReady && nextPayoutValue !== null && nextPayoutValue !== undefined
+ ? `${formatNumber(nextPayoutValue)} USDC`
+ : formatUsdcCompact(0)
+ const linkedSinceLabel = formatShortDate(traderStats?.linked_since) ?? '-'
+ const rewardsEndLabel = formatShortDate(traderStats?.rewards_end) ?? '-'
+ const statsUpdatedLabel = useTimeAgo(statsUpdatedAt ?? undefined, 60_000)
+ const statsUpdatedAbsoluteLabel = formatUpdatedAt(statsUpdatedAt)
+ const statsUpdatedDisplay = statsUpdatedLabel || '-'
+ const statsUpdatedText = i18n._(t`Last updated: ${statsUpdatedDisplay}`)
+ const statsUpdatedTitle = statsUpdatedAbsoluteLabel !== '-' ? statsUpdatedAbsoluteLabel : undefined
+
+ const handleOpenRewardsModal = useCallback(() => {
+ traderReferralCodeActions.openModal('rewards')
+ }, [traderReferralCodeActions])
+
+ const handleConnect = useCallback(() => {
+ toggleWalletModal()
+ }, [toggleWalletModal])
+
+ const supportedNetworks = AFFILIATE_SUPPORTED_NETWORK_NAMES.join(', ')
+
+ return (
+ <>
+
+
+
+
+ {isIneligible ? (
+
+
+
+
+ Your wallet is ineligible
+
+
+
+
+
+ ) : isUnsupportedNetwork ? (
+
+
+
+ Switch network
+
+
+ Please connect your wallet to one of our supported networks: {supportedNetworks}.
+
+
+ ) : !traderHasCode ? (
+
+
+
+
+ Earn while you trade
+
+
+
+ Use a referral code to earn a flat fee for
+
+ the eligible volume done through the app.
+
+ New wallets only.
+
+
+
+ {!isConnected ? (
+
+ Connect wallet
+
+ ) : (
+
+ Add code
+
+ )}
+
+
+
+
+ ) : (
+ <>
+
+
+
+ {isLinked ? Active referral code : Referral code}
+
+
+
+ {traderCode}
+ {isLinked ? (
+
+
+ Linked
+
+ ) : (
+
+
+ Valid
+
+ )}
+
+
+
+
+
+ Linked since
+
+ {isLinked ? linkedSinceLabel : '-'}
+
+
+
+ Rewards end
+
+ {isLinked ? rewardsEndLabel : '-'}
+
+
+ {!isLinked && (
+
+
+ Edit code
+
+
+ )}
+
+
+
+
+ Next {rewardAmountLabel} reward
+
+
+
+
+
+ Left to next {rewardAmountLabel}
+
+ {leftToNextRewardLabel}
+
+
+
+ Total earned
+
+ {totalEarnedLabel}
+
+
+
+ Received
+
+ {claimedLabel}
+
+
+
+
+ {rewardsProgressLabel}
+ {hasTriggerVolume && (
+
+ of {triggerVolumeLabel}
+
+ )}
+
+
+
+
+ {statsUpdatedText}
+
+
+
+
+
+ >
+ )}
+
+ >
+ )
+}
diff --git a/apps/cowswap-frontend/src/pages/Account/index.tsx b/apps/cowswap-frontend/src/pages/Account/index.tsx
index ed6854aa486..694bb6d0256 100644
--- a/apps/cowswap-frontend/src/pages/Account/index.tsx
+++ b/apps/cowswap-frontend/src/pages/Account/index.tsx
@@ -1,6 +1,7 @@
import { lazy, ReactNode } from 'react'
import { PAGE_TITLES } from '@cowprotocol/common-const'
+import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { t } from '@lingui/core/macro'
import { useLingui } from '@lingui/react/macro'
@@ -20,7 +21,7 @@ const Balances = lazy(() => import(/* webpackChunkName: "account" */ 'pages/Acco
const Governance = lazy(() => import(/* webpackChunkName: "governance" */ 'pages/Account/Governance'))
const Delegate = lazy(() => import(/* webpackChunkName: "delegate" */ 'pages/Account/Delegate'))
-function _getPropsFromRoute(route: string): string[] {
+function getPropsFromRoute(route: string, isAffiliateProgramEnabled: boolean): string[] {
switch (route) {
case RoutesEnum.ACCOUNT:
return ['account-overview', t`Account overview`]
@@ -28,6 +29,10 @@ function _getPropsFromRoute(route: string): string[] {
return ['account-governance', t`Governance`]
case RoutesEnum.ACCOUNT_TOKENS:
return ['account-tokens', t`Tokens overview`]
+ case RoutesEnum.ACCOUNT_AFFILIATE:
+ return isAffiliateProgramEnabled ? ['account-affiliate', t`Rewards hub - Affiliate`] : []
+ case RoutesEnum.ACCOUNT_MY_REWARDS:
+ return isAffiliateProgramEnabled ? ['account-my-rewards', t`Rewards hub - My rewards`] : []
default:
return []
}
@@ -53,7 +58,8 @@ export const AccountOverview = (): ReactNode => {
export default function Account(): ReactNode {
const { pathname } = useLocation()
- const [id, name] = _getPropsFromRoute(pathname)
+ const { isAffiliateProgramEnabled = false } = useFeatureFlags()
+ const [id, name] = getPropsFromRoute(pathname, isAffiliateProgramEnabled)
return (
diff --git a/apps/cowswap-frontend/src/pages/Account/styled.tsx b/apps/cowswap-frontend/src/pages/Account/styled.tsx
index 10ca0d309b3..54319842e27 100644
--- a/apps/cowswap-frontend/src/pages/Account/styled.tsx
+++ b/apps/cowswap-frontend/src/pages/Account/styled.tsx
@@ -84,7 +84,6 @@ export const Card = styled.div<{ showLoader?: boolean }>`
display: flex;
flex-flow: row wrap;
flex: 1;
- min-height: 192px;
margin: 0;
background: var(${UI.COLOR_PAPER});
box-shadow: none;
@@ -94,12 +93,20 @@ export const Card = styled.div<{ showLoader?: boolean }>`
border: none;
align-items: flex-end;
+ > * {
+ transition: opacity 200ms ease-out;
+ }
+
${({ showLoader, theme }) =>
showLoader &&
css`
position: relative;
overflow: hidden;
+ > * {
+ opacity: 0;
+ }
&::after {
+ z-index: 2;
position: absolute;
top: 0;
right: 0;
@@ -117,8 +124,7 @@ export const Card = styled.div<{ showLoader?: boolean }>`
}
${ButtonPrimary} {
- height: 52px;
- gap: 10px;
+ gap: 8px;
> svg {
height: 100%;
@@ -149,7 +155,6 @@ export const BannerCard = styled.div<{ rowOnMobile?: boolean }>`
flex-flow: row;
align-items: center;
justify-content: flex-start;
- min-height: 192px;
border-radius: 16px;
background: var(${UI.COLOR_PAPER});
border: none;
diff --git a/apps/cowswap-frontend/src/pages/Account/utils.ts b/apps/cowswap-frontend/src/pages/Account/utils.ts
deleted file mode 100644
index 82b63d0cefe..00000000000
--- a/apps/cowswap-frontend/src/pages/Account/utils.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { i18n } from '@lingui/core'
-
-const numberFormatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- minimumFractionDigits: 0,
-})
-
-export const formatDecimal = (number?: number): string => {
- return number ? numberFormatter.format(number) : '-'
-}
-
-export const formatInt = (number?: number): string => {
- return number ? number.toLocaleString(i18n.locale) : '-'
-}
diff --git a/apps/cowswap-frontend/src/theme/components.tsx b/apps/cowswap-frontend/src/theme/components.tsx
index b62b56017de..b2ca6b4e8b5 100644
--- a/apps/cowswap-frontend/src/theme/components.tsx
+++ b/apps/cowswap-frontend/src/theme/components.tsx
@@ -13,27 +13,3 @@ export const CloseIcon = styled(X)<{ onClick: Command }>`
opacity: 1;
}
`
-
-// A button that triggers some onClick result, but looks like a link.
-export const LinkStyledButton = styled.button<{ disabled?: boolean; bg?: boolean; isCopied?: boolean; color?: string }>`
- border: none;
- text-decoration: none;
- background: none;
- cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
- color: ${({ color }) => color || 'inherit'};
- font-weight: 500;
- opacity: ${({ disabled }) => (disabled ? 0.7 : 1)};
-
- :hover {
- text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
- }
-
- :focus {
- outline: none;
- text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
- }
-
- :active {
- text-decoration: none;
- }
-`
diff --git a/libs/assets/src/images/earn-as-affiliate.svg b/libs/assets/src/images/earn-as-affiliate.svg
new file mode 100644
index 00000000000..00affcdb69f
--- /dev/null
+++ b/libs/assets/src/images/earn-as-affiliate.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/assets/src/images/earn-as-trader.svg b/libs/assets/src/images/earn-as-trader.svg
new file mode 100644
index 00000000000..f9b3df709d0
--- /dev/null
+++ b/libs/assets/src/images/earn-as-trader.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/assets/src/images/icon-locked-2.svg b/libs/assets/src/images/icon-locked-2.svg
new file mode 100644
index 00000000000..dfb8855cf78
--- /dev/null
+++ b/libs/assets/src/images/icon-locked-2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/libs/assets/src/images/icon-qr-code.svg b/libs/assets/src/images/icon-qr-code.svg
new file mode 100644
index 00000000000..7437d16ca11
--- /dev/null
+++ b/libs/assets/src/images/icon-qr-code.svg
@@ -0,0 +1,3 @@
+
diff --git a/libs/assets/src/images/icon-save.svg b/libs/assets/src/images/icon-save.svg
new file mode 100644
index 00000000000..7280315441a
--- /dev/null
+++ b/libs/assets/src/images/icon-save.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/libs/assets/src/images/logo-icon-cow-circle-accent.svg b/libs/assets/src/images/logo-icon-cow-circle-accent.svg
new file mode 100644
index 00000000000..a4bd5eb5223
--- /dev/null
+++ b/libs/assets/src/images/logo-icon-cow-circle-accent.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/assets/src/images/logo-icon-cow-circle-black.svg b/libs/assets/src/images/logo-icon-cow-circle-black.svg
new file mode 100644
index 00000000000..2645cccc0ce
--- /dev/null
+++ b/libs/assets/src/images/logo-icon-cow-circle-black.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/assets/src/images/logo-icon-cow-circle-white.svg b/libs/assets/src/images/logo-icon-cow-circle-white.svg
new file mode 100644
index 00000000000..4a463cb96af
--- /dev/null
+++ b/libs/assets/src/images/logo-icon-cow-circle-white.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/assets/src/images/logo-icon-cow-circle.svg b/libs/assets/src/images/logo-icon-cow-circle.svg
new file mode 100644
index 00000000000..116402bd938
--- /dev/null
+++ b/libs/assets/src/images/logo-icon-cow-circle.svg
@@ -0,0 +1,8 @@
+
diff --git a/libs/common-const/src/common.ts b/libs/common-const/src/common.ts
index 8c0af0536e3..386ec5f8fa5 100644
--- a/libs/common-const/src/common.ts
+++ b/libs/common-const/src/common.ts
@@ -47,6 +47,8 @@ export const PAGE_TITLES = {
COW_RUNNER: msg`CoW Runner`,
MEV_SLICER: msg`Mev Slicer`,
HOOKS: msg`Hooks`,
+ AFFILIATE: msg`Rewards hub - Affiliate`,
+ MY_REWARDS: msg`Rewards hub - My rewards`,
}
export function getEthFlowContractAddresses(env: CowEnv, chainId: SupportedChainId): string {
diff --git a/libs/common-utils/src/index.ts b/libs/common-utils/src/index.ts
index 5dbcd206c91..56bc2fc1435 100644
--- a/libs/common-utils/src/index.ts
+++ b/libs/common-utils/src/index.ts
@@ -17,6 +17,7 @@ export * from './environments'
export * from './explorer'
export * from './featureFlags'
export * from './format'
+export { default as formatLocaleNumber } from './formatLocaleNumber'
export * from './fractionUtils'
export * from './genericPropsChecker'
export * from './getAddress'
diff --git a/libs/common-utils/src/time.test.ts b/libs/common-utils/src/time.test.ts
new file mode 100644
index 00000000000..b631468889a
--- /dev/null
+++ b/libs/common-utils/src/time.test.ts
@@ -0,0 +1,25 @@
+import { formatDateWithTimezone, formatShortDate } from './time'
+
+describe('time', () => {
+ describe('formatShortDate', () => {
+ it('treats 0 (unix epoch) as a valid timestamp', () => {
+ expect(formatShortDate(0)).toEqual(expect.any(String))
+ })
+
+ it('returns undefined for nullish values', () => {
+ expect(formatShortDate(undefined)).toBeUndefined()
+ expect(formatShortDate(null)).toBeUndefined()
+ })
+
+ it('returns undefined for invalid dates', () => {
+ expect(formatShortDate('')).toBeUndefined()
+ expect(formatShortDate('not-a-date')).toBeUndefined()
+ })
+ })
+
+ describe('formatDateWithTimezone', () => {
+ it('treats 0 (unix epoch) as a valid timestamp', () => {
+ expect(formatDateWithTimezone(0)).toEqual(expect.any(String))
+ })
+ })
+})
diff --git a/libs/common-utils/src/time.ts b/libs/common-utils/src/time.ts
index 5841bfc9577..bd6bb1cf677 100644
--- a/libs/common-utils/src/time.ts
+++ b/libs/common-utils/src/time.ts
@@ -12,13 +12,32 @@ export function getDateTimestamp(date: Date): number {
* Helper function that returns a given Date/timestamp as a locale representation of it as string
* in the format (