diff --git a/.env b/.env
index 0c1e003713e..e4826821163 100644
--- a/.env
+++ b/.env
@@ -315,3 +315,5 @@ VITE_FEATURE_EARN_TAB=true
# Userback feedback widget
VITE_FEATURE_USERBACK=true
VITE_USERBACK_TOKEN=A-3gHopRTd55QqxXGsJd0XLVVG3
+
+VITE_FEATURE_REFERRAL=false
diff --git a/.env.development b/.env.development
index d93d878a3ff..72b591a994e 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/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/Routes/RoutesCommon.tsx b/src/Routes/RoutesCommon.tsx
index 1fb96062dce..be9838c9e99 100644
--- a/src/Routes/RoutesCommon.tsx
+++ b/src/Routes/RoutesCommon.tsx
@@ -1,6 +1,6 @@
import { TimeIcon } from '@chakra-ui/icons'
import { lazy } from 'react'
-import { FaCreditCard, FaFlag } from 'react-icons/fa'
+import { FaCreditCard, FaFlag, FaUsers } from 'react-icons/fa'
import { RiExchangeFundsLine } from 'react-icons/ri'
import { TbGraph, TbTrendingUp } from 'react-icons/tb'
@@ -147,6 +147,16 @@ const YieldDetailPage = makeSuspenseful(
true,
)
+const Referral = makeSuspenseful(
+ lazy(() =>
+ import('@/pages/Referral/Referral').then(({ Referral }) => ({
+ default: Referral,
+ })),
+ ),
+ {},
+ true,
+)
+
const WalletConnectDeepLink = makeSuspenseful(
lazy(() =>
import('@/pages/WalletConnectDeepLink/WalletConnectDeepLink').then(
@@ -271,6 +281,16 @@ export const routes: Route[] = [
mobileNav: false,
disable: !getConfig().VITE_FEATURE_YIELD_XYZ || !getConfig().VITE_FEATURE_YIELDS_PAGE,
},
+ {
+ path: '/referral',
+ label: 'navBar.referral',
+ icon: ,
+ main: Referral,
+ category: RouteCategory.Featured,
+ mobileNav: false,
+ priority: 4,
+ disable: !getConfig().VITE_FEATURE_REFERRAL,
+ },
{
path: '/ramp/*',
label: 'navBar.buyCrypto',
diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json
index 224faffd4aa..9ee6976d282 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",
@@ -90,6 +91,7 @@
"terms": "Terms of Service",
"clear": "Clear",
"copy": "Copy",
+ "shareOnX": "Share on X",
"copied": "Copied to clipboard.",
"copyFailed": "Failed to copy to clipboard",
"copyFailedDescription": "Please copy manually",
@@ -523,7 +525,8 @@
"tokens": "Tokens",
"swap": "Swap",
"yields": "Yields",
- "earn": "Earn"
+ "earn": "Earn",
+ "referral": "Referral"
},
"shapeShiftMenu": {
"products": "Products",
@@ -2152,6 +2155,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/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..22c87141695
--- /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..13488af4210
--- /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..3b2ce9682b5
--- /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 a252ca0ced6..c4c6116b957 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -245,6 +245,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 }),
VITE_FEATURE_YIELD_XYZ: bool({ default: false }),
VITE_FEATURE_YIELDS_PAGE: bool({ default: false }),
VITE_FEATURE_EARN_TAB: bool({ default: false }),
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/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/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 a5e5994e0b3..4752cb9d980 100644
--- a/src/state/slices/preferencesSlice/preferencesSlice.ts
+++ b/src/state/slices/preferencesSlice/preferencesSlice.ts
@@ -108,6 +108,7 @@ export type FeatureFlags = {
WebServices: boolean
AddressBook: boolean
AppRating: boolean
+ Referral: boolean
YieldXyz: boolean
YieldsPage: boolean
YieldMultiAccount: boolean
@@ -254,6 +255,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,
YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ,
YieldsPage: getConfig().VITE_FEATURE_YIELDS_PAGE,
YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT,
diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts
index b4ef84639e8..9d629cecdc8 100644
--- a/src/test/mocks/store.ts
+++ b/src/test/mocks/store.ts
@@ -185,6 +185,7 @@ export const mockStore: ReduxState = {
YieldsPage: false,
YieldMultiAccount: false,
EarnTab: false,
+ Referral: false,
},
showTopAssetsCarousel: true,
quickBuyAmounts: [10, 50, 100],