Commit 4b6e41c3 authored by Max Alekseenko's avatar Max Alekseenko

link rewards program to account

parent 7531e24b
import { useBoolean } from '@chakra-ui/react'; import { useBoolean } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useAccount, useSignMessage } from 'wagmi'; import { useAccount, useSignMessage } from 'wagmi';
...@@ -15,11 +16,13 @@ import type { ...@@ -15,11 +16,13 @@ import type {
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import decodeJWT from 'lib/decodeJWT';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam'; import removeQueryParam from 'lib/router/removeQueryParam';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type TRewardsContext = { type TRewardsContext = {
balancesQuery: UseQueryResult<RewardsUserBalancesResponse, ResourceError<unknown>>; balancesQuery: UseQueryResult<RewardsUserBalancesResponse, ResourceError<unknown>>;
...@@ -27,6 +30,7 @@ type TRewardsContext = { ...@@ -27,6 +30,7 @@ type TRewardsContext = {
referralsQuery: UseQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>; referralsQuery: UseQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>;
rewardsConfigQuery: UseQueryResult<RewardsConfigResponse, ResourceError<unknown>>; rewardsConfigQuery: UseQueryResult<RewardsConfigResponse, ResourceError<unknown>>;
apiToken: string | undefined; apiToken: string | undefined;
isInitialized: boolean;
isLoginModalOpen: boolean; isLoginModalOpen: boolean;
openLoginModal: () => void; openLoginModal: () => void;
closeLoginModal: () => void; closeLoginModal: () => void;
...@@ -43,6 +47,7 @@ const RewardsContext = createContext<TRewardsContext>({ ...@@ -43,6 +47,7 @@ const RewardsContext = createContext<TRewardsContext>({
referralsQuery: createDefaultQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>(), referralsQuery: createDefaultQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>(),
rewardsConfigQuery: createDefaultQueryResult<RewardsConfigResponse, ResourceError<unknown>>(), rewardsConfigQuery: createDefaultQueryResult<RewardsConfigResponse, ResourceError<unknown>>(),
apiToken: undefined, apiToken: undefined,
isInitialized: false,
isLoginModalOpen: false, isLoginModalOpen: false,
openLoginModal: () => {}, openLoginModal: () => {},
closeLoginModal: () => {}, closeLoginModal: () => {},
...@@ -50,6 +55,7 @@ const RewardsContext = createContext<TRewardsContext>({ ...@@ -50,6 +55,7 @@ const RewardsContext = createContext<TRewardsContext>({
claim: async() => {}, claim: async() => {},
}); });
// Message to sign for the rewards program
function getMessageToSign(address: string, nonce: string, isLogin?: boolean, refCode?: string) { function getMessageToSign(address: string, nonce: string, isLogin?: boolean, refCode?: string) {
const signInText = 'Sign-In for the Blockscout points program.'; const signInText = 'Sign-In for the Blockscout points program.';
const signUpText = 'Sign-Up for the Blockscout points program. I accept Terms of Service: https://points.blockscout.com/tos. I love capybaras.'; const signUpText = 'Sign-Up for the Blockscout points program. I accept Terms of Service: https://points.blockscout.com/tos. I love capybaras.';
...@@ -70,47 +76,82 @@ function getMessageToSign(address: string, nonce: string, isLogin?: boolean, ref ...@@ -70,47 +76,82 @@ function getMessageToSign(address: string, nonce: string, isLogin?: boolean, ref
].join('\n'); ].join('\n');
} }
// Get the registered address from the JWT token
function getRegisteredAddress(token: string) {
const decodedToken = decodeJWT(token);
return decodedToken?.payload.sub;
}
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
} }
export function RewardsContextProvider({ children }: Props) { export function RewardsContextProvider({ children }: Props) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const toast = useToast(); const toast = useToast();
const { address } = useAccount(); const { address } = useAccount();
const { signMessageAsync } = useSignMessage(); const { signMessageAsync } = useSignMessage();
const profileQuery = useProfileQuery();
const [ isLoginModalOpen, setIsLoginModalOpen ] = useBoolean(false); const [ isLoginModalOpen, setIsLoginModalOpen ] = useBoolean(false);
const [ isInitialized, setIsInitialized ] = useBoolean(false); const [ isInitialized, setIsInitialized ] = useBoolean(false);
const [ apiToken, setApiToken ] = React.useState<string | undefined>(); const [ apiToken, setApiToken ] = React.useState<string | undefined>();
// Initialize state with the API token from cookies
useEffect(() => { useEffect(() => {
if (!profileQuery.isLoading) {
const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN); const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
if (token) { const registeredAddress = getRegisteredAddress(token || '');
if (registeredAddress === profileQuery.data?.address_hash) {
setApiToken(token); setApiToken(token);
} }
setIsInitialized.on(); setIsInitialized.on();
}, [ setIsInitialized ]); }
}, [ setIsInitialized, profileQuery ]);
const queryOptions = { enabled: Boolean(apiToken) && config.features.rewards.isEnabled }; // Save the API token to cookies and state
const fetchParams = { headers: { Authorization: `Bearer ${ apiToken }` } }; const saveApiToken = useCallback((token: string | undefined) => {
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token || '');
setApiToken(token);
}, []);
const [ queryOptions, fetchParams ] = useMemo(() => [
{ enabled: Boolean(apiToken) && config.features.rewards.isEnabled },
{ headers: { Authorization: `Bearer ${ apiToken }` } },
], [ apiToken ]);
const balancesQuery = useApiQuery('rewards_user_balances', { queryOptions, fetchParams }); const balancesQuery = useApiQuery('rewards_user_balances', { queryOptions, fetchParams });
const dailyRewardQuery = useApiQuery('rewards_user_daily_check', { queryOptions, fetchParams }); const dailyRewardQuery = useApiQuery('rewards_user_daily_check', { queryOptions, fetchParams });
const referralsQuery = useApiQuery('rewards_user_referrals', { queryOptions, fetchParams }); const referralsQuery = useApiQuery('rewards_user_referrals', { queryOptions, fetchParams });
const rewardsConfigQuery = useApiQuery('rewards_config', { queryOptions }); const rewardsConfigQuery = useApiQuery('rewards_config', { queryOptions });
const saveApiToken = useCallback((token: string) => { // Reset queries when the API token is removed
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token); useEffect(() => {
setApiToken(token); if (isInitialized && !apiToken) {
}, []); queryClient.resetQueries({ queryKey: getResourceKey('rewards_user_balances'), exact: true });
queryClient.resetQueries({ queryKey: getResourceKey('rewards_user_daily_check'), exact: true });
queryClient.resetQueries({ queryKey: getResourceKey('rewards_user_referrals'), exact: true });
}
}, [ isInitialized, apiToken, queryClient ]);
// Handle 401 error
useEffect(() => { useEffect(() => {
if (apiToken && balancesQuery.error?.status === 401) { if (apiToken && balancesQuery.error?.status === 401) {
saveApiToken(''); saveApiToken(undefined);
} }
}, [ balancesQuery.error, apiToken, saveApiToken ]); }, [ balancesQuery.error, apiToken, saveApiToken ]);
// Check if the profile address is the same as the registered address
useEffect(() => {
const registeredAddress = getRegisteredAddress(apiToken || '');
if (registeredAddress && !profileQuery.isLoading && profileQuery.data?.address_hash !== registeredAddress) {
setApiToken(undefined);
}
}, [ apiToken, profileQuery, setApiToken ]);
// Handle referral code in the URL
useEffect(() => { useEffect(() => {
const refCode = getQueryParamString(router.query.ref); const refCode = getQueryParamString(router.query.ref);
if (refCode && isInitialized) { if (refCode && isInitialized) {
...@@ -133,13 +174,14 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -133,13 +174,14 @@ export function RewardsContextProvider({ children }: Props) {
}); });
}, [ toast ]); }, [ toast ]);
// Login to the rewards program
const login = useCallback(async(refCode: string) => { const login = useCallback(async(refCode: string) => {
try { try {
const [ nonceResponse, userResponse, checkCodeResponse ] = await Promise.all([ const [ nonceResponse, userResponse, checkCodeResponse ] = await Promise.all([
apiFetch<'rewards_nonce', RewardsNonceResponse>('rewards_nonce'), apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>,
apiFetch<'rewards_check_user', RewardsCheckUserResponse>('rewards_check_user', { pathParams: { address } }), apiFetch('rewards_check_user', { pathParams: { address } }) as Promise<RewardsCheckUserResponse>,
refCode ? refCode ?
apiFetch<'rewards_check_ref_code', RewardsCheckRefCodeResponse>('rewards_check_ref_code', { pathParams: { code: refCode } }) : apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> :
Promise.resolve({ valid: true }), Promise.resolve({ valid: true }),
]); ]);
if (!address || !('nonce' in nonceResponse) || !('exists' in userResponse) || !('valid' in checkCodeResponse)) { if (!address || !('nonce' in nonceResponse) || !('exists' in userResponse) || !('valid' in checkCodeResponse)) {
...@@ -150,7 +192,7 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -150,7 +192,7 @@ export function RewardsContextProvider({ children }: Props) {
} }
const message = getMessageToSign(address, nonceResponse.nonce, userResponse.exists, refCode); const message = getMessageToSign(address, nonceResponse.nonce, userResponse.exists, refCode);
const signature = await signMessageAsync({ message }); const signature = await signMessageAsync({ message });
const loginResponse = await apiFetch<'rewards_login', RewardsLoginResponse>('rewards_login', { const loginResponse = await apiFetch('rewards_login', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
body: { body: {
...@@ -159,7 +201,7 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -159,7 +201,7 @@ export function RewardsContextProvider({ children }: Props) {
signature, signature,
}, },
}, },
}); }) as RewardsLoginResponse;
if (!('created' in loginResponse)) { if (!('created' in loginResponse)) {
throw loginResponse; throw loginResponse;
} }
...@@ -171,16 +213,15 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -171,16 +213,15 @@ export function RewardsContextProvider({ children }: Props) {
} }
}, [ apiFetch, address, signMessageAsync, errorToast, saveApiToken ]); }, [ apiFetch, address, signMessageAsync, errorToast, saveApiToken ]);
// Claim daily reward
const claim = useCallback(async() => { const claim = useCallback(async() => {
try { try {
const claimResponse = await apiFetch<'rewards_user_daily_claim', RewardsUserDailyClaimResponse>('rewards_user_daily_claim', { const claimResponse = await apiFetch('rewards_user_daily_claim', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
headers: { ...fetchParams,
Authorization: `Bearer ${ apiToken }`,
}, },
}, }) as RewardsUserDailyClaimResponse;
});
if (!('daily_reward' in claimResponse)) { if (!('daily_reward' in claimResponse)) {
throw claimResponse; throw claimResponse;
} }
...@@ -188,7 +229,7 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -188,7 +229,7 @@ export function RewardsContextProvider({ children }: Props) {
errorToast(_error as ResourceError<{ message: string }>); errorToast(_error as ResourceError<{ message: string }>);
throw _error; throw _error;
} }
}, [ apiFetch, errorToast, apiToken ]); }, [ apiFetch, errorToast, fetchParams ]);
const value = useMemo(() => ({ const value = useMemo(() => ({
balancesQuery, balancesQuery,
...@@ -196,6 +237,7 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -196,6 +237,7 @@ export function RewardsContextProvider({ children }: Props) {
referralsQuery, referralsQuery,
rewardsConfigQuery, rewardsConfigQuery,
apiToken, apiToken,
isInitialized,
isLoginModalOpen, isLoginModalOpen,
openLoginModal: setIsLoginModalOpen.on, openLoginModal: setIsLoginModalOpen.on,
closeLoginModal: setIsLoginModalOpen.off, closeLoginModal: setIsLoginModalOpen.off,
...@@ -203,7 +245,7 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -203,7 +245,7 @@ export function RewardsContextProvider({ children }: Props) {
claim, claim,
}), [ }), [
isLoginModalOpen, setIsLoginModalOpen, balancesQuery, dailyRewardQuery, isLoginModalOpen, setIsLoginModalOpen, balancesQuery, dailyRewardQuery,
apiToken, login, claim, referralsQuery, rewardsConfigQuery, apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized,
]); ]);
return ( return (
......
interface JWTHeader {
alg: string;
typ?: string;
[key: string]: unknown;
}
interface JWTPayload {
[key: string]: unknown;
}
const base64UrlDecode = (str: string): string => {
// Replace characters according to Base64Url standard
str = str.replace(/-/g, '+').replace(/_/g, '/');
// Add padding '=' characters for correct decoding
const pad = str.length % 4;
if (pad) {
str += '='.repeat(4 - pad);
}
// Decode from Base64 to string
const decodedStr = atob(str);
return decodedStr;
};
export default function decodeJWT(token: string): { header: JWTHeader; payload: JWTPayload; signature: string } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const [ encodedHeader, encodedPayload, signature ] = parts;
const headerJson = base64UrlDecode(encodedHeader);
const payloadJson = base64UrlDecode(encodedPayload);
const header = JSON.parse(headerJson) as JWTHeader;
const payload = JSON.parse(payloadJson) as JWTPayload;
return { header, payload, signature };
} catch (error) {
return null;
}
}
...@@ -12,11 +12,14 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -12,11 +12,14 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const RewardsDashboard = () => { const RewardsDashboard = () => {
const router = useRouter(); const router = useRouter();
const { balancesQuery, dailyRewardQuery, apiToken, claim, referralsQuery, rewardsConfigQuery } = useRewardsContext(); const {
balancesQuery, dailyRewardQuery, apiToken, claim,
referralsQuery, rewardsConfigQuery, isInitialized,
} = useRewardsContext();
const [ isClaiming, setIsClaiming ] = useBoolean(false); const [ isClaiming, setIsClaiming ] = useBoolean(false);
const [ timeLeft, setTimeLeft ] = React.useState<string>(''); const [ timeLeft, setTimeLeft ] = React.useState<string>('');
if (!apiToken) { if (isInitialized && !apiToken) {
router.replace({ pathname: '/' }, undefined, { shallow: true }); router.replace({ pathname: '/' }, undefined, { shallow: true });
} }
......
...@@ -15,8 +15,9 @@ type Props = { ...@@ -15,8 +15,9 @@ type Props = {
}; };
const RewardsButton = ({ variant = 'header', size }: Props) => { const RewardsButton = ({ variant = 'header', size }: Props) => {
const { apiToken, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext(); const { isInitialized, apiToken, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isLoading = !isInitialized || dailyRewardQuery.isLoading || balancesQuery.isLoading;
return ( return (
<Tooltip <Tooltip
label="Earn merits for using Blockscout" label="Earn merits for using Blockscout"
...@@ -28,7 +29,7 @@ const RewardsButton = ({ variant = 'header', size }: Props) => { ...@@ -28,7 +29,7 @@ const RewardsButton = ({ variant = 'header', size }: Props) => {
> >
<Button <Button
variant={ variant } variant={ variant }
data-selected={ Boolean(apiToken) } data-selected={ !isLoading && Boolean(apiToken) }
flexShrink={ 0 } flexShrink={ 0 }
as={ apiToken ? LinkInternal : 'button' } as={ apiToken ? LinkInternal : 'button' }
{ ...(apiToken ? { href: route({ pathname: '/account/rewards' }) } : {}) } { ...(apiToken ? { href: route({ pathname: '/account/rewards' }) } : {}) }
...@@ -36,7 +37,7 @@ const RewardsButton = ({ variant = 'header', size }: Props) => { ...@@ -36,7 +37,7 @@ const RewardsButton = ({ variant = 'header', size }: Props) => {
fontSize="sm" fontSize="sm"
size={ size } size={ size }
px={ 2.5 } px={ 2.5 }
isLoading={ dailyRewardQuery.isLoading || balancesQuery.isLoading } isLoading={ isLoading }
loadingText={ isMobile ? undefined : 'Merits' } loadingText={ isMobile ? undefined : 'Merits' }
textDecoration="none !important" textDecoration="none !important"
> >
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment