Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/api/useApiFetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function useApiFetch() {
{ pathParams, queryParams, fetchParams, logError, chain }: Params<R> = {},
) => {
const apiToken = cookies.get(cookies.NAMES.API_TOKEN);
const rewardsApiToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
const apiTempToken = cookies.get(cookies.NAMES.API_TEMP_TOKEN);
const showScamTokens = cookies.get(cookies.NAMES.SHOW_SCAM_TOKENS) === 'true';

Expand All @@ -49,6 +50,9 @@ export default function useApiFetch() {
'api-v2-temp-token': apiTempToken,
'show-scam-tokens': showScamTokens ? 'true' : undefined,
} : {}),
...(apiName === 'rewards' && rewardsApiToken ? {
Authorization: `Bearer ${ rewardsApiToken }`,
} : {}),
...resource.headers,
...fetchParams?.headers,
}, Boolean) as HeadersInit;
Expand Down
136 changes: 68 additions & 68 deletions lib/contexts/rewards.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { useToggle } from '@uidotdev/usehooks';
import { useRouter } from 'next/router';
import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useCallback, useState } from 'react';
import { useSignMessage, useSwitchChain } from 'wagmi';

import type * as rewards from '@blockscout/points-types';
Expand Down Expand Up @@ -33,13 +32,14 @@ type TRewardsContext = {
referralsQuery: ContextQueryResult<rewards.GetReferralDataResponse>;
rewardsConfigQuery: ContextQueryResult<rewards.GetConfigResponse>;
checkUserQuery: ContextQueryResult<rewards.AuthUserResponse>;
apiToken: string | undefined;
isInitialized: boolean;
isLoginModalOpen: boolean;
isAuth: boolean;
openLoginModal: () => void;
closeLoginModal: () => void;
saveApiToken: (token: string | undefined) => void;
login: (refCode: string) => Promise<{ isNewUser: boolean; reward?: string; invalidRefCodeError?: boolean }>;
onLoginSuccess: (token: string) => void;
logout: () => Promise<void>;
claim: () => Promise<void>;
};

Expand All @@ -58,13 +58,14 @@ const initialState = {
referralsQuery: defaultQueryResult,
rewardsConfigQuery: defaultQueryResult,
checkUserQuery: defaultQueryResult,
apiToken: undefined,
isInitialized: false,
isLoginModalOpen: false,
isAuth: false,
openLoginModal: () => {},
closeLoginModal: () => {},
saveApiToken: () => {},
login: async() => ({ isNewUser: false }),
onLoginSuccess: () => {},
logout: async() => {},
claim: async() => {},
};

Expand Down Expand Up @@ -108,85 +109,48 @@ type Props = {

export function RewardsContextProvider({ children }: Props) {
const router = useRouter();
const queryClient = useQueryClient();
const apiFetch = useApiFetch();
const queryClient = useQueryClient();

const { address } = useAccount();
const { signMessageAsync } = useSignMessage();
const { switchChainAsync } = useSwitchChain();
const profileQuery = useProfileQuery();

const [ isLoginModalOpen, setIsLoginModalOpen ] = useToggle(false);
const [ isInitialized, setIsInitialized ] = useToggle(false);
const [ apiToken, setApiToken ] = React.useState<string | undefined>();
const [ isInitialized, setIsInitialized ] = useState(false);
const [ isAuth, setIsAuth ] = useState(false);

// Initialize state with the API token from cookies
useEffect(() => {
if (!profileQuery.isLoading) {
const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
const registeredAddress = getRegisteredAddress(token || '');
if (registeredAddress === profileQuery.data?.address_hash) {
setApiToken(token);
}
setIsInitialized(true);
}
}, [ setIsInitialized, profileQuery ]);
const enabled = feature.isEnabled && isAuth;

// Save the API token to cookies and state
const saveApiToken = useCallback((token: string | undefined) => {
if (token) {
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token, { expires: 365 });
} else {
cookies.remove(cookies.NAMES.REWARDS_API_TOKEN);
}
setApiToken(token);
}, []);

const [ queryOptions, fetchParams ] = useMemo(() => [
{ enabled: Boolean(apiToken) && feature.isEnabled },
{ headers: { Authorization: `Bearer ${ apiToken }` } },
], [ apiToken ]);

const balancesQuery = useApiQuery('rewards:user_balances', { queryOptions, fetchParams });
const dailyRewardQuery = useApiQuery('rewards:user_daily_check', { queryOptions, fetchParams });
const referralsQuery = useApiQuery('rewards:user_referrals', { queryOptions, fetchParams });
const balancesQuery = useApiQuery('rewards:user_balances', { queryOptions: { enabled } });
const dailyRewardQuery = useApiQuery('rewards:user_daily_check', { queryOptions: { enabled } });
const referralsQuery = useApiQuery('rewards:user_referrals', { queryOptions: { enabled } });
const rewardsConfigQuery = useApiQuery('rewards:config', { queryOptions: { enabled: feature.isEnabled } });
const checkUserQuery = useApiQuery('rewards:check_user', { queryOptions: { enabled: feature.isEnabled }, pathParams: { address } });

// Reset queries when the API token is removed
useEffect(() => {
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(() => {
if (apiToken && balancesQuery.error?.status === 401) {
saveApiToken(undefined);
}
}, [ 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);
if (balancesQuery.error?.status === 401) {
const rewardsToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
if (rewardsToken) {
cookies.remove(cookies.NAMES.REWARDS_API_TOKEN);
setIsAuth(false);
}
}
}, [ apiToken, profileQuery, setApiToken ]);
}, [ balancesQuery.error?.status ]);

// Handle referral code in the URL
useEffect(() => {
const refCode = getQueryParamString(router.query.ref);
if (refCode && isInitialized) {
cookies.set(cookies.NAMES.REWARDS_REFERRAL_CODE, refCode);
removeQueryParam(router, 'ref');
if (!apiToken) {
if (!isAuth) {
setIsLoginModalOpen(true);
}
}
}, [ router, apiToken, isInitialized, setIsLoginModalOpen ]);
}, [ router, isInitialized, setIsLoginModalOpen, isAuth ]);

const errorToast = useCallback((error: unknown) => {
const apiError = getErrorObjPayload<{ message: string }>(error);
Expand All @@ -196,6 +160,41 @@ export function RewardsContextProvider({ children }: Props) {
});
}, [ ]);

const onLoginSuccess = useCallback((token: string) => {
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token, { expires: 365 });
setIsAuth(true);
}, [ ]);

const logout = useCallback(async() => {
const rewardsToken = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
if (rewardsToken) {
await apiFetch('rewards:logout', { fetchParams: { method: 'POST' } });
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 });

cookies.remove(cookies.NAMES.REWARDS_API_TOKEN);
}
setIsAuth(false);
}, [ apiFetch, queryClient ]);

// Initialize state with the API token from cookies
useEffect(() => {
if (!profileQuery.isLoading) {
const token = cookies.get(cookies.NAMES.REWARDS_API_TOKEN);
if (token && profileQuery.data?.address_hash) {
// Check if the profile address is the same as the registered address
const registeredAddress = getRegisteredAddress(token);
if (registeredAddress === profileQuery.data.address_hash) {
setIsAuth(true);
} else {
logout();
}
}
setIsInitialized(true);
}
}, [ profileQuery.isLoading, profileQuery.data?.address_hash, logout ]);

// Login to the rewards program
const login = useCallback(async(refCode: string) => {
try {
Expand Down Expand Up @@ -227,7 +226,7 @@ export function RewardsContextProvider({ children }: Props) {
},
},
}) as rewards.AuthLoginResponse;
saveApiToken(loginResponse.token);
onLoginSuccess(loginResponse.token);
return {
isNewUser: loginResponse.created,
reward: checkCodeResponse.reward,
Expand All @@ -236,22 +235,21 @@ export function RewardsContextProvider({ children }: Props) {
errorToast(_error);
throw _error;
}
}, [ address, apiFetch, checkUserQuery.data?.exists, switchChainAsync, signMessageAsync, saveApiToken, errorToast ]);
}, [ address, apiFetch, switchChainAsync, checkUserQuery.data?.exists, signMessageAsync, onLoginSuccess, errorToast ]);

// Claim daily reward
const claim = useCallback(async() => {
try {
await apiFetch('rewards:user_daily_claim', {
fetchParams: {
method: 'POST',
...fetchParams,
},
}) as rewards.DailyRewardClaimResponse;
} catch (_error) {
errorToast(_error);
throw _error;
}
}, [ apiFetch, errorToast, fetchParams ]);
}, [ apiFetch, errorToast ]);

const openLoginModal = React.useCallback(() => {
setIsLoginModalOpen(true);
Expand All @@ -271,19 +269,21 @@ export function RewardsContextProvider({ children }: Props) {
referralsQuery,
rewardsConfigQuery,
checkUserQuery,
apiToken,
saveApiToken,
isInitialized,
isLoginModalOpen,
openLoginModal,
closeLoginModal,
login,
onLoginSuccess,
logout,
claim,
isAuth,
};
}, [
balancesQuery, dailyRewardQuery, checkUserQuery,
apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized, saveApiToken,
login, claim, referralsQuery, rewardsConfigQuery, isInitialized,
isLoginModalOpen, openLoginModal, closeLoginModal,
isAuth, logout, onLoginSuccess,
]);

return (
Expand Down
13 changes: 6 additions & 7 deletions lib/hooks/useRewardsActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ type RewardsActivityEndpoint =
'rewards:user_activity_track_usage';

export default function useRewardsActivity() {
const { apiToken } = useRewardsContext();
const { isAuth } = useRewardsContext();
const apiFetch = useApiFetch();
const lastExploreTime = useRef<number>(0);

const profileQuery = useProfileQuery();
const checkActivityPassQuery = useApiQuery('rewards:user_check_activity_pass', {
queryOptions: {
enabled: feature.isEnabled && Boolean(apiToken) && Boolean(profileQuery.data?.address_hash),
enabled: feature.isEnabled && isAuth && Boolean(profileQuery.data?.address_hash),
},
queryParams: {
address: profileQuery.data?.address_hash ?? '',
Expand All @@ -44,7 +44,7 @@ export default function useRewardsActivity() {
}, []);

const makeRequest = useCallback(async(endpoint: RewardsActivityEndpoint, params: Record<string, string>) => {
if (!apiToken || !checkActivityPassQuery.data?.is_valid) {
if (!isAuth || !checkActivityPassQuery.data?.is_valid) {
return;
}

Expand All @@ -53,11 +53,10 @@ export default function useRewardsActivity() {
fetchParams: {
method: 'POST',
body: params,
headers: { Authorization: `Bearer ${ apiToken }` },
},
});
} catch {}
}, [ apiFetch, checkActivityPassQuery.data, apiToken ]);
}, [ apiFetch, checkActivityPassQuery.data, isAuth ]);

const trackTransaction = useCallback(async(from: string, to: string, chainId?: string) => {
return (
Expand All @@ -84,7 +83,7 @@ export default function useRewardsActivity() {

const trackUsage = useCallback((action: string) => {
// check here because this function is called on page load
if (!apiToken || !checkActivityPassQuery.data?.is_valid) {
if (!isAuth || !checkActivityPassQuery.data?.is_valid) {
return;
}

Expand All @@ -103,7 +102,7 @@ export default function useRewardsActivity() {
action,
chain_id: config.chain.id ?? '',
});
}, [ makeRequest, apiToken, checkActivityPassQuery.data ]);
}, [ makeRequest, isAuth, checkActivityPassQuery.data ]);

return {
trackTransaction,
Expand Down
6 changes: 3 additions & 3 deletions ui/pages/RewardsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';

const RewardsDashboard = () => {
const { balancesQuery, apiToken, referralsQuery, rewardsConfigQuery, dailyRewardQuery, isInitialized } = useRewardsContext();
const { balancesQuery, isAuth, referralsQuery, rewardsConfigQuery, dailyRewardQuery, isInitialized } = useRewardsContext();
const { nextAchievementText, isLoading: isBadgesLoading, badgesQuery } = useStreakBadges();
const streakModal = useDisclosure();
const isMobile = useIsMobile();
Expand All @@ -33,10 +33,10 @@ const RewardsDashboard = () => {
useRedirectForInvalidAuthToken();

useEffect(() => {
if (!config.features.rewards.isEnabled || (isInitialized && !apiToken)) {
if (!config.features.rewards.isEnabled || (isInitialized && !isAuth)) {
window.location.assign('/');
}
}, [ isInitialized, apiToken ]);
}, [ isInitialized, isAuth ]);

useEffect(() => {
setIsError(balancesQuery.isError || referralsQuery.isError || rewardsConfigQuery.isError || dailyRewardQuery.isError);
Expand Down
16 changes: 8 additions & 8 deletions ui/rewards/RewardsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Props = {
};

const RewardsButton = ({ variant = 'header', size }: Props) => {
const { isInitialized, apiToken, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext();
const { isInitialized, isAuth, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext();
const isMobile = useIsMobile();
const isLoading = !isInitialized || dailyRewardQuery.isLoading || balancesQuery.isLoading;

Expand All @@ -28,15 +28,15 @@ const RewardsButton = ({ variant = 'header', size }: Props) => {
<Tooltip
content="Earn Merits for using Blockscout"
openDelay={ 500 }
disabled={ isMobile || isLoading || Boolean(apiToken) }
disabled={ isMobile || isLoading || isAuth }
>
<Button
variant={ variant }
selected={ !isLoading && Boolean(apiToken) }
selected={ !isLoading && isAuth }
flexShrink={ 0 }
as={ apiToken ? 'a' : 'button' }
{ ...(apiToken ? { href: route({ pathname: '/account/merits' }) } : {}) }
onClick={ apiToken ? undefined : openLoginModal }
as={ isAuth ? 'a' : 'button' }
{ ...(isAuth ? { href: route({ pathname: '/account/merits' }) } : {}) }
onClick={ isAuth ? undefined : openLoginModal }
onFocus={ handleFocus }
size={ size }
px={{ base: '10px', lg: 3 }}
Expand All @@ -52,9 +52,9 @@ const RewardsButton = ({ variant = 'header', size }: Props) => {
/>
<chakra.span
display={{ base: 'none', md: 'inline' }}
fontWeight={ apiToken ? '700' : '600' }
fontWeight={ isAuth ? '700' : '600' }
>
{ apiToken ? (balancesQuery.data?.total || 'N/A') : 'Merits' }
{ isAuth ? (balancesQuery.data?.total || 'N/A') : 'Merits' }
</chakra.span>
</Button>
</Tooltip>
Expand Down
Loading