Commit 85ec04fd authored by Max Alekseenko's avatar Max Alekseenko

post-review changes

parent b472070b
import type { Feature } from './types'; import type { Feature } from './types';
import { getEnvValue } from '../utils'; import { getEnvValue } from '../utils';
import account from './account';
import blockchainInteraction from './blockchainInteraction';
const apiHost = getEnvValue('NEXT_PUBLIC_REWARDS_SERVICE_API_HOST'); const apiHost = getEnvValue('NEXT_PUBLIC_REWARDS_SERVICE_API_HOST');
const title = 'Rewards service integration'; const title = 'Rewards service integration';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => { const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (apiHost) { if (apiHost && account.isEnabled && blockchainInteraction.isEnabled) {
return Object.freeze({ return Object.freeze({
title, title,
isEnabled: true, isEnabled: true,
......
...@@ -837,6 +837,7 @@ const schema = yup ...@@ -837,6 +837,7 @@ const schema = yup
return isUndefined || valueSchema.isValidSync(data); return isUndefined || valueSchema.isValidSync(data);
}), }),
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest),
// 6. External services envs // 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
...@@ -844,7 +845,6 @@ const schema = yup ...@@ -844,7 +845,6 @@ const schema = yup
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(), NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(),
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: yup.string(), NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: yup.string(),
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(), NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(),
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest),
// Misc // Misc
NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(), NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(),
......
...@@ -796,7 +796,7 @@ The feature enables a "Save with GasHawk" button next to the "Gas used" value on ...@@ -796,7 +796,7 @@ The feature enables a "Save with GasHawk" button next to the "Gas used" value on
### Rewards service API ### Rewards service API
This feature enables Blockscout Merits program. This feature enables Blockscout Merits program. It requires that the [My account](ENVS.md#my-account) and [Blockchain interaction](ENVS.md#blockchain-interaction-writing-to-contract-etc) features are also enabled.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
......
...@@ -3,7 +3,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; ...@@ -3,7 +3,7 @@ import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } 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 { useSignMessage } from 'wagmi';
import type { import type {
RewardsUserBalancesResponse, RewardsUserDailyCheckResponse, RewardsUserBalancesResponse, RewardsUserDailyCheckResponse,
...@@ -17,19 +17,25 @@ import config from 'configs/app'; ...@@ -17,19 +17,25 @@ 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, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { YEAR } from 'lib/consts';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import decodeJWT from 'lib/decodeJWT'; import decodeJWT from 'lib/decodeJWT';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
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 useAccount from 'lib/web3/useAccount';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery'; import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type ContextQueryResult<Response> = Pick<UseQueryResult<Response, ResourceError<unknown>>, 'data' | 'isLoading' | 'refetch' | 'isPending' | 'isFetching'>;
type TRewardsContext = { type TRewardsContext = {
balancesQuery: UseQueryResult<RewardsUserBalancesResponse, ResourceError<unknown>>; balancesQuery: ContextQueryResult<RewardsUserBalancesResponse>;
dailyRewardQuery: UseQueryResult<RewardsUserDailyCheckResponse, ResourceError<unknown>>; dailyRewardQuery: ContextQueryResult<RewardsUserDailyCheckResponse>;
referralsQuery: UseQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>; referralsQuery: ContextQueryResult<RewardsUserReferralsResponse>;
rewardsConfigQuery: UseQueryResult<RewardsConfigResponse, ResourceError<unknown>>; rewardsConfigQuery: ContextQueryResult<RewardsConfigResponse>;
checkUserQuery: UseQueryResult<RewardsCheckUserResponse, ResourceError<unknown>>; checkUserQuery: ContextQueryResult<RewardsCheckUserResponse>;
apiToken: string | undefined; apiToken: string | undefined;
isInitialized: boolean; isInitialized: boolean;
isLoginModalOpen: boolean; isLoginModalOpen: boolean;
...@@ -39,15 +45,20 @@ type TRewardsContext = { ...@@ -39,15 +45,20 @@ type TRewardsContext = {
claim: () => Promise<void>; claim: () => Promise<void>;
} }
const createDefaultQueryResult = <TData, TError>() => const defaultQueryResult = {
({ data: undefined, isLoading: false, refetch: () => {} } as UseQueryResult<TData, TError>); data: undefined,
isLoading: false,
isPending: false,
isFetching: false,
refetch: () => Promise.resolve({} as never),
};
const RewardsContext = createContext<TRewardsContext>({ const RewardsContext = createContext<TRewardsContext>({
balancesQuery: createDefaultQueryResult<RewardsUserBalancesResponse, ResourceError<unknown>>(), balancesQuery: defaultQueryResult,
dailyRewardQuery: createDefaultQueryResult<RewardsUserDailyCheckResponse, ResourceError<unknown>>(), dailyRewardQuery: defaultQueryResult,
referralsQuery: createDefaultQueryResult<RewardsUserReferralsResponse, ResourceError<unknown>>(), referralsQuery: defaultQueryResult,
rewardsConfigQuery: createDefaultQueryResult<RewardsConfigResponse, ResourceError<unknown>>(), rewardsConfigQuery: defaultQueryResult,
checkUserQuery: createDefaultQueryResult<RewardsCheckUserResponse, ResourceError<unknown>>(), checkUserQuery: defaultQueryResult,
apiToken: undefined, apiToken: undefined,
isInitialized: false, isInitialized: false,
isLoginModalOpen: false, isLoginModalOpen: false,
...@@ -74,7 +85,7 @@ function getMessageToSign(address: string, nonce: string, isLogin?: boolean, ref ...@@ -74,7 +85,7 @@ function getMessageToSign(address: string, nonce: string, isLogin?: boolean, ref
`Chain ID: ${ config.chain.id }`, `Chain ID: ${ config.chain.id }`,
`Nonce: ${ nonce }`, `Nonce: ${ nonce }`,
`Issued At: ${ new Date().toISOString() }`, `Issued At: ${ new Date().toISOString() }`,
`Expiration Time: ${ new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() }`, `Expiration Time: ${ new Date(Date.now() + YEAR).toISOString() }`,
].join('\n'); ].join('\n');
} }
...@@ -117,7 +128,11 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -117,7 +128,11 @@ export function RewardsContextProvider({ children }: Props) {
// Save the API token to cookies and state // Save the API token to cookies and state
const saveApiToken = useCallback((token: string | undefined) => { const saveApiToken = useCallback((token: string | undefined) => {
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token || ''); if (token) {
cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token);
} else {
cookies.remove(cookies.NAMES.REWARDS_API_TOKEN);
}
setApiToken(token); setApiToken(token);
}, []); }, []);
...@@ -168,11 +183,12 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -168,11 +183,12 @@ export function RewardsContextProvider({ children }: Props) {
} }
}, [ router, apiToken, isInitialized, setIsLoginModalOpen ]); }, [ router, apiToken, isInitialized, setIsLoginModalOpen ]);
const errorToast = useCallback((error: ResourceError<{ message: string }>) => { const errorToast = useCallback((error: unknown) => {
const apiError = getErrorObjPayload<{ message: string }>(error);
toast({ toast({
position: 'top-right', position: 'top-right',
title: 'Error', title: 'Error',
description: error?.payload?.message || 'Something went wrong. Try again later.', description: apiError?.message || getErrorMessage(error) || 'Something went wrong. Try again later.',
status: 'error', status: 'error',
variant: 'subtle', variant: 'subtle',
isClosable: true, isClosable: true,
...@@ -182,15 +198,15 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -182,15 +198,15 @@ export function RewardsContextProvider({ children }: Props) {
// Login to the rewards program // Login to the rewards program
const login = useCallback(async(refCode: string) => { const login = useCallback(async(refCode: string) => {
try { try {
if (!address) {
throw new Error();
}
const [ nonceResponse, checkCodeResponse ] = await Promise.all([ const [ nonceResponse, checkCodeResponse ] = await Promise.all([
apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>, apiFetch('rewards_nonce') as Promise<RewardsNonceResponse>,
refCode ? refCode ?
apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> : apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise<RewardsCheckRefCodeResponse> :
Promise.resolve({ valid: true }), Promise.resolve({ valid: true }),
]); ]);
if (!address || !('nonce' in nonceResponse) || !('valid' in checkCodeResponse)) {
throw new Error();
}
if (!checkCodeResponse.valid) { if (!checkCodeResponse.valid) {
return { invalidRefCodeError: true }; return { invalidRefCodeError: true };
} }
...@@ -206,9 +222,6 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -206,9 +222,6 @@ export function RewardsContextProvider({ children }: Props) {
}, },
}, },
}) as RewardsLoginResponse; }) as RewardsLoginResponse;
if (!('created' in loginResponse)) {
throw loginResponse;
}
saveApiToken(loginResponse.token); saveApiToken(loginResponse.token);
return { isNewUser: loginResponse.created }; return { isNewUser: loginResponse.created };
} catch (_error) { } catch (_error) {
...@@ -220,15 +233,12 @@ export function RewardsContextProvider({ children }: Props) { ...@@ -220,15 +233,12 @@ export function RewardsContextProvider({ children }: Props) {
// Claim daily reward // Claim daily reward
const claim = useCallback(async() => { const claim = useCallback(async() => {
try { try {
const claimResponse = await apiFetch('rewards_user_daily_claim', { await apiFetch('rewards_user_daily_claim', {
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
...fetchParams, ...fetchParams,
}, },
}) as RewardsUserDailyClaimResponse; }) as RewardsUserDailyClaimResponse;
if (!('daily_reward' in claimResponse)) {
throw claimResponse;
}
} catch (_error) { } catch (_error) {
errorToast(_error as ResourceError<{ message: string }>); errorToast(_error as ResourceError<{ message: string }>);
throw _error; throw _error;
......
...@@ -2,6 +2,7 @@ import { Button, Flex, Skeleton, useBoolean, Image } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Button, Flex, Skeleton, useBoolean, Image } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { SECOND } from 'lib/consts';
import { useRewardsContext } from 'lib/contexts/rewards'; import { useRewardsContext } from 'lib/contexts/rewards';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import splitSecondsInPeriods from 'ui/blockCountdown/splitSecondsInPeriods'; import splitSecondsInPeriods from 'ui/blockCountdown/splitSecondsInPeriods';
...@@ -10,6 +11,7 @@ import RewardsDashboardCard from 'ui/rewards/RewardsDashboardCard'; ...@@ -10,6 +11,7 @@ import RewardsDashboardCard from 'ui/rewards/RewardsDashboardCard';
import RewardsDashboardCardValue from 'ui/rewards/RewardsDashboardCardValue'; import RewardsDashboardCardValue from 'ui/rewards/RewardsDashboardCardValue';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const RewardsDashboard = () => { const RewardsDashboard = () => {
const router = useRouter(); const router = useRouter();
...@@ -20,9 +22,13 @@ const RewardsDashboard = () => { ...@@ -20,9 +22,13 @@ const RewardsDashboard = () => {
const [ isClaiming, setIsClaiming ] = useBoolean(false); const [ isClaiming, setIsClaiming ] = useBoolean(false);
const [ timeLeft, setTimeLeft ] = React.useState<string>(''); const [ timeLeft, setTimeLeft ] = React.useState<string>('');
if (isInitialized && !apiToken) { useRedirectForInvalidAuthToken();
router.replace({ pathname: '/' }, undefined, { shallow: true });
} useEffect(() => {
if (isInitialized && !apiToken) {
router.replace({ pathname: '/' }, undefined, { shallow: true });
}
}, [ isInitialized, apiToken, router ]);
const dailyRewardValue = Number(dailyRewardQuery.data?.daily_reward || 0) + Number(dailyRewardQuery.data?.pending_referral_rewards || 0); const dailyRewardValue = Number(dailyRewardQuery.data?.daily_reward || 0) + Number(dailyRewardQuery.data?.pending_referral_rewards || 0);
...@@ -30,8 +36,10 @@ const RewardsDashboard = () => { ...@@ -30,8 +36,10 @@ const RewardsDashboard = () => {
setIsClaiming.on(); setIsClaiming.on();
try { try {
await claim(); await claim();
balancesQuery.refetch(); await Promise.all([
dailyRewardQuery.refetch(); balancesQuery.refetch(),
dailyRewardQuery.refetch(),
]);
} catch (error) {} } catch (error) {}
setIsClaiming.off(); setIsClaiming.off();
}, [ claim, setIsClaiming, balancesQuery, dailyRewardQuery ]); }, [ claim, setIsClaiming, balancesQuery, dailyRewardQuery ]);
...@@ -41,17 +49,18 @@ const RewardsDashboard = () => { ...@@ -41,17 +49,18 @@ const RewardsDashboard = () => {
return; return;
} }
let interval: NodeJS.Timeout; // eslint-disable-line // format the date to be compatible with the Date constructor
const formattedDate = dailyRewardQuery.data.reset_at.replace(' ', 'T').replace(' UTC', 'Z');
const target = new Date(formattedDate).getTime();
let interval: ReturnType<typeof setTimeout>; // eslint-disable-line prefer-const
const updateCountdown = () => { const updateCountdown = (target: number) => {
const now = new Date().getTime(); const now = new Date().getTime();
// format the date to be compatible with the Date constructor
const formattedDate = dailyRewardQuery.data.reset_at.replace(' ', 'T').replace(' UTC', 'Z');
const target = new Date(formattedDate).getTime();
const difference = target - now; const difference = target - now;
if (difference > 0) { if (difference > 0) {
const { hours, minutes, seconds } = splitSecondsInPeriods(Math.floor(difference / 1000)); const { hours, minutes, seconds } = splitSecondsInPeriods(Math.floor(difference / SECOND));
setTimeLeft(`${ hours }:${ minutes }:${ seconds }`); setTimeLeft(`${ hours }:${ minutes }:${ seconds }`);
} else { } else {
setTimeLeft('00:00:00'); setTimeLeft('00:00:00');
...@@ -60,11 +69,11 @@ const RewardsDashboard = () => { ...@@ -60,11 +69,11 @@ const RewardsDashboard = () => {
} }
}; };
updateCountdown(); updateCountdown(target);
interval = setInterval(() => { interval = setInterval(() => {
updateCountdown(); updateCountdown(target);
}, 1000); }, SECOND);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [ dailyRewardQuery ]); }, [ dailyRewardQuery ]);
...@@ -78,7 +87,10 @@ const RewardsDashboard = () => { ...@@ -78,7 +87,10 @@ const RewardsDashboard = () => {
secondRow={ ( secondRow={ (
<span> <span>
The Blockscout Merits Program is just getting started! Learn more about the details, The Blockscout Merits Program is just getting started! Learn more about the details,
features, and future plans in our <LinkExternal href="">blog post</LinkExternal>. features, and future plans in our{ ' ' }
<LinkExternal href="https://www.blog.blockscout.com/blockscout-merits-rewarding-block-explorer-skills">
blog post
</LinkExternal>.
</span> </span>
) } ) }
/> />
...@@ -108,7 +120,7 @@ const RewardsDashboard = () => { ...@@ -108,7 +120,7 @@ const RewardsDashboard = () => {
hint={ ( hint={ (
<> <>
Total number of merits earned from all activities.{ ' ' } Total number of merits earned from all activities.{ ' ' }
<LinkExternal href="https://docs.blockscout.com/using-blockscout/my-account/merits"> <LinkExternal href="https://docs.blockscout.com/using-blockscout/merits">
More info on merits More info on merits
</LinkExternal> </LinkExternal>
</> </>
......
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