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' + /> + + + + + + ) +} 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' + /> + + + + + + + + {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: {},