diff --git a/src/App.tsx b/src/App.tsx index f033a3ff..59eda8a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,8 @@ import { DEFAULT_MARKET_ID } from "./constants/applications"; import Portfolio from "./pages/Portfolio"; import { AppContainer } from "./app-styles"; import SDKProvider from "./providers/SDKProvider"; -import ScrollToTop from './utils/scrollToTop' +import ScrollToTop from "./utils/scrollToTop"; +import Referrals from "./pages/Referrals"; import Trackers from "./components/Trackers"; const App = () => { @@ -42,6 +43,7 @@ const App = () => { /> } /> } /> + } /> diff --git a/src/assets/icons/svg-icons.tsx b/src/assets/icons/svg-icons.tsx index 7af323ff..2f2f0577 100644 --- a/src/assets/icons/svg-icons.tsx +++ b/src/assets/icons/svg-icons.tsx @@ -18,3 +18,33 @@ export const InfoIcon: FC = () => ( /> ); + +export const CopyGradientIcon: FC = () => ( + + + + + + + + + +); diff --git a/src/components/NavBar/NavLinksSection.tsx b/src/components/NavBar/NavLinksSection.tsx index 03797b50..0789c549 100644 --- a/src/components/NavBar/NavLinksSection.tsx +++ b/src/components/NavBar/NavLinksSection.tsx @@ -9,10 +9,10 @@ import { TradeIcon, TradeActiveIcon, } from "../../assets/icons/navBar-icons/trade"; -// import { -// RocketIcon, -// RocketActiveIcon, -// } from "../../assets/icons/navBar-icons/rocket"; +import { + RocketIcon, + RocketActiveIcon, +} from "../../assets/icons/navBar-icons/rocket"; // import { // PowercardIcon, // PowercardActiveIcon, @@ -67,6 +67,13 @@ const NavLinksSection: React.FC = ({ activeIcon: , showOnMobile: true, }, + { + to: "/referrals", + label: "Referrals", + icon: , // TODO change icon + activeIcon: , // TODO change icon + showOnMobile: true, + }, // { // to: "/powercards", // label: "PowerCards", diff --git a/src/constants/applications.ts b/src/constants/applications.ts index 87a31a5e..c5c2583a 100644 --- a/src/constants/applications.ts +++ b/src/constants/applications.ts @@ -5,6 +5,8 @@ export enum MARKET_CHART_URL { DEFAULT = "https://api.overlay.market/charts/v1/charts", } +export const REFERRAL_API_BASE_URL = "https://api.overlay.market/referral"; + export const DEFAULT_MARKET_ID = encodeURIComponent("BTC Dominance"); export const TRADE_POLLING_INTERVAL = 30000; diff --git a/src/pages/Referrals/AliasSubmit.tsx b/src/pages/Referrals/AliasSubmit.tsx new file mode 100644 index 00000000..c98a8a7d --- /dev/null +++ b/src/pages/Referrals/AliasSubmit.tsx @@ -0,0 +1,307 @@ +import { useEffect, useState } from "react"; +import { + GradientLoaderButton, + GradientSolidButton, +} from "../../components/Button"; +import { Flex, Text } from "@radix-ui/themes"; +import { + ContentContainer, + GradientText, + StyledInput, +} from "./referrals-styles"; +import theme from "../../theme"; +import useDebounce from "../../hooks/useDebounce"; +import { REFERRAL_API_BASE_URL } from "../../constants/applications"; +import { useAccount, useSignTypedData } from "wagmi"; +import { isAddress } from "viem"; +import { CopyGradientIcon } from "../../assets/icons/svg-icons"; +import { CopyLink, Toast } from "./alias-submit-styles"; +import { useLocation } from "react-router-dom"; + +type AliasSubmitProps = { + alias: string | null; +}; + +const AliasSubmit: React.FC = ({ alias }) => { + const { address: affiliateAddress } = useAccount(); + const { signTypedDataAsync } = useSignTypedData(); + const location = useLocation(); + + const [aliasValue, setAliasValue] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [fetchingSignature, setFetchingSignature] = useState(false); + const [registeringAlias, setRegisteringAlias] = useState(false); + const [succeededToSubmit, setSucceededToSubmit] = useState(false); + const [toastVisible, setToastVisible] = useState(false); + const [registeredAlias, setRegisteredAlias] = useState(alias); + const debouncedAliasValue = useDebounce(aliasValue, 500); + + useEffect(() => { + setErrorMessage(null); + setSuccessMessage(null); + }, [debouncedAliasValue]); + + useEffect(() => { + const regex = /^[a-zA-Z0-9]{3,8}$/; + if (debouncedAliasValue !== "") { + if (!regex.test(debouncedAliasValue)) { + setErrorMessage("Input must be 3-8 alphanumeric characters"); + } else { + setErrorMessage(null); + checkAliasAvailability(debouncedAliasValue.toLowerCase()); + } + } + }, [debouncedAliasValue]); + + const checkAliasAvailability = async (alias: string) => { + try { + const response = await fetch( + REFERRAL_API_BASE_URL + `/affiliates/aliases/${alias}` + ); + if (response.status === 404) { + setSuccessMessage("Alias is available! Please sign to confirm"); + } + if (response.status === 200 && alias !== "") { + setErrorMessage("Alias is already taken"); + } + } catch (error) { + console.error("Error checking alias availability", error); + } + }; + + // EIP 712 signature data + const domain = { + name: "Overlay Referrals", + version: "1.0", + }; + const types = { + SetAlias: [ + { name: "affiliate", type: "address" }, + { name: "alias", type: "string" }, + ], + }; + const primaryType = "SetAlias"; + + const fetchSignature = async (affiliate: string, alias: string) => { + setFetchingSignature(true); + let signature; + try { + signature = await signTypedDataAsync({ + domain, + types, + primaryType, + message: { affiliate, alias }, + }); + } catch (error) { + const errorWithDetails = error as { details?: string }; + if (errorWithDetails?.details) { + setErrorMessage(`${errorWithDetails.details}`); + } else { + setErrorMessage("unable to get error, see console"); + } + console.error("Error fetching signature:", error); + } finally { + setFetchingSignature(false); + } + return signature; + }; + + const registerAlias = async ( + signature: string, + affiliate: string, + alias: string + ) => { + setRegisteringAlias(true); + try { + const response = await fetch( + REFERRAL_API_BASE_URL + `/affiliates/aliases`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + address: affiliate, + alias, + signature, + }), + } + ); + + if (!response.ok) { + const result = await response.json(); + setErrorMessage( + `Failed to register alias: ${ + result?.message ?? "Unable to get error message" + }` + ); + throw new Error(`Failed to register alias: ${JSON.stringify(result)}`); + } + + const result = await response.json(); + + if (result.alias && result.createdAt) { + setSucceededToSubmit(true); + setRegisteredAlias(result.alias); + } + } catch (error) { + console.error("Error registering alias:", error); + } finally { + setRegisteringAlias(false); + } + }; + + const handleAliasConfirm = async () => { + if (!affiliateAddress || !debouncedAliasValue) return; + if (!isAddress(affiliateAddress)) { + setErrorMessage("Invalid affiliate address"); + return; + } + const signature = await fetchSignature( + affiliateAddress.toLowerCase(), + debouncedAliasValue.toLowerCase() + ); + signature && + (await registerAlias( + signature, + affiliateAddress.toLowerCase(), + debouncedAliasValue.toLowerCase() + )); + setSuccessMessage(null); + }; + + const showToast = (duration = 3000) => { + setToastVisible(true); + + setTimeout(() => { + setToastVisible(false); + }, duration); + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText( + `${window.location.origin}${ + location.pathname + }?referrer=${registeredAlias?.toLowerCase()}` + ); + showToast(); + }; + + return ( + <> + {!succeededToSubmit && ( + <> + {alias && ( + + + Your affiliate alias is active + + {alias.toUpperCase()} + + + Copy referral link + + + + + Link copied to clipboard + + + + + )} + {!alias && ( + + + Affiliate Alias + + + + + Choose a unique name to identify your affiliate profile + + setAliasValue(e.target.value.trim())} + placeholder="Enter 3-8 alphanumeric chars" + /> + + {errorMessage && ( + + {errorMessage} + + )} + {successMessage && ( + + {successMessage} + + )} + + + {successMessage && + (fetchingSignature || registeringAlias ? ( + + ) : ( + + ))} + + )} + + )} + + {succeededToSubmit && ( + + 🎉 + + Success! + + + + Alias{" "} + + {registeredAlias?.toUpperCase()} + {" "} + has been successfully registered! + + + + Copy referral link + + + + + Link copied to clipboard + + + + )} + + ); +}; + +export default AliasSubmit; diff --git a/src/pages/Referrals/alias-submit-styles.ts b/src/pages/Referrals/alias-submit-styles.ts new file mode 100644 index 00000000..1a403406 --- /dev/null +++ b/src/pages/Referrals/alias-submit-styles.ts @@ -0,0 +1,49 @@ +import { Box } from "@radix-ui/themes"; +import styled from "styled-components"; +import theme from "../../theme"; + +export const CopyLink = styled(Box)` + cursor: pointer; + position: relative; + + &::after { + content: 'Copy Link'; + position: absolute; + top: 50%; + left: 100%; + transform: translate(8px, -50%); + color: #888; + font-size: 12px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; + } + + &:hover::after { + opacity: 1; + visibility: visible; +`; + +export const Toast = styled.div<{visible: string}>` + position: fixed; + bottom: 110px; + width: 160px; + left: 50%; + transform: translateX(-50%); + background-color: ${theme.color.grey7}; + color: ${theme.color.grey2}; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + opacity: ${({ visible }) => (visible === 'true' ? 1 : 0)}; + visibility: ${({ visible }) => (visible === 'true' ? 'visible' : 'hidden')}; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 1000; + + @media (min-width: ${theme.breakpoints.sm}) { + bottom: 20px; + left: 52.5%; + } +`; \ No newline at end of file diff --git a/src/pages/Referrals/index.tsx b/src/pages/Referrals/index.tsx new file mode 100644 index 00000000..61697110 --- /dev/null +++ b/src/pages/Referrals/index.tsx @@ -0,0 +1,422 @@ +import { useEffect, useState } from "react"; +import { + GradientLoaderButton, + GradientSolidButton, +} from "../../components/Button"; +import { useAccount, useSignTypedData } from "wagmi"; +import { shortenAddress } from "../../utils/web3"; +import { isAddress } from "viem"; +import { useSearchParams } from "react-router-dom"; +import { useModalHelper } from "../../components/ConnectWalletModal/utils"; +import { Flex, Text } from "@radix-ui/themes"; +import { + ContentContainer, + GradientBorderBox, + GradientText, + LineSeparator, + StyledInput, +} from "./referrals-styles"; +import theme from "../../theme"; +import Loader from "../../components/Loader"; +import AliasSubmit from "./AliasSubmit"; +import { REFERRAL_API_BASE_URL } from "../../constants/applications"; + +const Referrals: React.FC = () => { + const [searchParams] = useSearchParams(); + const referralAddressFromURL = searchParams.get("referrer"); + const { openModal } = useModalHelper(); + + const { address: traderAddress, isConnecting } = useAccount(); + const { signTypedDataAsync } = useSignTypedData(); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [checkingTraderStatus, setCheckingTraderStatus] = useState(false); + const [checkingAffiliateStatus, setCheckingAffiliateStatus] = useState(false); + const [fetchingSignature, setFetchingSignature] = useState(false); + const [affiliate, setAffiliate] = useState(""); + const [traderSignedUpTo, setTraderSignedUpTo] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [succeededToSignUp, setSucceededToSignUp] = useState(false); + const [isAffiliate, setIsAffiliate] = useState(false); + const [alias, setAlias] = useState(null); + + useEffect(() => { + if (referralAddressFromURL) { + setAffiliate(referralAddressFromURL); + } + }, [referralAddressFromURL]); + + useEffect(() => { + setErrorMessage(null); + setTraderSignedUpTo(""); + }, [affiliate, traderAddress]); + + // Check affiliate status + const checkAffiliateStatus = async (address: string) => { + setCheckingAffiliateStatus(true); + let affiliateStatus = false; + try { + const { isValid, alias } = await getAffiliateAlias(address); + setIsAffiliate(isValid); + setAlias(alias); + affiliateStatus = isValid; + } catch (error) { + console.error("Error checking affiliate status:", error); + } finally { + setCheckingAffiliateStatus(false); + } + return affiliateStatus; + }; + + // Check trader status + const checkTraderStatus = async (address: string) => { + setCheckingTraderStatus(true); + try { + const response = await fetch( + REFERRAL_API_BASE_URL + `/signatures/check/${address.toLowerCase()}` + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch trader status: ${response.statusText}` + ); + } + const { affiliate }: { exists: boolean; affiliate: string } = + await response.json(); + + const { alias } = await getAffiliateAlias(affiliate); + if (alias) { + setTraderSignedUpTo(alias); + } else { + setTraderSignedUpTo(shortenAddress(affiliate, 7)); + } + } catch (error) { + console.error("Error checking trader status:", error); + } finally { + setCheckingTraderStatus(false); + } + }; + + useEffect(() => { + const checkStatus = async () => { + if (traderAddress) { + const affiliateStatus = await checkAffiliateStatus(traderAddress); + if (!affiliateStatus) { + await checkTraderStatus(traderAddress); + } + } else { + setTraderSignedUpTo(""); + setSucceededToSignUp(false); + setIsAffiliate(false); + setAlias(null); + } + setInitialLoading(false); + }; + + checkStatus(); + }, [traderAddress]); + + const getAffiliateAddress = async (alias: string) => { + try { + const response = await fetch( + REFERRAL_API_BASE_URL + `/affiliates/aliases/${alias.toLowerCase()}` + ); + + if (response.status === 404) { + return null; + } + if (response.status === 200 && alias !== "") { + const result = await response.json(); + return result.address; + } + } catch (error) { + console.error("Error getting affiliate", error); + } + }; + + const getAffiliateAlias = async (address: string) => { + try { + const response = await fetch( + REFERRAL_API_BASE_URL + `/affiliates/${address.toLowerCase()}` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch affiliate status: ${response.statusText}` + ); + } + const { isValid, alias }: { isValid: boolean; alias: string | null } = + await response.json(); + return { isValid, alias }; + } catch (error) { + console.error("Error getting affiliate status", error); + return { isValid: false, alias: null }; + } + }; + + const postSignature = async (signature: string, affiliate: string) => { + if (!traderAddress) { + console.error("Trader address is missing"); + } + + setLoading(true); + try { + const response = await fetch(REFERRAL_API_BASE_URL + `/signatures`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + trader: traderAddress?.toLowerCase(), + affiliate: affiliate.toLowerCase(), + signature, + }), + }); + + if (!response.ok) { + const result = await response.json(); + setErrorMessage( + `Failed to post signature: ${ + result?.message ?? "Unable to get error message" + }` + ); + throw new Error(`Failed to post signature: ${JSON.stringify(result)}`); + } + + const result = await response.json(); + + if (result.createdAt && result.affiliate) { + setSucceededToSignUp(true); + if (result.affiliateAlias) { + setTraderSignedUpTo(result.affiliateAlias); + } else { + setTraderSignedUpTo(shortenAddress(result.affiliate, 7)); + } + // TODO create toast notification + } + } catch (error) { + console.error("Error posting signature:", error); + } finally { + setLoading(false); + } + }; + + // EIP 712 signature data + const domain = { + name: "Overlay Referrals", + version: "1.0", + }; + const types = { + AffiliateTo: [{ name: "affiliate", type: "address" }], + }; + const primaryType = "AffiliateTo"; + + const fetchSignature = async (affiliate: string) => { + setFetchingSignature(true); + let signature; + const message = { + affiliate: affiliate.toLowerCase(), + }; + + try { + signature = await signTypedDataAsync({ + domain, + types, + primaryType, + message, + }); + } catch (error) { + const errorWithDetails = error as { details?: string }; + if (errorWithDetails?.details) { + setErrorMessage(`${errorWithDetails.details}`); + } else { + setErrorMessage("unable to get error, see console"); + } + console.error("Error fetching signature:", error); + } finally { + setFetchingSignature(false); + } + return signature; + }; + + const handleSubmit = async () => { + setErrorMessage(null); + if (!affiliate || !traderAddress) return; + + let affiliateAddress: string | null = null; + let isAffiliateValid = true; + + if (isAddress(affiliate)) { + affiliateAddress = affiliate; + const { isValid } = await getAffiliateAlias(affiliate); + + if (!isValid) { + setErrorMessage("Affiliate not found"); + isAffiliateValid = isValid; + } + } else { + const fetchedAffiliateAddress = await getAffiliateAddress(affiliate); + + if (fetchedAffiliateAddress) { + affiliateAddress = fetchedAffiliateAddress; + } else { + setErrorMessage("Invalid affiliate"); + } + } + + if (affiliateAddress && isAffiliateValid) { + const signature = await fetchSignature(affiliateAddress); + signature && (await postSignature(signature, affiliateAddress)); + } + }; + + return ( + + + + Referrals + + + + + + {isConnecting || + checkingAffiliateStatus || + checkingTraderStatus || + initialLoading ? ( + + + + ) : ( + + {isAffiliate ? ( + + ) : ( + <> + {!succeededToSignUp && ( + <> + {traderSignedUpTo && ( + + + + You are already signed up for the referral program + to{" "} + + + {isAddress(traderSignedUpTo) + ? traderSignedUpTo + : traderSignedUpTo.toUpperCase()} + + + + + )} + {!traderSignedUpTo && ( + + + Affiliate Address + + + + + setAffiliate(e.target.value.trim()) + } + placeholder="Enter Affiliate Address" + /> + + {errorMessage && ( + + {errorMessage} + + )} + + + {!traderAddress ? ( + + ) : fetchingSignature || loading ? ( + + ) : ( + + )} + + )} + + )} + + {succeededToSignUp && ( + + 🎉 + + Success! + + + + You signed up for the referral program to + + + {isAddress(traderSignedUpTo) + ? traderSignedUpTo + : traderSignedUpTo.toUpperCase()} + + + + )} + + )} + + )} + + + ); +}; + +export default Referrals; diff --git a/src/pages/Referrals/referrals-styles.ts b/src/pages/Referrals/referrals-styles.ts new file mode 100644 index 00000000..356fbfa5 --- /dev/null +++ b/src/pages/Referrals/referrals-styles.ts @@ -0,0 +1,71 @@ +import { Flex, Text } from "@radix-ui/themes" +import styled from "styled-components" +import theme from "../../theme" + +export const LineSeparator = styled(Flex)` + @media (min-width: ${theme.breakpoints.sm}) { + height: 0; + width: calc(100% - ${theme.headerSize.tabletWidth}); + position: absolute; + top: ${theme.headerSize.height}; + left: ${theme.headerSize.tabletWidth}; + border-bottom: 1px solid ${theme.color.darkBlue}; + } + + @media (min-width: ${theme.breakpoints.md}) { + width: calc(100% - ${theme.headerSize.width}); + left: ${theme.headerSize.width}; + + } +` + +export const GradientBorderBox = styled(Flex)` + @media (min-width: ${theme.breakpoints.sm}) { + border: solid 1px transparent; + border-radius: 16px; + background: linear-gradient(${theme.color.background}, ${theme.color.background}) padding-box, + linear-gradient(90deg, #ffc955 0%, #ff7cd5 100%) border-box; + } +`; + +export const ContentContainer = styled(Flex)` + flex-direction: column; + width: 343px; + gap: 20px; + padding: 0; + + @media (min-width: ${theme.breakpoints.sm}) { + width: 424px; + padding: 32px; + } +`; + +export const StyledInput = styled.input` + width: 100%; + padding: 16px; + outline: none; + border: none; + border-radius: 8px; + box-sizing: border-box; + color: ${theme.color.grey2}; + background-color: ${theme.color.grey4}; + font-size: 14px; + font-weight: 600; + font-family: Inter; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &::placeholder { + color: #6c7180; + } +`; + +export const GradientText = styled(Text)` + width: fit-content; + background: linear-gradient(90deg, #ffc955 0%, #ff7cd5 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-fill-color: transparent; +`; \ No newline at end of file