Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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) {
const registeredAddress = getRegisteredAddress(token);
if (registeredAddress === profileQuery.data.address_hash) {
setIsAuth(true);
} else {
// Check if the profile address is the same as the registered address
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
Loading