diff --git a/.env b/.env
index 9d7e1aae9f9..f7c9977553f 100644
--- a/.env
+++ b/.env
@@ -295,5 +295,7 @@ VITE_FEATURE_CETUS_SWAP=false
VITE_FEATURE_SUNIO_SWAP=false
VITE_FEATURE_MONAD=false
VITE_FEATURE_PLASMA=false
+
+VITE_FEATURE_REFERRAL=false
VITE_HYPEREVM_NODE_URL=https://rpc.hyperliquid.xyz/evm
VITE_FEATURE_HYPEREVM=false
diff --git a/.env.development b/.env.development
index 3dbb069611c..174bf2831bc 100644
--- a/.env.development
+++ b/.env.development
@@ -6,6 +6,7 @@
# feature flags
VITE_FEATURE_THORCHAIN_TCY_ACTIVITY=true
+VITE_FEATURE_REFERRAL=true
# mixpanel
VITE_MIXPANEL_TOKEN=a867ce40912a6b7d01d088cf62b0e1ff
diff --git a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts
index 51df697b98c..58341626aeb 100644
--- a/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts
+++ b/packages/swapper/src/swappers/PortalsSwapper/getPortalsTradeQuote/getPortalsTradeQuote.ts
@@ -268,6 +268,7 @@ export async function getPortalsTradeQuote(
estimatedExecutionTimeMs: isCrossChain ? 300000 : 0,
portalsTransactionMetadata: {
...tx,
+ orderId: portalsTradeOrderResponse.context.orderId,
isCrossChain,
buyAssetChainId: isCrossChain ? buyAssetChainId : undefined,
expiry: portalsTradeOrderResponse.context.expiry
diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts
index 72c3b57d7cc..003cf699bcd 100644
--- a/packages/swapper/src/types.ts
+++ b/packages/swapper/src/types.ts
@@ -342,6 +342,7 @@ export type TradeQuoteStep = {
expiry?: number
steps?: string[]
route?: string[]
+ orderId?: string
}
bebopTransactionMetadata?: {
to: Address
@@ -448,6 +449,7 @@ export type SwapperSpecificMetadata = {
timeEstimate: number
deadline: string
}
+ cowswapQuoteSpecific?: OrderQuoteResponse
relayTransactionMetadata: RelayTransactionMetadata | undefined
relayerExplorerTxLink: string | undefined
relayerTxHash: string | undefined
diff --git a/src/App.tsx b/src/App.tsx
index e40fb72445e..89518d86477 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag'
import { useHasAppUpdated } from '@/hooks/useHasAppUpdated/useHasAppUpdated'
import { useModal } from '@/hooks/useModal/useModal'
import { useNotificationToast } from '@/hooks/useNotificationToast'
+import { useReferralCapture } from '@/hooks/useReferralCapture/useReferralCapture'
import { isMobile as isMobileApp } from '@/lib/globals'
import { AppRoutes } from '@/Routes/Routes'
@@ -35,6 +36,7 @@ export const App = () => {
useAddAccountsGuard()
useAppleSearchAdsAttribution()
+ useReferralCapture()
useEffect(() => {
if (hasUpdated && !toast.isActive(updateId) && !isActionCenterEnabled) {
diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json
index fee72c07163..5ec94cb3409 100644
--- a/src/assets/translations/en/main.json
+++ b/src/assets/translations/en/main.json
@@ -11,6 +11,7 @@
"and": "and",
"balance": "Balance",
"next": "Next",
+ "actions": "Actions",
"edit": "Edit",
"error": "Error",
"show": "Show",
@@ -515,7 +516,8 @@
"foxEcosystem": "FOX Ecosystem",
"markets": "Markets",
"tokens": "Tokens",
- "swap": "Swap"
+ "swap": "Swap",
+ "referral": "Referral"
},
"shapeShiftMenu": {
"products": "Products",
@@ -2126,6 +2128,39 @@
"emptyBody": "It appears you don't have any loans at the moment. Is this financial zen or just a break before your next big lending adventure? Either way, enjoy the calm!"
}
},
+ "referral": {
+ "description": "Earn rewards by referring friends to ShapeShift",
+ "totalReferrals": "Total Referrals",
+ "activeCodes": "Active Codes",
+ "feesCollected": "Fees Collected",
+ "currentMonth": "Current Month",
+ "yourReferralLink": "Your Referral Link",
+ "yourReferralCode": "Your Referral Code",
+ "currentRewards": "Current Rewards",
+ "totalRewards": "Total Rewards",
+ "totalReferred": "Total Referred",
+ "referrals": "Referrals",
+ "dashboard": "Dashboard",
+ "codes": "Codes",
+ "address": "Address",
+ "volume": "Volume",
+ "noCodeYet": "Create a referral code to get your link",
+ "createNewCode": "Create New Referral Code",
+ "enterCodeOrLeaveEmpty": "Enter a custom code or leave empty for random",
+ "random": "Random",
+ "create": "Create",
+ "yourCodes": "Your Referral Codes",
+ "code": "Code",
+ "usages": "Usages",
+ "status": "Status",
+ "createdAt": "Created",
+ "active": "Active",
+ "inactive": "Inactive",
+ "noCodes": "You don't have any referral codes yet. Create your first one above!",
+ "codeCreated": "Referral Code Created",
+ "codeCreatedDescription": "Your referral code %{code} has been created successfully",
+ "createCodeFailed": "Failed to create referral code. Please try again."
+ },
"chart": {
"interval": {
"5min": "Past five minutes",
diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx
index 3f21530b7bc..b87d326a098 100644
--- a/src/components/Layout/Header/Header.tsx
+++ b/src/components/Layout/Header/Header.tsx
@@ -1,4 +1,4 @@
-import { Box, Divider, Flex, HStack, useMediaQuery } from '@chakra-ui/react'
+import { Box, Button, Divider, Flex, HStack, useMediaQuery } from '@chakra-ui/react'
import { useScroll } from 'framer-motion'
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
@@ -11,8 +11,9 @@ import {
TbRefresh,
TbStack,
} from 'react-icons/tb'
+import { useTranslate } from 'react-polyglot'
import { useSelector } from 'react-redux'
-import { useNavigate } from 'react-router-dom'
+import { Link as ReactRouterLink, useLocation, useNavigate } from 'react-router-dom'
import { ActionCenter } from './ActionCenter/ActionCenter'
import { DegradedStateBanner } from './DegradedStateBanner'
@@ -72,10 +73,14 @@ const earnSubMenuItems = [
{ label: 'navBar.lending', path: '/lending', icon: TbBuildingBank },
]
+const menuButtonHoverSx = { bg: 'background.surface.elevated' }
+const menuButtonActiveSx = { bg: 'transparent' }
+
export const Header = memo(() => {
const isDegradedState = useSelector(selectPortfolioDegradedState)
const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`)
-
+ const location = useLocation()
+ const translate = useTranslate()
const navigate = useNavigate()
const {
state: { isConnected, walletInfo },
@@ -111,6 +116,7 @@ export const Header = memo(() => {
const isWalletConnectToDappsV2Enabled = useFeatureFlag('WalletConnectToDappsV2')
const isActionCenterEnabled = useFeatureFlag('ActionCenter')
const isNewWalletManagerEnabled = useFeatureFlag('NewWalletManager')
+ const isReferralEnabled = useFeatureFlag('Referral')
const { degradedChainIds } = useDiscoverAccounts()
const hasWallet = Boolean(walletInfo?.deviceId)
@@ -170,6 +176,29 @@ export const Header = memo(() => {
defaultPath='/assets'
/>
+ {isReferralEnabled && (
+
+ )}
diff --git a/src/components/Referral/CreateCodeCard.tsx b/src/components/Referral/CreateCodeCard.tsx
new file mode 100644
index 00000000000..9d4491a42f4
--- /dev/null
+++ b/src/components/Referral/CreateCodeCard.tsx
@@ -0,0 +1,68 @@
+import { Button, Card, CardBody, CardHeader, Heading, HStack, Input } from '@chakra-ui/react'
+import { FaPlus } from 'react-icons/fa'
+import { useTranslate } from 'react-polyglot'
+
+type CreateCodeCardProps = {
+ newCodeInput: string
+ isCreating: boolean
+ onInputChange: (value: string) => void
+ onGenerateRandom: () => void
+ onCreate: () => void
+}
+
+export const CreateCodeCard = ({
+ newCodeInput,
+ isCreating,
+ onInputChange,
+ onGenerateRandom,
+ onCreate,
+}: CreateCodeCardProps) => {
+ const translate = useTranslate()
+
+ return (
+
+
+ {translate('referral.createNewCode')}
+
+
+
+ onInputChange(e.target.value.toUpperCase())}
+ placeholder={translate('referral.enterCodeOrLeaveEmpty')}
+ maxLength={20}
+ bg='background.surface.raised.base'
+ border='none'
+ />
+ }
+ variant='outline'
+ flexShrink={0}
+ borderRadius='full'
+ border='1px solid'
+ borderColor='gray.700'
+ backgroundColor='background.surface.raised.base'
+ >
+ {translate('referral.random')}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Referral/ReferralCodeCard.tsx b/src/components/Referral/ReferralCodeCard.tsx
new file mode 100644
index 00000000000..c598601a704
--- /dev/null
+++ b/src/components/Referral/ReferralCodeCard.tsx
@@ -0,0 +1,83 @@
+import { Card, CardBody, Flex, Heading, IconButton, Skeleton, Text } from '@chakra-ui/react'
+import { FaCopy } from 'react-icons/fa'
+import { FaXTwitter } from 'react-icons/fa6'
+import { useTranslate } from 'react-polyglot'
+
+type ReferralCodeCardProps = {
+ code: string | null
+ isLoading: boolean
+ onShareOnX: (code: string) => void
+ onCopyCode: (code: string) => void
+}
+
+export const ReferralCodeCard = ({
+ code,
+ isLoading,
+ onShareOnX,
+ onCopyCode,
+}: ReferralCodeCardProps) => {
+ const translate = useTranslate()
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {translate('referral.yourReferralCode')}
+
+
+ {code || 'N/A'}
+
+
+ {code && (
+
+ }
+ size='md'
+ colorScheme='whiteAlpha'
+ borderRadius='100%'
+ bg='whiteAlpha.200'
+ onClick={() => onShareOnX(code)}
+ />
+ }
+ size='md'
+ colorScheme='whiteAlpha'
+ bg='whiteAlpha.200'
+ borderRadius='100%'
+ onClick={() => onCopyCode(code)}
+ />
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/Referral/ReferralCodesManagementTable.tsx b/src/components/Referral/ReferralCodesManagementTable.tsx
new file mode 100644
index 00000000000..75f5288b549
--- /dev/null
+++ b/src/components/Referral/ReferralCodesManagementTable.tsx
@@ -0,0 +1,129 @@
+import {
+ Box,
+ Card,
+ CardBody,
+ Flex,
+ HStack,
+ IconButton,
+ Skeleton,
+ Stack,
+ Text,
+} from '@chakra-ui/react'
+import { FaCopy } from 'react-icons/fa'
+import { FaXTwitter } from 'react-icons/fa6'
+import { useTranslate } from 'react-polyglot'
+
+type ReferralCodeFull = {
+ code: string
+ usageCount: number
+ maxUses?: number | null
+ isActive: boolean
+ createdAt: string | Date
+}
+
+type ReferralCodesManagementTableProps = {
+ codes: ReferralCodeFull[]
+ isLoading: boolean
+ onShareOnX: (code: string) => void
+ onCopyCode: (code: string) => void
+}
+
+export const ReferralCodesManagementTable = ({
+ codes,
+ isLoading,
+ onShareOnX,
+ onCopyCode,
+}: ReferralCodesManagementTableProps) => {
+ const translate = useTranslate()
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (!codes.length) {
+ return (
+
+
+
+ {translate('referral.noCodes')}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {translate('referral.code')}
+
+ {translate('referral.usages')}
+
+
+ {translate('referral.status')}
+
+
+ {translate('referral.createdAt')}
+
+
+
+
+ {codes.map(code => (
+
+
+
+
+ {code.code}
+
+
+ {code.usageCount}
+ {code.maxUses ? ` / ${code.maxUses}` : ''}
+
+
+
+ {code.isActive ? translate('referral.active') : translate('referral.inactive')}
+
+
+
+ {new Date(code.createdAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ }
+ size='sm'
+ colorScheme='twitter'
+ variant='ghost'
+ onClick={() => onShareOnX(code.code)}
+ />
+ }
+ size='sm'
+ variant='ghost'
+ onClick={() => onCopyCode(code.code)}
+ />
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/Referral/ReferralCodesTable.tsx b/src/components/Referral/ReferralCodesTable.tsx
new file mode 100644
index 00000000000..15c60b92faf
--- /dev/null
+++ b/src/components/Referral/ReferralCodesTable.tsx
@@ -0,0 +1,110 @@
+import {
+ Box,
+ Card,
+ CardBody,
+ Flex,
+ HStack,
+ IconButton,
+ Skeleton,
+ Stack,
+ Text,
+} from '@chakra-ui/react'
+import { FaCopy } from 'react-icons/fa'
+import { FaXTwitter } from 'react-icons/fa6'
+import { useTranslate } from 'react-polyglot'
+
+type ReferralCode = {
+ code: string
+ usageCount: number
+ swapVolumeUsd?: string
+}
+
+type ReferralCodesTableProps = {
+ codes: ReferralCode[]
+ isLoading: boolean
+ onShareOnX: (code: string) => void
+ onCopyCode: (code: string) => void
+}
+
+export const ReferralCodesTable = ({
+ codes,
+ isLoading,
+ onShareOnX,
+ onCopyCode,
+}: ReferralCodesTableProps) => {
+ const translate = useTranslate()
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (!codes.length) {
+ return (
+
+
+
+ {translate('referral.noCodes')}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {translate('referral.address')}
+
+ {translate('referral.referrals')}
+
+
+ {translate('referral.volume')}
+
+
+
+
+ {codes.map(code => (
+
+
+
+
+ {code.code}
+
+
+ {code.usageCount}
+
+
+ ${code.swapVolumeUsd || '0.00'}
+
+
+
+ }
+ size='sm'
+ colorScheme='twitter'
+ variant='ghost'
+ onClick={() => onShareOnX(code.code)}
+ />
+ }
+ size='sm'
+ variant='ghost'
+ onClick={() => onCopyCode(code.code)}
+ />
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/Referral/ReferralDashboard.tsx b/src/components/Referral/ReferralDashboard.tsx
new file mode 100644
index 00000000000..3e4b6cb98c5
--- /dev/null
+++ b/src/components/Referral/ReferralDashboard.tsx
@@ -0,0 +1,151 @@
+import { Alert, AlertIcon, Flex, Stack, useToast } from '@chakra-ui/react'
+import { useCallback, useMemo, useState } from 'react'
+import { useTranslate } from 'react-polyglot'
+
+import { CreateCodeCard } from './CreateCodeCard'
+import { ReferralCodeCard } from './ReferralCodeCard'
+import { ReferralCodesManagementTable } from './ReferralCodesManagementTable'
+import { ReferralCodesTable } from './ReferralCodesTable'
+import { ReferralHeader } from './ReferralHeader'
+import { ReferralStatsCards } from './ReferralStatsCards'
+import { ReferralTabs } from './ReferralTabs'
+
+import { RawText } from '@/components/Text'
+import { useReferral } from '@/hooks/useReferral/useReferral'
+
+const generateRandomCode = () => {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+ let code = ''
+ for (let i = 0; i < 8; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return code
+}
+
+type ReferralTab = 'referrals' | 'codes'
+
+export const ReferralDashboard = () => {
+ const translate = useTranslate()
+ const toast = useToast()
+ const { referralStats, isLoadingReferralStats, error, createCode, isCreatingCode } = useReferral()
+
+ const [newCodeInput, setNewCodeInput] = useState('')
+ const [activeTab, setActiveTab] = useState('referrals')
+
+ const defaultCode = useMemo(() => {
+ if (!referralStats?.referralCodes.length) return null
+ return referralStats.referralCodes.find(code => code.isActive) || referralStats.referralCodes[0]
+ }, [referralStats])
+
+ const handleShareOnX = useCallback((code: string) => {
+ const shareUrl = `${window.location.origin}/#/?ref=${code}`
+ const text = `Join me on ShapeShift using my referral code ${code}!`
+ const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
+ text,
+ )}&url=${encodeURIComponent(shareUrl)}`
+ window.open(twitterUrl, '_blank', 'noopener,noreferrer')
+ }, [])
+
+ const handleCopyCode = useCallback(
+ (code: string) => {
+ const shareUrl = `${window.location.origin}/#/?ref=${code}`
+ navigator.clipboard.writeText(shareUrl)
+ toast({
+ title: translate('common.copied'),
+ status: 'success',
+ duration: 2000,
+ })
+ },
+ [toast, translate],
+ )
+
+ const handleCreateCode = useCallback(async () => {
+ const code = newCodeInput.trim() || generateRandomCode()
+
+ try {
+ await createCode({ code })
+ setNewCodeInput('')
+ toast({
+ title: translate('referral.codeCreated'),
+ description: translate('referral.codeCreatedDescription', { code }),
+ status: 'success',
+ duration: 3000,
+ isClosable: true,
+ })
+ } catch (err) {
+ toast({
+ title: translate('common.error'),
+ description: err instanceof Error ? err.message : translate('referral.createCodeFailed'),
+ status: 'error',
+ duration: 5000,
+ isClosable: true,
+ })
+ }
+ }, [createCode, newCodeInput, toast, translate])
+
+ const handleGenerateRandom = useCallback(() => {
+ setNewCodeInput(generateRandomCode())
+ }, [])
+
+ if (error) {
+ return (
+
+
+
+
+ {error.message}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'referrals' && (
+
+ )}
+
+ {activeTab === 'codes' && (
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/Referral/ReferralHeader.tsx b/src/components/Referral/ReferralHeader.tsx
new file mode 100644
index 00000000000..b5157cac78c
--- /dev/null
+++ b/src/components/Referral/ReferralHeader.tsx
@@ -0,0 +1,14 @@
+import { Heading, Stack } from '@chakra-ui/react'
+import { useTranslate } from 'react-polyglot'
+
+import { RawText } from '@/components/Text'
+
+export const ReferralHeader = () => {
+ const translate = useTranslate()
+ return (
+
+ {translate('navBar.referral')}
+ {translate('referral.description')}
+
+ )
+}
diff --git a/src/components/Referral/ReferralStatsCards.tsx b/src/components/Referral/ReferralStatsCards.tsx
new file mode 100644
index 00000000000..eb16767dcc8
--- /dev/null
+++ b/src/components/Referral/ReferralStatsCards.tsx
@@ -0,0 +1,126 @@
+import { Card, CardBody, Heading, HStack, Icon, Skeleton, Text } from '@chakra-ui/react'
+import { FaUser } from 'react-icons/fa'
+import { useTranslate } from 'react-polyglot'
+
+type ReferralStatsCardsProps = {
+ currentRewards?: string
+ totalRewards?: string
+ totalReferrals?: number
+ isLoading: boolean
+}
+
+export const ReferralStatsCards = ({
+ currentRewards,
+ totalRewards,
+ totalReferrals,
+ isLoading,
+}: ReferralStatsCardsProps) => {
+ const translate = useTranslate()
+
+ if (isLoading) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+
+ ${currentRewards ?? '0.00'}
+
+
+ {translate('referral.currentRewards')}
+
+
+
+
+
+
+
+ ${totalRewards ?? '0.00'}
+
+
+ {translate('referral.totalRewards')}
+
+
+
+
+
+
+
+
+
+ {totalReferrals ?? 0}
+
+
+
+ {translate('referral.totalReferred')}
+
+
+
+ >
+ )
+}
diff --git a/src/components/Referral/ReferralTabs.tsx b/src/components/Referral/ReferralTabs.tsx
new file mode 100644
index 00000000000..8bace8f8ce6
--- /dev/null
+++ b/src/components/Referral/ReferralTabs.tsx
@@ -0,0 +1,69 @@
+import { Badge, Button, HStack, Text, useColorModeValue } from '@chakra-ui/react'
+import { useTranslate } from 'react-polyglot'
+
+type ReferralTab = 'referrals' | 'codes'
+
+type ReferralTabsProps = {
+ activeTab: ReferralTab
+ onTabChange: (tab: ReferralTab) => void
+}
+
+export const ReferralTabs = ({ activeTab, onTabChange }: ReferralTabsProps) => {
+ const translate = useTranslate()
+ const activeTabBg = useColorModeValue('background.surface.raised.base', 'white')
+ const activeTabColor = useColorModeValue('white', 'black')
+
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/Referral/index.ts b/src/components/Referral/index.ts
new file mode 100644
index 00000000000..da98d6151ab
--- /dev/null
+++ b/src/components/Referral/index.ts
@@ -0,0 +1 @@
+export { ReferralDashboard } from './ReferralDashboard'
diff --git a/src/config.ts b/src/config.ts
index febac2c0125..319401cf669 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -224,6 +224,7 @@ const validators = {
VITE_NOTIFICATIONS_SERVER_URL: url({ default: '' }),
VITE_FEATURE_ADDRESS_BOOK: bool({ default: false }),
VITE_FEATURE_APP_RATING: bool({ default: false }),
+ VITE_FEATURE_REFERRAL: bool({ default: false }),
}
function reporter({ errors }: envalid.ReporterOptions) {
diff --git a/src/hooks/useReferral/useReferral.tsx b/src/hooks/useReferral/useReferral.tsx
new file mode 100644
index 00000000000..28f723caf61
--- /dev/null
+++ b/src/hooks/useReferral/useReferral.tsx
@@ -0,0 +1,74 @@
+import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useMemo } from 'react'
+
+import { createReferralCode, getReferralStatsByOwner } from '../../lib/referral/api'
+import { selectWalletEnabledAccountIds } from '../../state/slices/common-selectors'
+import { useFeatureFlag } from '../useFeatureFlag/useFeatureFlag'
+
+import type { CreateReferralCodeRequest, ReferralStats } from '@/lib/referral/types'
+import { useAppSelector } from '@/state/store'
+
+export type UseReferralData = {
+ referralStats: ReferralStats | null
+ isLoadingReferralStats: boolean
+ error: Error | null
+ refetchReferralStats: () => void
+ createCode: (request: Omit) => Promise
+ isCreatingCode: boolean
+}
+
+export const useReferral = (): UseReferralData => {
+ const queryClient = useQueryClient()
+ const walletEnabledAccountIds = useAppSelector(selectWalletEnabledAccountIds)
+ const isWebServicesEnabled = useFeatureFlag('WebServices')
+
+ // Use the first account ID (full CAIP format) as owner identifier
+ // Backend will hash it for privacy
+ const ownerAddress = useMemo(() => {
+ if (walletEnabledAccountIds.length === 0) return null
+ return walletEnabledAccountIds[0]
+ }, [walletEnabledAccountIds])
+
+ // Get current month date range
+ const { startDate, endDate } = useMemo(() => {
+ const now = new Date()
+ const start = new Date(now.getFullYear(), now.getMonth(), 1)
+ const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
+ return { startDate: start, endDate: end }
+ }, [])
+
+ const {
+ data: referralStats,
+ isLoading: isLoadingReferralStats,
+ error,
+ refetch: refetchReferralStats,
+ } = useQuery({
+ queryKey: ['referralStats', ownerAddress, startDate, endDate],
+ queryFn:
+ ownerAddress && isWebServicesEnabled
+ ? () => getReferralStatsByOwner(ownerAddress, startDate, endDate)
+ : skipToken,
+ })
+
+ const { mutateAsync: createCodeMutation, isPending: isCreatingCode } = useMutation({
+ mutationFn: (request: Omit) => {
+ if (!ownerAddress) throw new Error('Wallet not connected')
+ return createReferralCode({ ...request, ownerAddress })
+ },
+ onSuccess: () => {
+ // Invalidate and refetch referral stats
+ queryClient.invalidateQueries({ queryKey: ['referralStats', ownerAddress] })
+ },
+ })
+
+ return {
+ referralStats: referralStats ?? null,
+ isLoadingReferralStats,
+ error: error ?? null,
+ refetchReferralStats,
+ createCode: async (request: Omit) => {
+ await createCodeMutation(request)
+ },
+ isCreatingCode,
+ }
+}
diff --git a/src/hooks/useReferralCapture/useReferralCapture.tsx b/src/hooks/useReferralCapture/useReferralCapture.tsx
new file mode 100644
index 00000000000..8c4d92e1422
--- /dev/null
+++ b/src/hooks/useReferralCapture/useReferralCapture.tsx
@@ -0,0 +1,49 @@
+import { useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+
+const REFERRAL_CODE_KEY = 'shapeshift.referralCode'
+
+/**
+ * Captures referral code from URL and stores it in localStorage
+ * This hook should be called early in the app lifecycle
+ */
+export const useReferralCapture = () => {
+ const location = useLocation()
+
+ useEffect(() => {
+ // Parse the URL search params
+ const searchParams = new URLSearchParams(location.search)
+ const refCode = searchParams.get('ref')
+
+ if (refCode) {
+ try {
+ localStorage.setItem(REFERRAL_CODE_KEY, refCode)
+ } catch (error) {
+ console.error('Failed to save referral code to localStorage:', error)
+ }
+ }
+ }, [location.search])
+}
+
+/**
+ * Gets the stored referral code from localStorage
+ */
+export const getStoredReferralCode = (): string | null => {
+ try {
+ return localStorage.getItem(REFERRAL_CODE_KEY)
+ } catch (error) {
+ console.error('Failed to get referral code from localStorage:', error)
+ return null
+ }
+}
+
+/**
+ * Clears the stored referral code (useful after successful registration)
+ */
+export const clearStoredReferralCode = (): void => {
+ try {
+ localStorage.removeItem(REFERRAL_CODE_KEY)
+ } catch (error) {
+ console.error('Failed to clear referral code from localStorage:', error)
+ }
+}
diff --git a/src/hooks/useUser/useUser.tsx b/src/hooks/useUser/useUser.tsx
index c7d9fc25f36..9eb832cb552 100644
--- a/src/hooks/useUser/useUser.tsx
+++ b/src/hooks/useUser/useUser.tsx
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'
import { getExpoToken } from '@/context/WalletProvider/MobileWallet/mobileMessageHandlers'
import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag'
+import { getStoredReferralCode } from '@/hooks/useReferralCapture/useReferralCapture'
import { isMobile } from '@/lib/globals'
import { getOrCreateUser, getOrRegisterDevice } from '@/lib/user/api'
import type { User } from '@/lib/user/types'
@@ -46,7 +47,13 @@ export const useUser = (): UseUserData => {
queryKey: ['user', walletEnabledAccountIds],
queryFn:
walletEnabledAccountIds.length > 0 && isWebServicesEnabled
- ? () => getOrCreateUser({ accountIds: walletEnabledAccountIds })
+ ? () => {
+ const referralCode = getStoredReferralCode()
+ return getOrCreateUser({
+ accountIds: walletEnabledAccountIds,
+ ...(referralCode && { referralCode }),
+ })
+ }
: skipToken,
staleTime: Infinity,
gcTime: Infinity,
diff --git a/src/lib/referral/api.ts b/src/lib/referral/api.ts
new file mode 100644
index 00000000000..060f1e2d1fa
--- /dev/null
+++ b/src/lib/referral/api.ts
@@ -0,0 +1,79 @@
+import type { AxiosError } from 'axios'
+import axios from 'axios'
+
+import type {
+ CreateReferralCodeRequest,
+ CreateReferralCodeResponse,
+ ReferralApiError,
+ ReferralStats,
+} from './types'
+
+const USER_SERVER_URL = import.meta.env.VITE_USER_SERVER_URL
+
+const handleApiError = (error: unknown): never => {
+ if (axios.isAxiosError(error)) {
+ const axiosError = error as AxiosError<{ message?: string; code?: string }>
+ const message = axiosError.response?.data?.message || axiosError.message
+ const code = axiosError.response?.data?.code
+ const statusCode = axiosError.response?.status
+
+ const apiError = new Error(message) as ReferralApiError
+ apiError.name = 'ReferralApiError'
+ apiError.code = code
+ apiError.statusCode = statusCode
+
+ throw apiError
+ }
+ throw error
+}
+
+export const getReferralStatsByOwner = async (
+ ownerAddress: string,
+ startDate?: Date,
+ endDate?: Date,
+): Promise => {
+ if (!USER_SERVER_URL) {
+ throw new Error('User server URL is not configured')
+ }
+
+ try {
+ const params = new URLSearchParams()
+ if (startDate) params.append('startDate', startDate.toISOString())
+ if (endDate) params.append('endDate', endDate.toISOString())
+
+ const response = await axios.get(
+ `${USER_SERVER_URL}/referrals/stats/${ownerAddress}${
+ params.toString() ? `?${params.toString()}` : ''
+ }`,
+ {
+ timeout: 10000,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ )
+ return response.data
+ } catch (error) {
+ return handleApiError(error)
+ }
+}
+
+export const createReferralCode = async (
+ request: CreateReferralCodeRequest,
+): Promise => {
+ if (!USER_SERVER_URL) {
+ throw new Error('User server URL is not configured')
+ }
+
+ try {
+ const response = await axios.post(
+ `${USER_SERVER_URL}/referrals/codes`,
+ request,
+ {
+ timeout: 10000,
+ headers: { 'Content-Type': 'application/json' },
+ },
+ )
+ return response.data
+ } catch (error) {
+ return handleApiError(error)
+ }
+}
diff --git a/src/lib/referral/types.ts b/src/lib/referral/types.ts
new file mode 100644
index 00000000000..b410ca02861
--- /dev/null
+++ b/src/lib/referral/types.ts
@@ -0,0 +1,51 @@
+export type ReferralCode = {
+ code: string
+ isActive: boolean
+ createdAt: Date | string
+ usageCount: number
+ maxUses?: number | null
+ expiresAt?: Date | string | null
+ swapCount?: number
+ swapVolumeUsd?: string
+ feesCollectedUsd?: string
+ referrerCommissionUsd?: string
+}
+
+export type ReferralStats = {
+ totalReferrals: number
+ activeCodesCount: number
+ totalCodesCount: number
+ totalFeesCollectedUsd?: string
+ totalReferrerCommissionUsd?: string
+ referralCodes: ReferralCode[]
+}
+
+export type CreateReferralCodeRequest = {
+ code: string
+ ownerAddress: string
+ maxUses?: number
+ expiresAt?: string
+}
+
+export type CreateReferralCodeResponse = {
+ id: string
+ code: string
+ ownerAddress: string
+ createdAt: string
+ updatedAt: string
+ isActive: boolean
+ maxUses?: number | null
+ expiresAt?: string | null
+}
+
+export class ReferralApiError extends Error {
+ code?: string
+ statusCode?: number
+
+ constructor(message: string, code?: string, statusCode?: number) {
+ super(message)
+ this.name = 'ReferralApiError'
+ this.code = code
+ this.statusCode = statusCode
+ }
+}
diff --git a/src/lib/tradeExecution.ts b/src/lib/tradeExecution.ts
index f0062bc467d..e4dff5a5ad0 100644
--- a/src/lib/tradeExecution.ts
+++ b/src/lib/tradeExecution.ts
@@ -179,6 +179,10 @@ export class TradeExecution {
chainflipSwapId: tradeQuote.steps[0]?.chainflipSpecific?.chainflipSwapId,
nearIntentsSpecific: tradeQuote.steps[0]?.nearIntentsSpecific,
relayTransactionMetadata: tradeQuote.steps[0]?.relayTransactionMetadata,
+ cowswapQuoteSpecific: tradeQuote.steps[0]?.cowswapQuoteResponse,
+ portalsTransactionMetadata: tradeQuote.steps[0]?.portalsTransactionMetadata,
+ zrxTransactionMetadata: tradeQuote.steps[0]?.zrxTransactionMetadata,
+ bebopTransactionMetadata: tradeQuote.steps[0]?.bebopTransactionMetadata,
stepIndex,
},
}
diff --git a/src/lib/user/types.ts b/src/lib/user/types.ts
index 2cf585a808c..57155095cee 100644
--- a/src/lib/user/types.ts
+++ b/src/lib/user/types.ts
@@ -30,6 +30,7 @@ export type User = {
export type GetOrCreateUserRequest = {
accountIds: string[]
+ referralCode?: string
}
export type RegisterDeviceRequest = {
diff --git a/src/pages/Fox/FoxEcosystemPage.tsx b/src/pages/Fox/FoxEcosystemPage.tsx
index 091a64cfc6d..091a8ccba1f 100644
--- a/src/pages/Fox/FoxEcosystemPage.tsx
+++ b/src/pages/Fox/FoxEcosystemPage.tsx
@@ -1,3 +1,4 @@
+import { Box } from '@chakra-ui/react'
import { lazy } from 'react'
import { useTranslate } from 'react-polyglot'
@@ -9,6 +10,8 @@ import { FoxToken } from './components/FoxToken'
import { Main } from '@/components/Layout/Main'
import { SEO } from '@/components/Layout/Seo'
+import { ReferralDashboard } from '@/components/Referral'
+import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag'
import { FoxPageProvider } from '@/pages/Fox/hooks/useFoxPageContext'
import { makeSuspenseful } from '@/utils/makeSuspenseful'
@@ -27,11 +30,17 @@ const RFOXSection = makeSuspenseful(
export const FoxEcosystemPage = () => {
const translate = useTranslate()
+ const isReferralEnabled = useFeatureFlag('Referral')
return (
+ {isReferralEnabled && (
+
+
+
+ )}
diff --git a/src/pages/Referral/Referral.tsx b/src/pages/Referral/Referral.tsx
new file mode 100644
index 00000000000..9f800f9a3a7
--- /dev/null
+++ b/src/pages/Referral/Referral.tsx
@@ -0,0 +1,511 @@
+import {
+ Alert,
+ AlertIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ Flex,
+ Heading,
+ HStack,
+ Icon,
+ IconButton,
+ Input,
+ Skeleton,
+ Stack,
+ Text,
+ useColorModeValue,
+ useToast,
+} from '@chakra-ui/react'
+import { useCallback, useMemo, useState } from 'react'
+import { FaCopy, FaPlus, FaUser } from 'react-icons/fa'
+import { FaXTwitter } from 'react-icons/fa6'
+import { useTranslate } from 'react-polyglot'
+
+import { Main } from '@/components/Layout/Main'
+import { RawText } from '@/components/Text'
+import { useReferral } from '@/hooks/useReferral/useReferral'
+
+const ReferralHeader = () => {
+ const translate = useTranslate()
+ return (
+
+ {translate('navBar.referral')}
+ {translate('referral.description')}
+
+ )
+}
+
+const generateRandomCode = () => {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+ let code = ''
+ for (let i = 0; i < 8; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return code
+}
+
+export const Referral = () => {
+ const translate = useTranslate()
+ const toast = useToast()
+ const { referralStats, isLoadingReferralStats, error, createCode, isCreatingCode } = useReferral()
+ const activeTabBg = useColorModeValue('background.surface.raised.base', 'white')
+ const activeTabColor = useColorModeValue('white', 'black')
+
+ const [newCodeInput, setNewCodeInput] = useState('')
+
+ const defaultCode = useMemo(() => {
+ if (!referralStats?.referralCodes.length) return null
+ return referralStats.referralCodes.find(code => code.isActive) || referralStats.referralCodes[0]
+ }, [referralStats])
+
+ const handleCreateCode = useCallback(async () => {
+ const code = newCodeInput.trim() || generateRandomCode()
+
+ try {
+ await createCode({ code })
+ setNewCodeInput('')
+ toast({
+ title: translate('referral.codeCreated'),
+ description: translate('referral.codeCreatedDescription', { code }),
+ status: 'success',
+ duration: 3000,
+ isClosable: true,
+ })
+ } catch (err) {
+ toast({
+ title: translate('common.error'),
+ description: err instanceof Error ? err.message : translate('referral.createCodeFailed'),
+ status: 'error',
+ duration: 5000,
+ isClosable: true,
+ })
+ }
+ }, [createCode, newCodeInput, toast, translate])
+
+ const handleGenerateRandom = useCallback(() => {
+ setNewCodeInput(generateRandomCode())
+ }, [])
+
+ const [activeTab, setActiveTab] = useState<'referrals' | 'leaderboard' | 'codes'>('referrals')
+
+ const handleShareOnX = useCallback((code: string) => {
+ const shareUrl = `${window.location.origin}/#/?ref=${code}`
+ const text = `Join me on ShapeShift using my referral code ${code}!`
+ const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
+ text,
+ )}&url=${encodeURIComponent(shareUrl)}`
+ window.open(twitterUrl, '_blank', 'noopener,noreferrer')
+ }, [])
+
+ const handleCopyCode = useCallback(
+ (code: string) => {
+ const shareUrl = `${window.location.origin}/#/?ref=${code}`
+ navigator.clipboard.writeText(shareUrl)
+ toast({
+ title: translate('common.copied'),
+ status: 'success',
+ duration: 2000,
+ })
+ },
+ [toast, translate],
+ )
+
+ if (error) {
+ return (
+ }>
+
+
+ {error.message}
+
+
+ )
+ }
+
+ return (
+ }>
+
+
+
+
+
+
+
+ {translate('referral.yourReferralCode')}
+
+
+ {isLoadingReferralStats ? (
+
+ ) : defaultCode ? (
+ defaultCode.code
+ ) : (
+ 'N/A'
+ )}
+
+
+ {defaultCode && (
+
+ }
+ size='md'
+ colorScheme='whiteAlpha'
+ borderRadius='100%'
+ bg='whiteAlpha.200'
+ onClick={() => handleShareOnX(defaultCode.code)}
+ />
+ }
+ size='md'
+ colorScheme='whiteAlpha'
+ bg='whiteAlpha.200'
+ borderRadius='100%'
+ onClick={() => handleCopyCode(defaultCode.code)}
+ />
+
+ )}
+
+
+
+
+
+
+
+ {isLoadingReferralStats ? (
+
+ ) : (
+ `$${referralStats?.totalReferrerCommissionUsd ?? '0.00'}`
+ )}
+
+
+ {translate('referral.currentRewards')}
+
+
+
+
+
+
+
+ {isLoadingReferralStats ? (
+
+ ) : (
+ `$${referralStats?.totalFeesCollectedUsd ?? '0.00'}`
+ )}
+
+
+ {translate('referral.totalRewards')}
+
+
+
+
+
+
+
+
+
+ {isLoadingReferralStats ? (
+
+ ) : (
+ referralStats?.totalReferrals ?? 0
+ )}
+
+
+
+ {translate('referral.totalReferred')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'referrals' && (
+
+ {isLoadingReferralStats ? (
+
+
+
+
+
+ ) : referralStats?.referralCodes.length ? (
+ <>
+
+ {translate('referral.address')}
+
+ {translate('referral.referrals')}
+
+
+ {translate('referral.volume')}
+
+
+
+
+ {referralStats.referralCodes.map(code => (
+
+
+
+
+ {code.code}
+
+
+ {code.usageCount}
+
+
+ ${code.swapVolumeUsd || '0.00'}
+
+
+
+ }
+ size='sm'
+ colorScheme='twitter'
+ variant='ghost'
+ onClick={() => handleShareOnX(code.code)}
+ />
+ }
+ size='sm'
+ variant='ghost'
+ onClick={() => handleCopyCode(code.code)}
+ />
+
+
+
+
+
+ ))}
+ >
+ ) : (
+
+
+
+ {translate('referral.noCodes')}
+
+
+
+ )}
+
+ )}
+
+ {activeTab === 'codes' && (
+
+
+
+ {translate('referral.createNewCode')}
+
+
+
+ setNewCodeInput(e.target.value.toUpperCase())}
+ placeholder={translate('referral.enterCodeOrLeaveEmpty')}
+ maxLength={20}
+ bg='background.surface.raised.base'
+ border='none'
+ />
+ }
+ variant='outline'
+ flexShrink={0}
+ borderRadius='full'
+ border='1px solid'
+ borderColor='gray.700'
+ backgroundColor='background.surface.raised.base'
+ >
+ {translate('referral.random')}
+
+
+
+
+
+
+
+ {isLoadingReferralStats ? (
+
+
+
+
+
+ ) : referralStats?.referralCodes.length ? (
+ <>
+
+ {translate('referral.code')}
+
+ {translate('referral.usages')}
+
+
+ {translate('referral.status')}
+
+
+ {translate('referral.createdAt')}
+
+
+
+
+ {referralStats.referralCodes.map(code => (
+
+
+
+
+ {code.code}
+
+
+ {code.usageCount}
+ {code.maxUses ? ` / ${code.maxUses}` : ''}
+
+
+
+ {code.isActive
+ ? translate('referral.active')
+ : translate('referral.inactive')}
+
+
+
+ {new Date(code.createdAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+ }
+ size='sm'
+ colorScheme='twitter'
+ variant='ghost'
+ onClick={() => handleShareOnX(code.code)}
+ />
+ }
+ size='sm'
+ variant='ghost'
+ onClick={() => handleCopyCode(code.code)}
+ />
+
+
+
+
+
+ ))}
+ >
+ ) : (
+
+
+
+ {translate('referral.noCodes')}
+
+
+
+ )}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts
index 1b0a3769662..afbade50ec7 100644
--- a/src/state/slices/preferencesSlice/preferencesSlice.ts
+++ b/src/state/slices/preferencesSlice/preferencesSlice.ts
@@ -106,6 +106,7 @@ export type FeatureFlags = {
WebServices: boolean
AddressBook: boolean
AppRating: boolean
+ Referral: boolean
}
export type Flag = keyof FeatureFlags
@@ -246,6 +247,7 @@ const initialState: Preferences = {
WebServices: getConfig().VITE_FEATURE_NOTIFICATIONS_WEBSERVICES,
AddressBook: getConfig().VITE_FEATURE_ADDRESS_BOOK,
AppRating: getConfig().VITE_FEATURE_APP_RATING,
+ Referral: getConfig().VITE_FEATURE_REFERRAL,
},
selectedLocale: simpleLocale(),
hasWalletSeenTcyClaimAlert: {},