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