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