diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts
index 6a7954da1d..0ccd4f23c8 100644
--- a/configs/app/features/index.ts
+++ b/configs/app/features/index.ts
@@ -23,6 +23,7 @@ export { default as multichainButton } from './multichainButton';
export { default as nameService } from './nameService';
export { default as publicTagsSubmission } from './publicTagsSubmission';
export { default as restApiDocs } from './restApiDocs';
+export { default as rewards } from './rewards';
export { default as rollup } from './rollup';
export { default as safe } from './safe';
export { default as saveOnGas } from './saveOnGas';
diff --git a/configs/app/features/rewards.ts b/configs/app/features/rewards.ts
new file mode 100644
index 0000000000..9f19ae58bf
--- /dev/null
+++ b/configs/app/features/rewards.ts
@@ -0,0 +1,29 @@
+import type { Feature } from './types';
+
+import { getEnvValue } from '../utils';
+import account from './account';
+import blockchainInteraction from './blockchainInteraction';
+
+const apiHost = getEnvValue('NEXT_PUBLIC_REWARDS_SERVICE_API_HOST');
+
+const title = 'Rewards service integration';
+
+const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
+ if (apiHost && account.isEnabled && blockchainInteraction.isEnabled) {
+ return Object.freeze({
+ title,
+ isEnabled: true,
+ api: {
+ endpoint: apiHost,
+ basePath: '',
+ },
+ });
+ }
+
+ return Object.freeze({
+ title,
+ isEnabled: false,
+ });
+})();
+
+export default config;
diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia
index 8555f20031..46b25eddf3 100644
--- a/configs/envs/.env.eth_sepolia
+++ b/configs/envs/.env.eth_sepolia
@@ -38,7 +38,7 @@ NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}]
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
-NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/apps']
+NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/apps','/account/rewards']
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
@@ -59,4 +59,6 @@ NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global
NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true
NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves
-NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
\ No newline at end of file
+NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
+NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
+NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://points.k8s-dev.blockscout.com
diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts
index 426f0cdc84..deda07048a 100644
--- a/deploy/tools/envs-validator/schema.ts
+++ b/deploy/tools/envs-validator/schema.ts
@@ -836,6 +836,7 @@ const schema = yup
return isUndefined || valueSchema.isValidSync(data);
}),
+ NEXT_PUBLIC_REWARDS_SERVICE_API_HOST: yup.string().test(urlTest),
// 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
diff --git a/deploy/tools/envs-validator/test/.env.base b/deploy/tools/envs-validator/test/.env.base
index 5a09ba29c6..36cca46b2b 100644
--- a/deploy/tools/envs-validator/test/.env.base
+++ b/deploy/tools/envs-validator/test/.env.base
@@ -85,3 +85,4 @@ NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'uniswap'
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}]
NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'dapp_id': 'smol-refuel', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&utm_medium=address&disableBridges=true', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'}
NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true
+NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://example.com
diff --git a/docs/ENVS.md b/docs/ENVS.md
index 69f17fb66b..840322fd4e 100644
--- a/docs/ENVS.md
+++ b/docs/ENVS.md
@@ -64,6 +64,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Multichain balance button](ENVS.md#multichain-balance-button)
- [Get gas button](ENVS.md#get-gas-button)
- [Save on gas with GasHawk](ENVS.md#save-on-gas-with-gashawk)
+ - [Rewards service API](ENVS.md#rewards-service-api)
- [3rd party services configuration](ENVS.md#external-services-configuration)
@@ -793,6 +794,14 @@ The feature enables a "Save with GasHawk" button next to the "Gas used" value on
+### Rewards service API
+
+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 |
+| --- | --- | --- | --- | --- | --- | --- |
+| NEXT_PUBLIC_REWARDS_SERVICE_API_HOST | `string` | API URL | - | - | `https://example.com` | v1.36.0+ |
+
## External services configuration
### Google ReCaptcha
diff --git a/icons/API_slim.svg b/icons/API_slim.svg
index 1473387362..f4b36f08ba 100644
--- a/icons/API_slim.svg
+++ b/icons/API_slim.svg
@@ -1,3 +1,3 @@
diff --git a/icons/merits.svg b/icons/merits.svg
new file mode 100644
index 0000000000..91b128a2b9
--- /dev/null
+++ b/icons/merits.svg
@@ -0,0 +1,4 @@
+
diff --git a/icons/merits_colored.svg b/icons/merits_colored.svg
new file mode 100644
index 0000000000..4006f7e43a
--- /dev/null
+++ b/icons/merits_colored.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/merits_slim.svg b/icons/merits_slim.svg
new file mode 100644
index 0000000000..8a0623a169
--- /dev/null
+++ b/icons/merits_slim.svg
@@ -0,0 +1,4 @@
+
diff --git a/icons/merits_with_dot.svg b/icons/merits_with_dot.svg
new file mode 100644
index 0000000000..e216115e4e
--- /dev/null
+++ b/icons/merits_with_dot.svg
@@ -0,0 +1,4 @@
+
diff --git a/icons/merits_with_dot_slim.svg b/icons/merits_with_dot_slim.svg
new file mode 100644
index 0000000000..4b5bf8a0aa
--- /dev/null
+++ b/icons/merits_with_dot_slim.svg
@@ -0,0 +1,4 @@
+
diff --git a/icons/private_tags_slim.svg b/icons/private_tags_slim.svg
index 599cff3349..538c47d054 100644
--- a/icons/private_tags_slim.svg
+++ b/icons/private_tags_slim.svg
@@ -1,5 +1,5 @@
diff --git a/icons/sign_out.svg b/icons/sign_out.svg
index b577078676..d5b68ecdab 100644
--- a/icons/sign_out.svg
+++ b/icons/sign_out.svg
@@ -1,4 +1,4 @@
diff --git a/lib/api/resources.ts b/lib/api/resources.ts
index 6b58cd6807..401403210a 100644
--- a/lib/api/resources.ts
+++ b/lib/api/resources.ts
@@ -93,6 +93,17 @@ import type {
OptimismL2BatchBlocks,
} from 'types/api/optimisticL2';
import type { RawTracesResponse } from 'types/api/rawTrace';
+import type {
+ RewardsConfigResponse,
+ RewardsCheckRefCodeResponse,
+ RewardsNonceResponse,
+ RewardsCheckUserResponse,
+ RewardsLoginResponse,
+ RewardsUserBalancesResponse,
+ RewardsUserDailyCheckResponse,
+ RewardsUserDailyClaimResponse,
+ RewardsUserReferralsResponse,
+} from 'types/api/rewards';
import type { SearchRedirectResult, SearchResult, SearchResultFilters, SearchResultItem } from 'types/api/search';
import type { ShibariumWithdrawalsResponse, ShibariumDepositsResponse } from 'types/api/shibarium';
import type { HomeStats } from 'types/api/stats';
@@ -347,6 +358,60 @@ export const RESOURCES = {
basePath: marketplaceApi?.basePath,
},
+ // REWARDS SERVICE
+ rewards_config: {
+ path: '/api/v1/config',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_check_ref_code: {
+ path: '/api/v1/auth/code/:code',
+ pathParams: [ 'code' as const ],
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_nonce: {
+ path: '/api/v1/auth/nonce',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_check_user: {
+ path: '/api/v1/auth/user/:address',
+ pathParams: [ 'address' as const ],
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_login: {
+ path: '/api/v1/auth/login',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_logout: {
+ path: '/api/v1/auth/logout',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_user_balances: {
+ path: '/api/v1/user/balances',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_user_daily_check: {
+ path: '/api/v1/user/daily/check',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_user_daily_claim: {
+ path: '/api/v1/user/daily/claim',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+ rewards_user_referrals: {
+ path: '/api/v1/user/referrals',
+ endpoint: getFeaturePayload(config.features.rewards)?.api.endpoint,
+ basePath: getFeaturePayload(config.features.rewards)?.api.basePath,
+ },
+
// BLOCKS, TXS
blocks: {
path: '/api/v2/blocks',
@@ -1229,6 +1294,15 @@ Q extends 'contract_mud_system_info' ? SmartContractMudSystemInfo :
Q extends 'address_epoch_rewards' ? AddressEpochRewardsResponse :
Q extends 'withdrawals' ? WithdrawalsResponse :
Q extends 'withdrawals_counters' ? WithdrawalsCounters :
+Q extends 'rewards_config' ? RewardsConfigResponse :
+Q extends 'rewards_check_ref_code' ? RewardsCheckRefCodeResponse :
+Q extends 'rewards_nonce' ? RewardsNonceResponse :
+Q extends 'rewards_check_user' ? RewardsCheckUserResponse :
+Q extends 'rewards_login' ? RewardsLoginResponse :
+Q extends 'rewards_user_balances' ? RewardsUserBalancesResponse :
+Q extends 'rewards_user_daily_check' ? RewardsUserDailyCheckResponse :
+Q extends 'rewards_user_daily_claim' ? RewardsUserDailyClaimResponse :
+Q extends 'rewards_user_referrals' ? RewardsUserReferralsResponse :
Q extends 'token_transfers_all' ? TokenTransferResponse :
never;
/* eslint-enable @typescript-eslint/indent */
diff --git a/lib/contexts/rewards.tsx b/lib/contexts/rewards.tsx
new file mode 100644
index 0000000000..6bfde4119e
--- /dev/null
+++ b/lib/contexts/rewards.tsx
@@ -0,0 +1,289 @@
+import { useBoolean } from '@chakra-ui/react';
+import type { UseQueryResult } from '@tanstack/react-query';
+import { useQueryClient } from '@tanstack/react-query';
+import { useRouter } from 'next/router';
+import React, { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
+import { useSignMessage } from 'wagmi';
+
+import type {
+ RewardsUserBalancesResponse, RewardsUserDailyCheckResponse,
+ RewardsNonceResponse, RewardsCheckUserResponse,
+ RewardsLoginResponse, RewardsCheckRefCodeResponse,
+ RewardsUserDailyClaimResponse, RewardsUserReferralsResponse,
+ RewardsConfigResponse,
+} from 'types/api/rewards';
+
+import config from 'configs/app';
+import type { ResourceError } from 'lib/api/resources';
+import useApiFetch from 'lib/api/useApiFetch';
+import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
+import { YEAR } from 'lib/consts';
+import * as cookies from 'lib/cookies';
+import decodeJWT from 'lib/decodeJWT';
+import getErrorMessage from 'lib/errors/getErrorMessage';
+import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
+import useToast from 'lib/hooks/useToast';
+import getQueryParamString from 'lib/router/getQueryParamString';
+import removeQueryParam from 'lib/router/removeQueryParam';
+import useAccount from 'lib/web3/useAccount';
+import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
+
+const feature = config.features.rewards;
+
+type ContextQueryResult =
+ Pick>, 'data' | 'isLoading' | 'refetch' | 'isPending' | 'isFetching' | 'isError'>;
+
+type TRewardsContext = {
+ balancesQuery: ContextQueryResult;
+ dailyRewardQuery: ContextQueryResult;
+ referralsQuery: ContextQueryResult;
+ rewardsConfigQuery: ContextQueryResult;
+ checkUserQuery: ContextQueryResult;
+ apiToken: string | undefined;
+ isInitialized: boolean;
+ isLoginModalOpen: boolean;
+ openLoginModal: () => void;
+ closeLoginModal: () => void;
+ login: (refCode: string) => Promise<{ isNewUser?: boolean; invalidRefCodeError?: boolean }>;
+ claim: () => Promise;
+}
+
+const defaultQueryResult = {
+ data: undefined,
+ isLoading: false,
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ refetch: () => Promise.resolve({} as never),
+};
+
+const initialState = {
+ balancesQuery: defaultQueryResult,
+ dailyRewardQuery: defaultQueryResult,
+ referralsQuery: defaultQueryResult,
+ rewardsConfigQuery: defaultQueryResult,
+ checkUserQuery: defaultQueryResult,
+ apiToken: undefined,
+ isInitialized: false,
+ isLoginModalOpen: false,
+ openLoginModal: () => {},
+ closeLoginModal: () => {},
+ login: async() => ({}),
+ claim: async() => {},
+};
+
+const RewardsContext = createContext(initialState);
+
+// Message to sign for the rewards program
+function getMessageToSign(address: string, nonce: string, isLogin?: boolean, refCode?: string) {
+ const signInText = 'Sign-In for the Blockscout Merits program.';
+ const signUpText = 'Sign-Up for the Blockscout Merits program. I accept Terms of Service: https://merits.blockscout.com/terms. I love capybaras.';
+ const referralText = refCode ? ` Referral code: ${ refCode }` : '';
+ const body = isLogin ? signInText : signUpText + referralText;
+
+ const urlObj = window.location.hostname === 'localhost' && feature.isEnabled ?
+ new URL(feature.api.endpoint) :
+ window.location;
+
+ return [
+ `${ urlObj.hostname } wants you to sign in with your Ethereum account:`,
+ address,
+ '',
+ body,
+ '',
+ `URI: ${ urlObj.origin }`,
+ 'Version: 1',
+ `Chain ID: ${ config.chain.id }`,
+ `Nonce: ${ nonce }`,
+ `Issued At: ${ new Date().toISOString() }`,
+ `Expiration Time: ${ new Date(Date.now() + YEAR).toISOString() }`,
+ ].join('\n');
+}
+
+// Get the registered address from the JWT token
+function getRegisteredAddress(token: string) {
+ const decodedToken = decodeJWT(token);
+ return decodedToken?.payload.sub;
+}
+
+type Props = {
+ children: React.ReactNode;
+}
+
+export function RewardsContextProvider({ children }: Props) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const apiFetch = useApiFetch();
+ const toast = useToast();
+ const { address } = useAccount();
+ const { signMessageAsync } = useSignMessage();
+ const profileQuery = useProfileQuery();
+
+ const [ isLoginModalOpen, setIsLoginModalOpen ] = useBoolean(false);
+ const [ isInitialized, setIsInitialized ] = useBoolean(false);
+ const [ apiToken, setApiToken ] = React.useState();
+
+ // 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.on();
+ }
+ }, [ setIsInitialized, profileQuery ]);
+
+ // Save the API token to cookies and state
+ const saveApiToken = useCallback((token: string | undefined) => {
+ if (token) {
+ cookies.set(cookies.NAMES.REWARDS_API_TOKEN, token);
+ } 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 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);
+ }
+ }, [ apiToken, profileQuery, setApiToken ]);
+
+ // 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) {
+ setIsLoginModalOpen.on();
+ }
+ }
+ }, [ router, apiToken, isInitialized, setIsLoginModalOpen ]);
+
+ const errorToast = useCallback((error: unknown) => {
+ const apiError = getErrorObjPayload<{ message: string }>(error);
+ toast({
+ position: 'top-right',
+ title: 'Error',
+ description: apiError?.message || getErrorMessage(error) || 'Something went wrong. Try again later.',
+ status: 'error',
+ variant: 'subtle',
+ isClosable: true,
+ });
+ }, [ toast ]);
+
+ // Login to the rewards program
+ const login = useCallback(async(refCode: string) => {
+ try {
+ if (!address) {
+ throw new Error();
+ }
+ const [ nonceResponse, checkCodeResponse ] = await Promise.all([
+ apiFetch('rewards_nonce') as Promise,
+ refCode ?
+ apiFetch('rewards_check_ref_code', { pathParams: { code: refCode } }) as Promise :
+ Promise.resolve({ valid: true }),
+ ]);
+ if (!checkCodeResponse.valid) {
+ return { invalidRefCodeError: true };
+ }
+ const message = getMessageToSign(address, nonceResponse.nonce, checkUserQuery.data?.exists, refCode);
+ const signature = await signMessageAsync({ message });
+ const loginResponse = await apiFetch('rewards_login', {
+ fetchParams: {
+ method: 'POST',
+ body: {
+ nonce: nonceResponse.nonce,
+ message,
+ signature,
+ },
+ },
+ }) as RewardsLoginResponse;
+ saveApiToken(loginResponse.token);
+ return { isNewUser: loginResponse.created };
+ } catch (_error) {
+ errorToast(_error);
+ throw _error;
+ }
+ }, [ apiFetch, address, signMessageAsync, errorToast, saveApiToken, checkUserQuery ]);
+
+ // Claim daily reward
+ const claim = useCallback(async() => {
+ try {
+ await apiFetch('rewards_user_daily_claim', {
+ fetchParams: {
+ method: 'POST',
+ ...fetchParams,
+ },
+ }) as RewardsUserDailyClaimResponse;
+ } catch (_error) {
+ errorToast(_error);
+ throw _error;
+ }
+ }, [ apiFetch, errorToast, fetchParams ]);
+
+ const value = useMemo(() => {
+ if (!feature.isEnabled) {
+ return initialState;
+ }
+ return {
+ balancesQuery,
+ dailyRewardQuery,
+ referralsQuery,
+ rewardsConfigQuery,
+ checkUserQuery,
+ apiToken,
+ isInitialized,
+ isLoginModalOpen,
+ openLoginModal: setIsLoginModalOpen.on,
+ closeLoginModal: setIsLoginModalOpen.off,
+ login,
+ claim,
+ };
+ }, [
+ isLoginModalOpen, setIsLoginModalOpen, balancesQuery, dailyRewardQuery, checkUserQuery,
+ apiToken, login, claim, referralsQuery, rewardsConfigQuery, isInitialized,
+ ]);
+
+ return (
+
+ { children }
+
+ );
+}
+
+export function useRewardsContext() {
+ return useContext(RewardsContext);
+}
diff --git a/lib/cookies.ts b/lib/cookies.ts
index f4cf66debc..cfcc2e5416 100644
--- a/lib/cookies.ts
+++ b/lib/cookies.ts
@@ -5,6 +5,8 @@ import isBrowser from './isBrowser';
export enum NAMES {
NAV_BAR_COLLAPSED='nav_bar_collapsed',
API_TOKEN='_explorer_key',
+ REWARDS_API_TOKEN='rewards_api_token',
+ REWARDS_REFERRAL_CODE='rewards_ref_code',
TXS_SORT='txs_sort',
COLOR_MODE='chakra-ui-color-mode',
COLOR_MODE_HEX='chakra-ui-color-mode-hex',
diff --git a/lib/decodeJWT.ts b/lib/decodeJWT.ts
new file mode 100644
index 0000000000..d94e21d4de
--- /dev/null
+++ b/lib/decodeJWT.ts
@@ -0,0 +1,47 @@
+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;
+ }
+}
diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts
index 7159ae3000..b0c6580b4e 100644
--- a/lib/metadata/getPageOgType.ts
+++ b/lib/metadata/getPageOgType.ts
@@ -28,6 +28,7 @@ const OG_TYPE_DICT: Record = {
'/graphiql': 'Regular page',
'/search-results': 'Regular page',
'/auth/profile': 'Root page',
+ '/account/rewards': 'Regular page',
'/account/watchlist': 'Regular page',
'/account/api-key': 'Regular page',
'/account/custom-abi': 'Regular page',
diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts
index 30452a5041..3e562d6a8d 100644
--- a/lib/metadata/templates/description.ts
+++ b/lib/metadata/templates/description.ts
@@ -32,6 +32,7 @@ const TEMPLATE_MAP: Record = {
'/graphiql': DEFAULT_TEMPLATE,
'/search-results': DEFAULT_TEMPLATE,
'/auth/profile': DEFAULT_TEMPLATE,
+ '/account/rewards': DEFAULT_TEMPLATE,
'/account/watchlist': DEFAULT_TEMPLATE,
'/account/api-key': DEFAULT_TEMPLATE,
'/account/custom-abi': DEFAULT_TEMPLATE,
diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts
index ed65a43528..dca9185e32 100644
--- a/lib/metadata/templates/title.ts
+++ b/lib/metadata/templates/title.ts
@@ -28,6 +28,7 @@ const TEMPLATE_MAP: Record = {
'/graphiql': 'GraphQL for %network_name% - %network_name% data query',
'/search-results': '%network_name% search result for %q%',
'/auth/profile': '%network_name% - my profile',
+ '/account/rewards': '%network_name% - rewards',
'/account/watchlist': '%network_name% - watchlist',
'/account/api-key': '%network_name% - API keys',
'/account/custom-abi': '%network_name% - custom ABI',
diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts
index 176046f379..71e52d5b90 100644
--- a/lib/mixpanel/getPageType.ts
+++ b/lib/mixpanel/getPageType.ts
@@ -26,6 +26,7 @@ export const PAGE_TYPE_DICT: Record = {
'/graphiql': 'GraphQL',
'/search-results': 'Search results',
'/auth/profile': 'Profile',
+ '/account/rewards': 'Merits',
'/account/watchlist': 'Watchlist',
'/account/api-key': 'API keys',
'/account/custom-abi': 'Custom ABI',
diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts
index 76d7b89e5a..acea62a1f8 100644
--- a/lib/mixpanel/utils.ts
+++ b/lib/mixpanel/utils.ts
@@ -97,7 +97,7 @@ Type extends EventTypes.VERIFY_TOKEN ? {
'Action': 'Form opened' | 'Submit';
} :
Type extends EventTypes.WALLET_CONNECT ? {
- 'Source': 'Header' | 'Login' | 'Profile' | 'Profile dropdown' | 'Smart contracts' | 'Swap button';
+ 'Source': 'Header' | 'Login' | 'Profile' | 'Profile dropdown' | 'Smart contracts' | 'Swap button' | 'Merits';
'Status': 'Started' | 'Connected';
} :
Type extends EventTypes.WALLET_ACTION ? (
diff --git a/mocks/rewards/balance.ts b/mocks/rewards/balance.ts
new file mode 100644
index 0000000000..8c78eab127
--- /dev/null
+++ b/mocks/rewards/balance.ts
@@ -0,0 +1,10 @@
+import type { RewardsUserBalancesResponse } from 'types/api/rewards';
+
+export const base: RewardsUserBalancesResponse = {
+ total: '250',
+ staked: '0',
+ unstaked: '0',
+ total_staking_rewards: '0',
+ total_referral_rewards: '0',
+ pending_referral_rewards: '0',
+};
diff --git a/mocks/rewards/dailyReward.ts b/mocks/rewards/dailyReward.ts
new file mode 100644
index 0000000000..c60377e43e
--- /dev/null
+++ b/mocks/rewards/dailyReward.ts
@@ -0,0 +1,9 @@
+import type { RewardsUserDailyCheckResponse } from 'types/api/rewards';
+
+export const base: RewardsUserDailyCheckResponse = {
+ available: true,
+ daily_reward: '10',
+ pending_referral_rewards: '0',
+ date: '',
+ reset_at: '',
+};
diff --git a/mocks/rewards/referrals.ts b/mocks/rewards/referrals.ts
new file mode 100644
index 0000000000..bac2b26042
--- /dev/null
+++ b/mocks/rewards/referrals.ts
@@ -0,0 +1,7 @@
+import type { RewardsUserReferralsResponse } from 'types/api/rewards';
+
+export const base: RewardsUserReferralsResponse = {
+ code: 'QWERTY',
+ link: 'https://example.com?ref=QWERTY',
+ referrals: '15',
+};
diff --git a/mocks/rewards/rewardsConfig.ts b/mocks/rewards/rewardsConfig.ts
new file mode 100644
index 0000000000..25ae8b7277
--- /dev/null
+++ b/mocks/rewards/rewardsConfig.ts
@@ -0,0 +1,10 @@
+import type { RewardsConfigResponse } from 'types/api/rewards';
+
+export const base: RewardsConfigResponse = {
+ rewards: {
+ registration: '100',
+ registration_with_referral: '200',
+ daily_claim: '10',
+ referral_share: '0.1',
+ },
+};
diff --git a/mocks/user/profile.ts b/mocks/user/profile.ts
index e0178bc140..7794447c25 100644
--- a/mocks/user/profile.ts
+++ b/mocks/user/profile.ts
@@ -15,3 +15,11 @@ export const withoutEmail: UserInfo = {
nickname: 'tom2drum',
address_hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
};
+
+export const withEmailAndWallet: UserInfo = {
+ avatar: 'https://avatars.githubusercontent.com/u/22130104',
+ email: 'tom@ohhhh.me',
+ name: 'tom goriunov',
+ nickname: 'tom2drum',
+ address_hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
+};
diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts
index 1b7a495284..43b73147d0 100644
--- a/nextjs/csp/policies/app.ts
+++ b/nextjs/csp/policies/app.ts
@@ -67,6 +67,7 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint,
getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
+ getFeaturePayload(config.features.rewards)?.api.endpoint,
// chain RPC server
config.chain.rpcUrl,
diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts
index 41b2bdd9f1..d6a7cb9764 100644
--- a/nextjs/nextjs-routes.d.ts
+++ b/nextjs/nextjs-routes.d.ts
@@ -9,6 +9,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/404">
| StaticRoute<"/account/api-key">
| StaticRoute<"/account/custom-abi">
+ | StaticRoute<"/account/rewards">
| StaticRoute<"/account/tag-address">
| StaticRoute<"/account/verified-addresses">
| StaticRoute<"/account/watchlist">
diff --git a/nextjs/utils/fetchProxy.ts b/nextjs/utils/fetchProxy.ts
index 0c52c9f80e..a8f0c032f2 100644
--- a/nextjs/utils/fetchProxy.ts
+++ b/nextjs/utils/fetchProxy.ts
@@ -23,7 +23,8 @@ export default function fetchFactory(
cookie: apiToken ? `${ cookies.NAMES.API_TOKEN }=${ apiToken }` : '',
..._pick(_req.headers, [
'x-csrf-token',
- 'Authorization',
+ 'Authorization', // the old value, just in case
+ 'authorization', // Node.js automatically lowercases headers
// feature flags
'updated-gas-oracle',
]) as Record,
diff --git a/pages/_app.tsx b/pages/_app.tsx
index 475700fbf5..525c9ce88a 100644
--- a/pages/_app.tsx
+++ b/pages/_app.tsx
@@ -13,11 +13,13 @@ import useQueryClientConfig from 'lib/api/useQueryClientConfig';
import { AppContextProvider } from 'lib/contexts/app';
import { ChakraProvider } from 'lib/contexts/chakra';
import { MarketplaceContextProvider } from 'lib/contexts/marketplace';
+import { RewardsContextProvider } from 'lib/contexts/rewards';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import { growthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import useNotifyOnNavigation from 'lib/hooks/useNotifyOnNavigation';
import { SocketProvider } from 'lib/socket/context';
+import RewardsLoginModal from 'ui/rewards/login/RewardsLoginModal';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import AppErrorGlobalContainer from 'ui/shared/AppError/AppErrorGlobalContainer';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
@@ -69,9 +71,12 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
-
- { getLayout() }
-
+
+
+ { getLayout() }
+ { config.features.rewards.isEnabled && }
+
+
diff --git a/pages/account/rewards.tsx b/pages/account/rewards.tsx
new file mode 100644
index 0000000000..422ce4c90f
--- /dev/null
+++ b/pages/account/rewards.tsx
@@ -0,0 +1,19 @@
+import type { NextPage } from 'next';
+import dynamic from 'next/dynamic';
+import React from 'react';
+
+import PageNextJs from 'nextjs/PageNextJs';
+
+const RewardsDashboard = dynamic(() => import('ui/pages/RewardsDashboard'), { ssr: false });
+
+const Page: NextPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default Page;
+
+export { account as getServerSideProps } from 'nextjs/getServerSideProps';
diff --git a/playwright/TestApp.tsx b/playwright/TestApp.tsx
index 07b5c12b34..aaa4bc7259 100644
--- a/playwright/TestApp.tsx
+++ b/playwright/TestApp.tsx
@@ -11,6 +11,7 @@ import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { MarketplaceContext } from 'lib/contexts/marketplace';
+import { RewardsContextProvider } from 'lib/contexts/rewards';
import { SocketProvider } from 'lib/socket/context';
import currentChain from 'lib/web3/currentChain';
import theme from 'theme/theme';
@@ -77,7 +78,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketp
- { children }
+
+ { children }
+
diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts
index 9bd117d86c..e010a8c4f1 100644
--- a/playwright/fixtures/mockEnvs.ts
+++ b/playwright/fixtures/mockEnvs.ts
@@ -81,4 +81,7 @@ export const ENVS_MAP: Record> = {
nameService: [
[ 'NEXT_PUBLIC_NAME_SERVICE_API_HOST', 'https://localhost:3101' ],
],
+ rewardsService: [
+ [ 'NEXT_PUBLIC_REWARDS_SERVICE_API_HOST', 'http://localhost:3003' ],
+ ],
};
diff --git a/playwright/fixtures/rewards.ts b/playwright/fixtures/rewards.ts
new file mode 100644
index 0000000000..fc6052f477
--- /dev/null
+++ b/playwright/fixtures/rewards.ts
@@ -0,0 +1,16 @@
+import type { BrowserContext, TestFixture } from '@playwright/test';
+
+import config from 'configs/app';
+import * as cookies from 'lib/cookies';
+
+// This JWT token contains 0xd789a607CEac2f0E14867de4EB15b15C9FFB5859 address
+const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIweGQ3ODlhNjA3Q0VhYzJmMEUxNDg2N2RlNEVCMTViMTVDOUZGQjU4NTkiLCJpYXQiOjE3MzA0NzAyNTIsImV4cCI6MTczMDQ3MDU1Mn0.uhWH59mJQhpWcK8RHaLQ-X_nieXZsYE-VdcPrjYNvp4'; // eslint-disable-line max-len
+
+export function authenticateUser(context: BrowserContext) {
+ context.addCookies([ { name: cookies.NAMES.REWARDS_API_TOKEN, value: token, domain: config.app.host, path: '/' } ]);
+}
+
+export const contextWithRewards: TestFixture = async({ context }, use) => {
+ authenticateUser(context);
+ use(context);
+};
diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts
index e01deeae1f..f44fd2e272 100644
--- a/public/icons/name.d.ts
+++ b/public/icons/name.d.ts
@@ -86,6 +86,11 @@
| "link_external"
| "link"
| "lock"
+ | "merits_colored"
+ | "merits_slim"
+ | "merits_with_dot_slim"
+ | "merits_with_dot"
+ | "merits"
| "minus"
| "monaco/file"
| "monaco/folder-open"
diff --git a/public/static/badges/badge_1.svg b/public/static/badges/badge_1.svg
new file mode 100644
index 0000000000..967cdc5b2a
--- /dev/null
+++ b/public/static/badges/badge_1.svg
@@ -0,0 +1,46 @@
+
diff --git a/public/static/badges/badge_2.svg b/public/static/badges/badge_2.svg
new file mode 100644
index 0000000000..7ac747f389
--- /dev/null
+++ b/public/static/badges/badge_2.svg
@@ -0,0 +1,41 @@
+
diff --git a/public/static/badges/badge_3.svg b/public/static/badges/badge_3.svg
new file mode 100644
index 0000000000..9d1a1e7899
--- /dev/null
+++ b/public/static/badges/badge_3.svg
@@ -0,0 +1,51 @@
+
diff --git a/public/static/badges/badge_4.svg b/public/static/badges/badge_4.svg
new file mode 100644
index 0000000000..18d5c72260
--- /dev/null
+++ b/public/static/badges/badge_4.svg
@@ -0,0 +1,56 @@
+
diff --git a/public/static/badges/badge_5.svg b/public/static/badges/badge_5.svg
new file mode 100644
index 0000000000..2c5f4f560c
--- /dev/null
+++ b/public/static/badges/badge_5.svg
@@ -0,0 +1,46 @@
+
diff --git a/public/static/merits_program.png b/public/static/merits_program.png
new file mode 100644
index 0000000000..8e5dee0551
Binary files /dev/null and b/public/static/merits_program.png differ
diff --git a/types/api/rewards.ts b/types/api/rewards.ts
new file mode 100644
index 0000000000..8d2d12b437
--- /dev/null
+++ b/types/api/rewards.ts
@@ -0,0 +1,53 @@
+export type RewardsConfigResponse = {
+ rewards: {
+ registration: string;
+ registration_with_referral: string;
+ daily_claim: string;
+ referral_share: string;
+ };
+};
+
+export type RewardsCheckRefCodeResponse = {
+ valid: boolean;
+};
+
+export type RewardsNonceResponse = {
+ nonce: string;
+};
+
+export type RewardsCheckUserResponse = {
+ exists: boolean;
+};
+
+export type RewardsLoginResponse = {
+ created: boolean;
+ token: string;
+};
+
+export type RewardsUserBalancesResponse = {
+ total: string;
+ staked: string;
+ unstaked: string;
+ total_staking_rewards: string;
+ total_referral_rewards: string;
+ pending_referral_rewards: string;
+};
+
+export type RewardsUserDailyCheckResponse = {
+ available: boolean;
+ daily_reward: string;
+ pending_referral_rewards: string;
+ date: string;
+ reset_at: string;
+};
+
+export type RewardsUserDailyClaimResponse = {
+ daily_reward: string;
+ pending_referral_rewards: string;
+};
+
+export type RewardsUserReferralsResponse = {
+ code: string;
+ link: string;
+ referrals: string;
+};
diff --git a/ui/home/HeroBanner.pw.tsx b/ui/home/HeroBanner.pw.tsx
index 9e510644fc..a637ab382e 100644
--- a/ui/home/HeroBanner.pw.tsx
+++ b/ui/home/HeroBanner.pw.tsx
@@ -1,8 +1,12 @@
import type { BrowserContext } from '@playwright/test';
import React from 'react';
+import * as rewardsBalanceMock from 'mocks/rewards/balance';
+import * as dailyRewardMock from 'mocks/rewards/dailyReward';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
+import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
+import { contextWithRewards } from 'playwright/fixtures/rewards';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
@@ -10,12 +14,15 @@ import HeroBanner from './HeroBanner';
const authTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
+}).extend<{ context: BrowserContext }>({
+ context: contextWithRewards,
});
authTest('customization +@dark-mode', async({ render, page, mockEnvs, mockApiResponse }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.png';
await mockEnvs([
+ ...ENVS_MAP.rewardsService,
// eslint-disable-next-line max-len
[ 'NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG', `{"background":["lightpink","no-repeat center/cover url(${ IMAGE_URL })"],"text_color":["deepskyblue","white"],"border":["3px solid green","3px dashed yellow"],"button":{"_default":{"background":["deeppink"],"text_color":["white"]},"_selected":{"background":["lime"]}}}` ],
]);
@@ -27,7 +34,9 @@ authTest('customization +@dark-mode', async({ render, page, mockEnvs, mockApiRes
});
});
- await mockApiResponse('user_info', profileMock.base);
+ await mockApiResponse('user_info', profileMock.withEmailAndWallet);
+ await mockApiResponse('rewards_user_balances', rewardsBalanceMock.base);
+ await mockApiResponse('rewards_user_daily_check', dailyRewardMock.base);
const component = await render();
diff --git a/ui/home/HeroBanner.tsx b/ui/home/HeroBanner.tsx
index 910b6cae92..396987c9d5 100644
--- a/ui/home/HeroBanner.tsx
+++ b/ui/home/HeroBanner.tsx
@@ -2,6 +2,7 @@ import { Box, Flex, Heading, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
+import RewardsButton from 'ui/rewards/RewardsButton';
import AdBanner from 'ui/shared/ad/AdBanner';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
@@ -67,7 +68,8 @@ const HeroBanner = () => {
}
{ config.UI.navigation.layout === 'vertical' && (
-
+
+ { config.features.rewards.isEnabled && }
{
(config.features.account.isEnabled && ) ||
(config.features.blockchainInteraction.isEnabled && )
diff --git a/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png b/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png
index c82c3974fa..1997c50704 100644
Binary files a/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png and b/ui/home/__screenshots__/HeroBanner.pw.tsx_dark-color-mode_customization-dark-mode-1.png differ
diff --git a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png
index 2c43a8496d..eaac6a08d2 100644
Binary files a/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png and b/ui/home/__screenshots__/HeroBanner.pw.tsx_default_customization-dark-mode-1.png differ
diff --git a/ui/marketplace/MarketplaceAppTopBar.tsx b/ui/marketplace/MarketplaceAppTopBar.tsx
index c335616bd7..351d5b0f00 100644
--- a/ui/marketplace/MarketplaceAppTopBar.tsx
+++ b/ui/marketplace/MarketplaceAppTopBar.tsx
@@ -1,4 +1,4 @@
-import { chakra, Flex, Tooltip, Skeleton, Box } from '@chakra-ui/react';
+import { chakra, Flex, Tooltip, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport, ContractListTypes } from 'types/client/marketplace';
@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import useIsMobile from 'lib/hooks/useIsMobile';
+import RewardsButton from 'ui/rewards/RewardsButton';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
import LinkInternal from 'ui/shared/links/LinkInternal';
@@ -98,12 +99,13 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props)
source="App page"
/>
{ !isMobile && (
-
+
+ { config.features.rewards.isEnabled && }
{
(config.features.account.isEnabled && ) ||
(config.features.blockchainInteraction.isEnabled && )
}
-
+
) }
{ contractListType && (
diff --git a/ui/myProfile/MyProfileWallet.tsx b/ui/myProfile/MyProfileWallet.tsx
index ec6bfb84ce..5a9ce56f1b 100644
--- a/ui/myProfile/MyProfileWallet.tsx
+++ b/ui/myProfile/MyProfileWallet.tsx
@@ -4,7 +4,9 @@ import React from 'react';
import type { UserInfo } from 'types/api/account';
+import config from 'configs/app';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
+import LinkExternal from 'ui/shared/links/LinkExternal';
interface Props {
profileQuery: UseQueryResult;
@@ -18,7 +20,15 @@ const MyProfileWallet = ({ profileQuery, onAddWallet }: Props) => {
My linked wallet
- This wallet address is used for login and participation in the merit program
+ This wallet address is used for login{ ' ' }
+ { config.features.rewards.isEnabled && (
+ <>
+ and participation in the Merits Program.
+
+ Learn more
+
+ >
+ ) }
{ profileQuery.data?.address_hash ? (
diff --git a/ui/pages/RewardsDashboard.pw.tsx b/ui/pages/RewardsDashboard.pw.tsx
new file mode 100644
index 0000000000..d848bc0655
--- /dev/null
+++ b/ui/pages/RewardsDashboard.pw.tsx
@@ -0,0 +1,40 @@
+import type { BrowserContext } from '@playwright/test';
+import React from 'react';
+
+import * as rewardsBalanceMock from 'mocks/rewards/balance';
+import * as dailyRewardMock from 'mocks/rewards/dailyReward';
+import * as referralsMock from 'mocks/rewards/referrals';
+import * as rewardsConfigMock from 'mocks/rewards/rewardsConfig';
+import * as profileMock from 'mocks/user/profile';
+import { contextWithAuth } from 'playwright/fixtures/auth';
+import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
+import { contextWithRewards } from 'playwright/fixtures/rewards';
+import { test, expect } from 'playwright/lib';
+
+import RewardsDashboard from './RewardsDashboard';
+
+const testWithAuth = test.extend<{ context: BrowserContext }>({
+ context: contextWithAuth,
+}).extend<{ context: BrowserContext }>({
+ context: contextWithRewards,
+});
+
+testWithAuth.beforeEach(async({ mockEnvs, mockApiResponse }) => {
+ await mockEnvs([ ...ENVS_MAP.rewardsService ]);
+ await mockApiResponse('user_info', profileMock.withEmailAndWallet);
+});
+
+testWithAuth('base view +@dark-mode +@mobile', async({ render, mockApiResponse }) => {
+ await mockApiResponse('rewards_user_balances', rewardsBalanceMock.base);
+ await mockApiResponse('rewards_user_daily_check', dailyRewardMock.base);
+ await mockApiResponse('rewards_user_referrals', referralsMock.base);
+ await mockApiResponse('rewards_config', rewardsConfigMock.base);
+
+ const component = await render();
+ await expect(component).toHaveScreenshot();
+});
+
+testWithAuth('with error', async({ render }) => {
+ const component = await render();
+ await expect(component).toHaveScreenshot();
+});
diff --git a/ui/pages/RewardsDashboard.tsx b/ui/pages/RewardsDashboard.tsx
new file mode 100644
index 0000000000..2c84952ea3
--- /dev/null
+++ b/ui/pages/RewardsDashboard.tsx
@@ -0,0 +1,208 @@
+import { Flex, Skeleton, Image, Alert } from '@chakra-ui/react';
+import React, { useEffect, useState } from 'react';
+
+import config from 'configs/app';
+import { useRewardsContext } from 'lib/contexts/rewards';
+import { apos } from 'lib/html-entities';
+import DailyRewardClaimButton from 'ui/rewards/dashboard/DailyRewardClaimButton';
+import RewardsDashboardCard from 'ui/rewards/dashboard/RewardsDashboardCard';
+import RewardsDashboardCardValue from 'ui/rewards/dashboard/RewardsDashboardCardValue';
+import RewardsReadOnlyInputWithCopy from 'ui/rewards/RewardsReadOnlyInputWithCopy';
+import LinkExternal from 'ui/shared/links/LinkExternal';
+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 [ isError, setIsError ] = useState(false);
+
+ useRedirectForInvalidAuthToken();
+
+ useEffect(() => {
+ if (!config.features.rewards.isEnabled || (isInitialized && !apiToken)) {
+ window.location.assign('/');
+ }
+ }, [ isInitialized, apiToken ]);
+
+ useEffect(() => {
+ setIsError(balancesQuery.isError || referralsQuery.isError || rewardsConfigQuery.isError || dailyRewardQuery.isError);
+ }, [ balancesQuery.isError, referralsQuery.isError, rewardsConfigQuery.isError, dailyRewardQuery.isError ]);
+
+ if (!config.features.rewards.isEnabled) {
+ return null;
+ }
+
+ return (
+ <>
+
+ The Blockscout Merits Program is just getting started! Learn more about the details,
+ features, and future plans in our{ ' ' }
+
+ blog post
+ .
+
+ ) }
+ />
+
+ { isError && Failed to load some data. Please try again later. }
+
+ }
+ >
+
+ Total number of Merits earned from all activities.{ ' ' }
+
+ More info on Merits
+
+ >
+ ) }
+ />
+
+
+
+
+
+
+
+
+
+ Refer friends and boost your Merits! You receive a{ ' ' }
+
+ { rewardsConfigQuery.data?.rewards.referral_share ?
+ `${ Number(rewardsConfigQuery.data?.rewards.referral_share) * 100 }%` :
+ 'N/A'
+ }
+
+ { ' ' }bonus on all Merits earned by your referrals.
+ >
+ ) }
+ direction="row"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Collect limited and legendary badges by completing different Blockscout related tasks.
+ Go to the badges website to see what{ apos }s available and start your collection today.
+
+
+ Go to website
+
+
+ ) }
+ direction="row"
+ availableSoon
+ >
+
+ { Array(5).fill(null).map((_, index) => (
+ 2 ? 'none' : 'block', sm: 'block' }}
+ src={ `/static/badges/badge_${ index + 1 }.svg` }
+ alt={ `Badge ${ index + 1 }` }
+ w={{ base: 'calc((100% - 16px) / 3)', sm: 'calc((100% - 32px) / 5)' }}
+ maxW={{ base: '80px', md: '100px' }}
+ maxH={{ base: '80px', md: '100px' }}
+ fallback={ (
+ 2 ? 'none' : 'block', sm: 'block' }}
+ w={{ base: 'calc((100% - 16px) / 3)', sm: 'calc((100% - 32px) / 5)' }}
+ maxW={{ base: '80px', md: '100px' }}
+ maxH={{ base: '80px', md: '100px' }}
+ aspectRatio={ 1 }
+ />
+ ) }
+ />
+ )) }
+
+
+
+ >
+ );
+};
+
+export default RewardsDashboard;
diff --git a/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-address-1.png b/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-address-1.png
index 337afce347..dfcc868fc0 100644
Binary files a/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-address-1.png and b/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-address-1.png differ
diff --git a/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-email-1.png b/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-email-1.png
index cb58210807..feb70e0de7 100644
Binary files a/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-email-1.png and b/ui/pages/__screenshots__/MyProfile.pw.tsx_default_without-email-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png
new file mode 100644
index 0000000000..89f1d6b71a
Binary files /dev/null and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_base-view-dark-mode-mobile-1.png
new file mode 100644
index 0000000000..f862877285
Binary files /dev/null and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png
new file mode 100644
index 0000000000..a21039b7f5
Binary files /dev/null and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_default_with-error-1.png differ
diff --git a/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_base-view-dark-mode-mobile-1.png
new file mode 100644
index 0000000000..ea6c60c969
Binary files /dev/null and b/ui/pages/__screenshots__/RewardsDashboard.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/rewards/MeritsIcon.tsx b/ui/rewards/MeritsIcon.tsx
new file mode 100644
index 0000000000..5258e851ff
--- /dev/null
+++ b/ui/rewards/MeritsIcon.tsx
@@ -0,0 +1,21 @@
+import { Icon, useColorModeValue, chakra } from '@chakra-ui/react';
+import React from 'react';
+
+// This icon doesn't work properly when it is in the sprite
+// Probably because of the gradient
+// eslint-disable-next-line no-restricted-imports
+import meritsIcon from 'icons/merits_colored.svg';
+
+type Props = {
+ className?: string;
+}
+
+const MeritsIcon = ({ className }: Props) => {
+ const shadow = useColorModeValue('drop-shadow(0px 4px 2px rgba(141, 179, 204, 0.25))', 'none');
+
+ return (
+
+ );
+};
+
+export default chakra(MeritsIcon);
diff --git a/ui/rewards/RewardsButton.tsx b/ui/rewards/RewardsButton.tsx
new file mode 100644
index 0000000000..8256fc1836
--- /dev/null
+++ b/ui/rewards/RewardsButton.tsx
@@ -0,0 +1,69 @@
+import type { ButtonProps } from '@chakra-ui/react';
+import { Button, chakra, Tooltip } from '@chakra-ui/react';
+import React, { useCallback } from 'react';
+
+import { route } from 'nextjs-routes';
+
+import { useRewardsContext } from 'lib/contexts/rewards';
+import useIsMobile from 'lib/hooks/useIsMobile';
+import IconSvg from 'ui/shared/IconSvg';
+import LinkInternal from 'ui/shared/links/LinkInternal';
+
+type Props = {
+ size?: ButtonProps['size'];
+ variant?: ButtonProps['variant'];
+};
+
+const RewardsButton = ({ variant = 'header', size }: Props) => {
+ const { isInitialized, apiToken, openLoginModal, dailyRewardQuery, balancesQuery } = useRewardsContext();
+ const isMobile = useIsMobile();
+ const isLoading = !isInitialized || dailyRewardQuery.isLoading || balancesQuery.isLoading;
+
+ const handleFocus = useCallback((e: React.FocusEvent) => {
+ e.preventDefault();
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default RewardsButton;
diff --git a/ui/rewards/RewardsReadOnlyInputWithCopy.tsx b/ui/rewards/RewardsReadOnlyInputWithCopy.tsx
new file mode 100644
index 0000000000..fc8defb958
--- /dev/null
+++ b/ui/rewards/RewardsReadOnlyInputWithCopy.tsx
@@ -0,0 +1,39 @@
+import { FormControl, Input, InputGroup, InputRightElement, Skeleton, chakra } from '@chakra-ui/react';
+import React from 'react';
+
+import CopyToClipboard from 'ui/shared/CopyToClipboard';
+import FormInputPlaceholder from 'ui/shared/forms/inputs/FormInputPlaceholder';
+
+type Props = {
+ label: string;
+ value: string;
+ className?: string;
+ isLoading?: boolean;
+};
+
+const RewardsReadOnlyInputWithCopy = ({ label, value, className, isLoading }: Props) => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default chakra(RewardsReadOnlyInputWithCopy);
diff --git a/ui/rewards/dashboard/DailyRewardClaimButton.tsx b/ui/rewards/dashboard/DailyRewardClaimButton.tsx
new file mode 100644
index 0000000000..02c4dfec0e
--- /dev/null
+++ b/ui/rewards/dashboard/DailyRewardClaimButton.tsx
@@ -0,0 +1,89 @@
+import { Button, useBoolean, Flex, useColorModeValue } from '@chakra-ui/react';
+import React, { useCallback, useEffect, useMemo } from 'react';
+
+import { SECOND } from 'lib/consts';
+import { useRewardsContext } from 'lib/contexts/rewards';
+import splitSecondsInPeriods from 'ui/blockCountdown/splitSecondsInPeriods';
+
+const DailyRewardClaimButton = () => {
+ const { balancesQuery, dailyRewardQuery, claim } = useRewardsContext();
+ const [ isClaiming, setIsClaiming ] = useBoolean(false);
+ const [ timeLeft, setTimeLeft ] = React.useState('');
+
+ const dailyRewardValue = useMemo(() =>
+ dailyRewardQuery.data ?
+ Number((Number(dailyRewardQuery.data.daily_reward) + Number(dailyRewardQuery.data.pending_referral_rewards)).toFixed(2)) :
+ 0,
+ [ dailyRewardQuery.data ]);
+
+ const handleClaim = useCallback(async() => {
+ setIsClaiming.on();
+ try {
+ await claim();
+ await Promise.all([
+ balancesQuery.refetch(),
+ dailyRewardQuery.refetch(),
+ ]);
+ } catch (error) {}
+ setIsClaiming.off();
+ }, [ claim, setIsClaiming, balancesQuery, dailyRewardQuery ]);
+
+ useEffect(() => {
+ if (!dailyRewardQuery.data?.reset_at) {
+ return;
+ }
+
+ // 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 = 0;
+
+ const updateCountdown = (target: number) => {
+ const now = new Date().getTime();
+ const difference = target - now;
+
+ if (difference > 0) {
+ const { hours, minutes, seconds } = splitSecondsInPeriods(Math.floor(difference / SECOND));
+ setTimeLeft(`${ hours }:${ minutes }:${ seconds }`);
+ } else {
+ setTimeLeft('00:00:00');
+ dailyRewardQuery.refetch();
+ clearInterval(interval);
+ }
+ };
+
+ updateCountdown(target);
+
+ interval = window.setInterval(() => {
+ updateCountdown(target);
+ }, SECOND);
+
+ return () => clearInterval(interval);
+ }, [ dailyRewardQuery ]);
+
+ const isLoading = isClaiming || dailyRewardQuery.isPending || dailyRewardQuery.isFetching;
+ const timerBgColor = useColorModeValue('gray.200', 'gray.800');
+
+ return !isLoading && !dailyRewardQuery.data?.available ? (
+
+ Next claim in { timeLeft || 'N/A' }
+
+ ) : (
+
+ );
+};
+
+export default DailyRewardClaimButton;
diff --git a/ui/rewards/dashboard/RewardsDashboardCard.tsx b/ui/rewards/dashboard/RewardsDashboardCard.tsx
new file mode 100644
index 0000000000..5847a9359c
--- /dev/null
+++ b/ui/rewards/dashboard/RewardsDashboardCard.tsx
@@ -0,0 +1,64 @@
+import { Flex, Text, useColorModeValue, Tag } from '@chakra-ui/react';
+import React from 'react';
+
+type Props = {
+ title?: string;
+ description: string | React.ReactNode;
+ availableSoon?: boolean;
+ blurFilter?: boolean;
+ contentAfter?: React.ReactNode;
+ direction?: 'column' | 'column-reverse' | 'row';
+ reverse?: boolean;
+ children?: React.ReactNode;
+};
+
+const RewardsDashboardCard = ({
+ title, description, availableSoon, contentAfter,
+ direction = 'column', children, blurFilter,
+}: Props) => {
+ return (
+
+
+ { title && (
+
+ { title }
+ { availableSoon && Available soon }
+
+ ) }
+
+ { description }
+
+ { contentAfter }
+
+
+ { children }
+
+
+ );
+};
+
+export default RewardsDashboardCard;
diff --git a/ui/rewards/dashboard/RewardsDashboardCardValue.tsx b/ui/rewards/dashboard/RewardsDashboardCardValue.tsx
new file mode 100644
index 0000000000..3663993fc9
--- /dev/null
+++ b/ui/rewards/dashboard/RewardsDashboardCardValue.tsx
@@ -0,0 +1,46 @@
+import { Flex, Text, Skeleton } from '@chakra-ui/react';
+import React from 'react';
+
+import HintPopover from 'ui/shared/HintPopover';
+
+import MeritsIcon from '../MeritsIcon';
+
+type Props = {
+ label: string;
+ value: number | string | undefined;
+ withIcon?: boolean;
+ hint?: string | React.ReactNode;
+ isLoading?: boolean;
+}
+
+const RewardsDashboardCard = ({ label, value, withIcon, hint, isLoading }: Props) => (
+
+
+ { hint && (
+
+ ) }
+
+ { label }
+
+
+
+ { withIcon && }
+
+ { value }
+
+
+
+);
+
+export default RewardsDashboardCard;
diff --git a/ui/rewards/login/RewardsLoginModal.tsx b/ui/rewards/login/RewardsLoginModal.tsx
new file mode 100644
index 0000000000..da48b163ca
--- /dev/null
+++ b/ui/rewards/login/RewardsLoginModal.tsx
@@ -0,0 +1,57 @@
+import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, useBoolean } from '@chakra-ui/react';
+import React, { useCallback, useEffect } from 'react';
+
+import { useRewardsContext } from 'lib/contexts/rewards';
+import useIsMobile from 'lib/hooks/useIsMobile';
+import useWallet from 'lib/web3/useWallet';
+
+import CongratsStepContent from './steps/CongratsStepContent';
+import LoginStepContent from './steps/LoginStepContent';
+
+const RewardsLoginModal = () => {
+ const { isOpen: isWalletModalOpen } = useWallet({ source: 'Merits' });
+ const isMobile = useIsMobile();
+ const { isLoginModalOpen, closeLoginModal } = useRewardsContext();
+
+ const [ isLoginStep, setIsLoginStep ] = useBoolean(true);
+ const [ isReferral, setIsReferral ] = useBoolean(false);
+
+ useEffect(() => {
+ if (!isLoginModalOpen) {
+ setIsLoginStep.on();
+ setIsReferral.off();
+ }
+ }, [ isLoginModalOpen, setIsLoginStep, setIsReferral ]);
+
+ const goNext = useCallback((isReferral: boolean) => {
+ if (isReferral) {
+ setIsReferral.on();
+ }
+ setIsLoginStep.off();
+ }, [ setIsLoginStep, setIsReferral ]);
+
+ return (
+
+
+
+
+ { isLoginStep ? 'Login' : 'Congratulations' }
+
+
+
+ { isLoginStep ?
+ :
+
+ }
+
+
+
+ );
+};
+
+export default RewardsLoginModal;
diff --git a/ui/rewards/login/steps/CongratsStepContent.tsx b/ui/rewards/login/steps/CongratsStepContent.tsx
new file mode 100644
index 0000000000..525a309ad4
--- /dev/null
+++ b/ui/rewards/login/steps/CongratsStepContent.tsx
@@ -0,0 +1,132 @@
+import { Text, Box, Flex, Button, Skeleton, useColorModeValue, Tag } from '@chakra-ui/react';
+import React from 'react';
+
+import { route } from 'nextjs-routes';
+
+import { useRewardsContext } from 'lib/contexts/rewards';
+import IconSvg from 'ui/shared/IconSvg';
+
+import MeritsIcon from '../../MeritsIcon';
+import RewardsReadOnlyInputWithCopy from '../../RewardsReadOnlyInputWithCopy';
+
+type Props = {
+ isReferral: boolean;
+}
+
+const CongratsStepContent = ({ isReferral }: Props) => {
+ const { referralsQuery, rewardsConfigQuery } = useRewardsContext();
+
+ const registrationReward = rewardsConfigQuery.data?.rewards.registration;
+ const registrationWithReferralReward = rewardsConfigQuery.data?.rewards.registration_with_referral;
+ const referralReward = Number(registrationWithReferralReward) - Number(registrationReward);
+
+ const refLink = referralsQuery.data?.link || 'N/A';
+ const shareText = `I joined the @blockscoutcom Merits Program and got my first ${ registrationReward || 'N/A' } #Merits! Use this link for a sign-up bonus and start earning rewards with @blockscoutcom block explorer.\n\n${ refLink }`; // eslint-disable-line max-len
+
+ const textColor = useColorModeValue('blue.700', 'blue.100');
+ const dividerColor = useColorModeValue('whiteAlpha.800', 'whiteAlpha.100');
+
+ return (
+ <>
+
+
+
+
+ +{ rewardsConfigQuery.data?.rewards[ isReferral ? 'registration_with_referral' : 'registration' ] || 'N/A' }
+
+
+ { isReferral && (
+
+
+
+ { [
+ {
+ title: 'Registration',
+ value: registrationReward || 'N/A',
+ },
+ {
+ title: 'Referral program',
+ value: referralReward || 'N/A',
+ },
+ ].map(({ title, value }) => (
+
+
+
+
+ +{ value }
+
+
+
+ { title }
+
+
+ )) }
+
+
+ ) }
+
+
+
+
+
+
+
+ Referral program
+
+
+
+ Receive a{ ' ' }
+
+ { rewardsConfigQuery.data?.rewards.referral_share ?
+ `${ Number(rewardsConfigQuery.data?.rewards.referral_share) * 100 }%` :
+ 'N/A'
+ }
+
+ { ' ' }bonus on all Merits earned by your referrals
+
+
+
+
+
+
+
+
+
+
+ Dashboard
+
+
+
+ Explore your current Merits balance, find activities to boost your Merits,
+ and view your capybara NFT badge collection on the dashboard
+
+
+
+ >
+ );
+};
+
+export default CongratsStepContent;
diff --git a/ui/rewards/login/steps/LoginStepContent.tsx b/ui/rewards/login/steps/LoginStepContent.tsx
new file mode 100644
index 0000000000..827a9483a9
--- /dev/null
+++ b/ui/rewards/login/steps/LoginStepContent.tsx
@@ -0,0 +1,157 @@
+import { Text, Button, useColorModeValue, Image, Box, Flex, Switch, useBoolean, Input, FormControl, Alert, Skeleton, Divider } from '@chakra-ui/react';
+import { useRouter } from 'next/router';
+import type { ChangeEvent } from 'react';
+import React, { useCallback, useState, useEffect, useMemo } from 'react';
+
+import { useRewardsContext } from 'lib/contexts/rewards';
+import * as cookies from 'lib/cookies';
+import { apos } from 'lib/html-entities';
+import useWallet from 'lib/web3/useWallet';
+import FormInputPlaceholder from 'ui/shared/forms/inputs/FormInputPlaceholder';
+import LinkExternal from 'ui/shared/links/LinkExternal';
+import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
+import useSignInWithWallet from 'ui/snippets/auth/useSignInWithWallet';
+
+type Props = {
+ goNext: (isReferral: boolean) => void;
+ closeModal: () => void;
+};
+
+const LoginStepContent = ({ goNext, closeModal }: Props) => {
+ const router = useRouter();
+ const { connect, isConnected, address } = useWallet({ source: 'Merits' });
+ const savedRefCode = cookies.get(cookies.NAMES.REWARDS_REFERRAL_CODE);
+ const [ isRefCodeUsed, setIsRefCodeUsed ] = useBoolean(Boolean(savedRefCode));
+ const [ isLoading, setIsLoading ] = useBoolean(false);
+ const [ refCode, setRefCode ] = useState(savedRefCode || '');
+ const [ refCodeError, setRefCodeError ] = useBoolean(false);
+ const { login, checkUserQuery } = useRewardsContext();
+ const profileQuery = useProfileQuery();
+
+ const isAddressMismatch = useMemo(() =>
+ Boolean(address) &&
+ Boolean(profileQuery.data?.address_hash) &&
+ profileQuery.data?.address_hash !== address,
+ [ address, profileQuery.data ]);
+
+ const isSignUp = useMemo(() =>
+ isConnected && !isAddressMismatch && !checkUserQuery.isFetching && !checkUserQuery.data?.exists,
+ [ isConnected, isAddressMismatch, checkUserQuery ]);
+
+ const handleRefCodeChange = React.useCallback((event: ChangeEvent) => {
+ setRefCode(event.target.value);
+ }, []);
+
+ const loginToRewardsProgram = useCallback(async() => {
+ try {
+ setRefCodeError.off();
+ setIsLoading.on();
+ const { isNewUser, invalidRefCodeError } = await login(isSignUp && isRefCodeUsed ? refCode : '');
+ if (invalidRefCodeError) {
+ setRefCodeError.on();
+ } else {
+ if (isNewUser) {
+ goNext(Boolean(refCode));
+ } else {
+ closeModal();
+ router.push({ pathname: '/account/rewards' }, undefined, { shallow: true });
+ }
+ }
+ } catch (error) {}
+ setIsLoading.off();
+ }, [ login, goNext, setIsLoading, router, closeModal, refCode, setRefCodeError, isRefCodeUsed, isSignUp ]);
+
+ useEffect(() => {
+ if (isSignUp && isRefCodeUsed && refCode.length > 0 && refCode.length !== 6) {
+ setRefCodeError.on();
+ } else {
+ setRefCodeError.off();
+ }
+ }, [ refCode, isRefCodeUsed, isSignUp ]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const { start: loginToAccount } = useSignInWithWallet({
+ isAuth: Boolean(!profileQuery.isLoading && profileQuery.data?.email),
+ onSuccess: loginToRewardsProgram,
+ onError: setIsLoading.off,
+ });
+
+ const handleLogin = useCallback(async() => {
+ if (!profileQuery.isLoading && !profileQuery.data?.address_hash) {
+ setIsLoading.on();
+ loginToAccount();
+ return;
+ }
+ loginToRewardsProgram();
+ }, [ loginToAccount, loginToRewardsProgram, profileQuery, setIsLoading ]);
+
+ return (
+ <>
+ }
+ />
+
+ Merits are awarded for a variety of different Blockscout activities. Connect a wallet to get started.
+
+ More about Blockscout Merits
+
+
+ { isSignUp && (
+
+
+
+ I have a referral code
+
+
+ { isRefCodeUsed && (
+ <>
+
+
+
+
+
+ { refCodeError ? 'Incorrect code or format' : 'The code should be in format XXXXXX' }
+
+ >
+ ) }
+
+ ) }
+ { isAddressMismatch && (
+
+ Your wallet address doesn{ apos }t match the one in your Blockscout account. Please connect the correct wallet.
+
+ ) }
+
+
+ Already registered for Blockscout Merits on another network or chain? Connect the same wallet here.
+
+ >
+ );
+};
+
+export default LoginStepContent;
diff --git a/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_base-view-mobile-1.png b/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_base-view-mobile-1.png
index b2ac588ad8..a9c6bb39bf 100644
Binary files a/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_base-view-mobile-1.png and b/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_base-view-mobile-1.png differ
diff --git a/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_xxl-screen-vertical-navigation-1.png b/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_xxl-screen-vertical-navigation-1.png
index 7321328b21..f7265b04f2 100644
Binary files a/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_xxl-screen-vertical-navigation-1.png and b/ui/shared/layout/__screenshots__/Layout.pw.tsx_default_xxl-screen-vertical-navigation-1.png differ
diff --git a/ui/snippets/auth/AuthModal.tsx b/ui/snippets/auth/AuthModal.tsx
index 84b0b6ada9..c3068a7e9c 100644
--- a/ui/snippets/auth/AuthModal.tsx
+++ b/ui/snippets/auth/AuthModal.tsx
@@ -140,6 +140,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig }: Props) => {
@@ -149,6 +150,7 @@ const AuthModal = ({ initialScreen, onClose, mixpanelConfig }: Props) => {
diff --git a/ui/snippets/auth/__screenshots__/AuthModal.pw.tsx_default_link-email-to-account-2.png b/ui/snippets/auth/__screenshots__/AuthModal.pw.tsx_default_link-email-to-account-2.png
index 4d25c2645d..4aa441a048 100644
Binary files a/ui/snippets/auth/__screenshots__/AuthModal.pw.tsx_default_link-email-to-account-2.png and b/ui/snippets/auth/__screenshots__/AuthModal.pw.tsx_default_link-email-to-account-2.png differ
diff --git a/ui/snippets/auth/screens/AuthModalScreenSuccessEmail.tsx b/ui/snippets/auth/screens/AuthModalScreenSuccessEmail.tsx
index 9699225bef..24cd84c3ec 100644
--- a/ui/snippets/auth/screens/AuthModalScreenSuccessEmail.tsx
+++ b/ui/snippets/auth/screens/AuthModalScreenSuccessEmail.tsx
@@ -9,22 +9,32 @@ import config from 'configs/app';
interface Props {
email: string;
onConnectWallet: (screen: Screen) => void;
+ onClose: () => void;
isAuth?: boolean;
profile: UserInfo | undefined;
}
-const AuthModalScreenSuccessEmail = ({ email, onConnectWallet, isAuth, profile }: Props) => {
+const AuthModalScreenSuccessEmail = ({ email, onConnectWallet, onClose, isAuth, profile }: Props) => {
const handleConnectWalletClick = React.useCallback(() => {
onConnectWallet({ type: 'connect_wallet', isAuth: true });
}, [ onConnectWallet ]);
if (isAuth) {
return (
-
- Your account was linked to{ ' ' }
- { email }{ ' ' }
- email. Use for the next login.
-
+
+
+ Your account was linked to{ ' ' }
+ { email }{ ' ' }
+ email. Use for the next login.
+
+
+
);
}
@@ -34,11 +44,19 @@ const AuthModalScreenSuccessEmail = ({ email, onConnectWallet, isAuth, profile }
{ email }{ ' ' }
email has been successfully used to log in to your Blockscout account.
- { !profile?.address_hash && config.features.blockchainInteraction.isEnabled && (
+ { !profile?.address_hash && config.features.blockchainInteraction.isEnabled ? (
<>
Add your web3 wallet to safely interact with smart contracts and dapps inside Blockscout.
>
+ ) : (
+
) }
);
diff --git a/ui/snippets/auth/screens/AuthModalScreenSuccessWallet.tsx b/ui/snippets/auth/screens/AuthModalScreenSuccessWallet.tsx
index 75c59a5cfe..862cfdebe8 100644
--- a/ui/snippets/auth/screens/AuthModalScreenSuccessWallet.tsx
+++ b/ui/snippets/auth/screens/AuthModalScreenSuccessWallet.tsx
@@ -9,22 +9,32 @@ import shortenString from 'lib/shortenString';
interface Props {
address: string;
onAddEmail: (screen: Screen) => void;
+ onClose: () => void;
isAuth?: boolean;
profile: UserInfo | undefined;
}
-const AuthModalScreenSuccessWallet = ({ address, onAddEmail, isAuth, profile }: Props) => {
+const AuthModalScreenSuccessWallet = ({ address, onAddEmail, onClose, isAuth, profile }: Props) => {
const handleAddEmailClick = React.useCallback(() => {
onAddEmail({ type: 'email', isAuth: true });
}, [ onAddEmail ]);
if (isAuth) {
return (
-
- Your account was linked to{ ' ' }
- { shortenString(address) }{ ' ' }
- wallet. Use for the next login.
-
+
+
+ Your account was linked to{ ' ' }
+ { shortenString(address) }{ ' ' }
+ wallet. Use for the next login.
+
+
+
);
}
@@ -35,11 +45,19 @@ const AuthModalScreenSuccessWallet = ({ address, onAddEmail, isAuth, profile }:
{ shortenString(address) }{ ' ' }
has been successfully used to log in to your Blockscout account.
- { !profile?.email && (
+ { !profile?.email ? (
<>
Add your email to receive notifications about addresses in your watch list.
>
+ ) : (
+
) }
);
diff --git a/ui/snippets/header/HeaderDesktop.pw.tsx b/ui/snippets/header/HeaderDesktop.pw.tsx
index be197237e5..4cd6f0eef7 100644
--- a/ui/snippets/header/HeaderDesktop.pw.tsx
+++ b/ui/snippets/header/HeaderDesktop.pw.tsx
@@ -1,9 +1,16 @@
import React from 'react';
+import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import HeaderDesktop from './HeaderDesktop';
+test.beforeEach(async({ mockEnvs }) => {
+ await mockEnvs([
+ ...ENVS_MAP.rewardsService,
+ ]);
+});
+
test('default view +@dark-mode', async({ render }) => {
const component = await render();
await expect(component).toHaveScreenshot();
diff --git a/ui/snippets/header/HeaderDesktop.tsx b/ui/snippets/header/HeaderDesktop.tsx
index 470fb26a1c..aa597f58e0 100644
--- a/ui/snippets/header/HeaderDesktop.tsx
+++ b/ui/snippets/header/HeaderDesktop.tsx
@@ -2,19 +2,16 @@ import { HStack, Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
-import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
+import RewardsButton from 'ui/rewards/RewardsButton';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
-import Burger from './Burger';
-
type Props = {
renderSearchBar?: () => React.ReactNode;
- isMarketplaceAppPage?: boolean;
}
-const HeaderDesktop = ({ renderSearchBar, isMarketplaceAppPage }: Props) => {
+const HeaderDesktop = ({ renderSearchBar }: Props) => {
const searchBar = renderSearchBar ? renderSearchBar() : ;
@@ -25,19 +22,14 @@ const HeaderDesktop = ({ renderSearchBar, isMarketplaceAppPage }: Props) => {
width="100%"
alignItems="center"
justifyContent="center"
- gap={ 12 }
+ gap={ 6 }
>
- { isMarketplaceAppPage && (
-
-
-
-
- ) }
{ searchBar }
{ config.UI.navigation.layout === 'vertical' && (
-
+
+ { config.features.rewards.isEnabled && }
{
(config.features.account.isEnabled && ) ||
(config.features.blockchainInteraction.isEnabled && )
diff --git a/ui/snippets/header/HeaderMobile.pw.tsx b/ui/snippets/header/HeaderMobile.pw.tsx
index f1658dfa72..4757dc1e58 100644
--- a/ui/snippets/header/HeaderMobile.pw.tsx
+++ b/ui/snippets/header/HeaderMobile.pw.tsx
@@ -1,11 +1,18 @@
import React from 'react';
+import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import HeaderMobile from './HeaderMobile';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
+test.beforeEach(async({ mockEnvs }) => {
+ await mockEnvs([
+ ...ENVS_MAP.rewardsService,
+ ]);
+});
+
test('default view +@dark-mode', async({ render, page }) => {
await render();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 150 } });
diff --git a/ui/snippets/header/HeaderMobile.tsx b/ui/snippets/header/HeaderMobile.tsx
index 58a619f2e1..fc038b411d 100644
--- a/ui/snippets/header/HeaderMobile.tsx
+++ b/ui/snippets/header/HeaderMobile.tsx
@@ -4,6 +4,7 @@ import { useInView } from 'react-intersection-observer';
import config from 'configs/app';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
+import RewardsButton from 'ui/rewards/RewardsButton';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import UserProfileMobile from 'ui/snippets/user/profile/UserProfileMobile';
@@ -48,6 +49,7 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => {
+ { config.features.rewards.isEnabled && }
{
(config.features.account.isEnabled && ) ||
(config.features.blockchainInteraction.isEnabled && ) ||
diff --git a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_dark-color-mode_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_dark-color-mode_default-view-dark-mode-1.png
index 4ed68c6da1..f20dd89e9f 100644
Binary files a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_dark-color-mode_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_dark-color-mode_default-view-dark-mode-1.png differ
diff --git a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png
index f121caff2a..dbe5afb871 100644
Binary files a/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderDesktop.pw.tsx_default_default-view-dark-mode-1.png differ
diff --git a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png
index a579f81b7e..329febcd0b 100644
Binary files a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_dark-color-mode_default-view-dark-mode-1.png differ
diff --git a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png
index 73c8398390..9d13a0ad41 100644
Binary files a/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png and b/ui/snippets/header/__screenshots__/HeaderMobile.pw.tsx_default_default-view-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/horizontal/NavigationDesktop.pw.tsx b/ui/snippets/navigation/horizontal/NavigationDesktop.pw.tsx
index 7b03175fe5..3b6e47c1bc 100644
--- a/ui/snippets/navigation/horizontal/NavigationDesktop.pw.tsx
+++ b/ui/snippets/navigation/horizontal/NavigationDesktop.pw.tsx
@@ -1,15 +1,20 @@
import type { BrowserContext } from '@playwright/test';
import React from 'react';
+import * as rewardsBalanceMock from 'mocks/rewards/balance';
+import * as dailyRewardMock from 'mocks/rewards/dailyReward';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
+import { contextWithRewards } from 'playwright/fixtures/rewards';
import { test, expect } from 'playwright/lib';
import NavigationDesktop from './NavigationDesktop';
const testWithAuth = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
+}).extend<{ context: BrowserContext }>({
+ context: contextWithRewards,
});
testWithAuth('base view +@dark-mode', async({ render, mockApiResponse, mockEnvs, page }) => {
@@ -20,10 +25,13 @@ testWithAuth('base view +@dark-mode', async({ render, mockApiResponse, mockEnvs,
},
};
- await mockApiResponse('user_info', profileMock.base);
+ await mockApiResponse('user_info', profileMock.withEmailAndWallet);
+ await mockApiResponse('rewards_user_balances', rewardsBalanceMock.base);
+ await mockApiResponse('rewards_user_daily_check', dailyRewardMock.base);
await mockEnvs([
...ENVS_MAP.userOps,
...ENVS_MAP.nameService,
+ ...ENVS_MAP.rewardsService,
[ 'NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES', '["/blocks","/apps"]' ],
]);
diff --git a/ui/snippets/navigation/horizontal/NavigationDesktop.tsx b/ui/snippets/navigation/horizontal/NavigationDesktop.tsx
index bb67c3045f..d5165fc740 100644
--- a/ui/snippets/navigation/horizontal/NavigationDesktop.tsx
+++ b/ui/snippets/navigation/horizontal/NavigationDesktop.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import config from 'configs/app';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
+import RewardsButton from 'ui/rewards/RewardsButton';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
@@ -38,10 +39,13 @@ const NavigationDesktop = () => {
}) }
- {
- (config.features.account.isEnabled && ) ||
- (config.features.blockchainInteraction.isEnabled && )
- }
+
+ { config.features.rewards.isEnabled && }
+ {
+ (config.features.account.isEnabled && ) ||
+ (config.features.blockchainInteraction.isEnabled && )
+ }
+
);
diff --git a/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_base-view-dark-mode-1.png
index 5e65fee57a..cef729543c 100644
Binary files a/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_default_base-view-dark-mode-1.png b/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_default_base-view-dark-mode-1.png
index 910c9d00b8..a7cbb35b3a 100644
Binary files a/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_default_base-view-dark-mode-1.png and b/ui/snippets/navigation/horizontal/__screenshots__/NavigationDesktop.pw.tsx_default_base-view-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/mobile/NavigationMobile.tsx b/ui/snippets/navigation/mobile/NavigationMobile.tsx
index 7652634029..3069784f53 100644
--- a/ui/snippets/navigation/mobile/NavigationMobile.tsx
+++ b/ui/snippets/navigation/mobile/NavigationMobile.tsx
@@ -7,6 +7,7 @@ import IconSvg from 'ui/shared/IconSvg';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import NavLink from '../vertical/NavLink';
+import NavLinkRewards from '../vertical/NavLinkRewards';
import NavLinkGroup from './NavLinkGroup';
const DRAWER_WIDTH = 330;
@@ -82,6 +83,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
borderColor="divider"
>
+
{ accountNavItems.map((item) => ) }
@@ -120,8 +122,11 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
>
{ item.map(subItem => ) }
- ) :
- ,
+ ) : (
+
+
+
+ ),
) }
diff --git a/ui/snippets/navigation/vertical/NavLink.tsx b/ui/snippets/navigation/vertical/NavLink.tsx
index 4dfaf67e43..275fce4ee2 100644
--- a/ui/snippets/navigation/vertical/NavLink.tsx
+++ b/ui/snippets/navigation/vertical/NavLink.tsx
@@ -1,4 +1,4 @@
-import { Link, Text, HStack, Tooltip, Box, useBreakpointValue, chakra, shouldForwardProp } from '@chakra-ui/react';
+import { Link, Text, HStack, Tooltip, Box, useBreakpointValue } from '@chakra-ui/react';
import NextLink from 'next/link';
import React from 'react';
@@ -18,23 +18,22 @@ import { checkRouteHighlight } from '../utils';
type Props = {
item: NavItem;
+ onClick?: (e: React.MouseEvent) => void;
isCollapsed?: boolean;
- px?: string | number;
- className?: string;
- onClick?: () => void;
- disableActiveState?: boolean;
+ isDisabled?: boolean;
}
-const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState }: Props) => {
+const NavLink = ({ item, onClick, isCollapsed, isDisabled }: Props) => {
const isMobile = useIsMobile();
const colors = useColors();
- const isExpanded = isCollapsed === false;
const isInternalLink = isInternalItem(item);
+ const href = isInternalLink ? route(item.nextRoute) : item.url;
+
+ const isExpanded = isCollapsed === false;
- const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive && !disableActiveState });
+ const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive });
const isXLScreen = useBreakpointValue({ base: false, xl: true });
- const href = isInternalLink ? route(item.nextRoute) : item.url;
const isHighlighted = checkRouteHighlight(item);
@@ -46,13 +45,13 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState
w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }}
display="flex"
position="relative"
- px={ px || { base: 2, lg: isExpanded ? 2 : '15px', xl: isCollapsed ? '15px' : 2 } }
+ px={{ base: 2, lg: isExpanded ? 2 : '15px', xl: isCollapsed ? '15px' : 2 }}
aria-label={ `${ item.text } link` }
whiteSpace="nowrap"
onClick={ onClick }
_hover={{
[`& *:not(.${ LIGHTNING_LABEL_CLASS_NAME }, .${ LIGHTNING_LABEL_CLASS_NAME } *)`]: {
- color: 'link_hovered',
+ color: isDisabled ? 'inherit' : 'link_hovered',
},
}}
>
@@ -81,7 +80,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState
);
return (
-
+
{ isInternalLink ? (
{ content }
@@ -91,16 +90,4 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState
);
};
-const NavLinkChakra = chakra(NavLink, {
- shouldForwardProp: (prop) => {
- const isChakraProp = !shouldForwardProp(prop);
-
- if (isChakraProp && prop !== 'px') {
- return false;
- }
-
- return true;
- },
-});
-
-export default React.memo(NavLinkChakra);
+export default React.memo(NavLink);
diff --git a/ui/snippets/navigation/vertical/NavLinkRewards.tsx b/ui/snippets/navigation/vertical/NavLinkRewards.tsx
new file mode 100644
index 0000000000..93d9fce589
--- /dev/null
+++ b/ui/snippets/navigation/vertical/NavLinkRewards.tsx
@@ -0,0 +1,50 @@
+import { useRouter } from 'next/router';
+import React, { useCallback } from 'react';
+
+import type { Route } from 'nextjs-routes';
+
+import config from 'configs/app';
+import { useRewardsContext } from 'lib/contexts/rewards';
+
+import NavLink from './NavLink';
+
+type Props = {
+ isCollapsed?: boolean;
+ onClick?: () => void;
+}
+
+const NavLinkRewards = ({ isCollapsed, onClick }: Props) => {
+ const router = useRouter();
+ const { openLoginModal, dailyRewardQuery, apiToken, isInitialized } = useRewardsContext();
+
+ const pathname = '/account/rewards';
+ const nextRoute = { pathname } as Route;
+
+ const handleClick = useCallback((e: React.MouseEvent) => {
+ if (isInitialized && !apiToken) {
+ e.preventDefault();
+ openLoginModal();
+ }
+ onClick?.();
+ }, [ onClick, isInitialized, apiToken, openLoginModal ]);
+
+ if (!config.features.rewards.isEnabled) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default React.memo(NavLinkRewards);
diff --git a/ui/snippets/navigation/vertical/NavigationDesktop.pw.tsx b/ui/snippets/navigation/vertical/NavigationDesktop.pw.tsx
index e89af8e7aa..6088bd9280 100644
--- a/ui/snippets/navigation/vertical/NavigationDesktop.pw.tsx
+++ b/ui/snippets/navigation/vertical/NavigationDesktop.pw.tsx
@@ -24,6 +24,7 @@ const FEATURED_NETWORKS_URL = 'https://localhost:3000/featured-networks.json';
test.beforeEach(async({ mockEnvs, mockConfigResponse }) => {
await mockEnvs([
+ ...ENVS_MAP.rewardsService,
[ 'NEXT_PUBLIC_FEATURED_NETWORKS', FEATURED_NETWORKS_URL ],
]);
await mockConfigResponse('NEXT_PUBLIC_FEATURED_NETWORKS', FEATURED_NETWORKS_URL, FEATURED_NETWORKS_MOCK);
diff --git a/ui/snippets/navigation/vertical/NavigationDesktop.tsx b/ui/snippets/navigation/vertical/NavigationDesktop.tsx
index 76d7e7c21d..660b72d242 100644
--- a/ui/snippets/navigation/vertical/NavigationDesktop.tsx
+++ b/ui/snippets/navigation/vertical/NavigationDesktop.tsx
@@ -14,6 +14,7 @@ import NetworkMenu from 'ui/snippets/networkMenu/NetworkMenu';
import TestnetBadge from '../TestnetBadge';
import NavLink from './NavLink';
import NavLinkGroup from './NavLinkGroup';
+import NavLinkRewards from './NavLinkRewards';
const NavigationDesktop = () => {
const appProps = useAppContext();
@@ -100,6 +101,7 @@ const NavigationDesktop = () => {
{ isAuth && (
+
{ accountNavItems.map((item) => ) }
diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png
index a164e0d570..e6338b1a8c 100644
Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-xl-screen-dark-mode-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-xl-screen-dark-mode-1.png
index 4cef46f32b..57b2ee44fd 100644
Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-xl-screen-dark-mode-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_auth-xl-screen-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-dark-mode-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-dark-mode-1.png
index 1fdea87741..63d6ce1323 100644
Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-dark-mode-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-dark-mode-1.png differ
diff --git a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-xl-screen-dark-mode-1.png b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-xl-screen-dark-mode-1.png
index 534a1b22d6..147db329a2 100644
Binary files a/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-xl-screen-dark-mode-1.png and b/ui/snippets/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_auth-xl-screen-dark-mode-1.png differ
diff --git a/ui/snippets/user/profile/UserProfileButton.tsx b/ui/snippets/user/profile/UserProfileButton.tsx
index 70134e51bd..d5360792d6 100644
--- a/ui/snippets/user/profile/UserProfileButton.tsx
+++ b/ui/snippets/user/profile/UserProfileButton.tsx
@@ -1,7 +1,7 @@
import type { ButtonProps } from '@chakra-ui/react';
-import { Button, Skeleton, Tooltip, Box, HStack } from '@chakra-ui/react';
+import { Button, Tooltip, Box, HStack } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
-import React from 'react';
+import React, { useCallback, useState } from 'react';
import type { UserInfo } from 'types/api/account';
@@ -22,8 +22,8 @@ interface Props {
isPending?: boolean;
}
-const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending }: Props, ref: React.ForwardedRef) => {
- const [ isFetched, setIsFetched ] = React.useState(false);
+const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending }: Props, ref: React.ForwardedRef) => {
+ const [ isFetched, setIsFetched ] = useState(false);
const isMobile = useIsMobile();
const { data, isLoading } = profileQuery;
@@ -36,6 +36,10 @@ const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending }:
}
}, [ isLoading ]);
+ const handleFocus = useCallback((e: React.FocusEvent) => {
+ e.preventDefault();
+ }, []);
+
const content = (() => {
if (web3AccountWithDomain.address) {
return (
@@ -60,31 +64,34 @@ const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending }:
);
})();
+ const isButtonLoading = isPending || !isFetched;
+ const dataExists = !isButtonLoading && (Boolean(data) || Boolean(web3AccountWithDomain.address));
+
return (
Sign in to My Account to add tags,
create watchlists, access API keys and more }
textAlign="center"
padding={ 2 }
- isDisabled={ isMobile || isFetched || Boolean(data) }
+ isDisabled={ isMobile || isLoading || Boolean(data) }
openDelay={ 500 }
>
-
-
-
+
);
};
diff --git a/ui/snippets/user/profile/UserProfileContent.tsx b/ui/snippets/user/profile/UserProfileContent.tsx
index 27a799dba4..7fb618a630 100644
--- a/ui/snippets/user/profile/UserProfileContent.tsx
+++ b/ui/snippets/user/profile/UserProfileContent.tsx
@@ -92,14 +92,14 @@ const UserProfileContent = ({ data, onClose, onLogin, onAddEmail, onAddAddress }
Address
{ data?.address_hash ?
{ shortenString(data?.address_hash) } :
- Add address
+ Add address
}
) }
@@ -107,7 +107,7 @@ const UserProfileContent = ({ data, onClose, onLogin, onAddEmail, onAddAddress }
Email
{ data?.email ?
:
- Add email
+ Add email
}
diff --git a/ui/snippets/user/profile/UserProfileContentWallet.tsx b/ui/snippets/user/profile/UserProfileContentWallet.tsx
index 3cb3e2953f..f811b6809f 100644
--- a/ui/snippets/user/profile/UserProfileContentWallet.tsx
+++ b/ui/snippets/user/profile/UserProfileContentWallet.tsx
@@ -41,7 +41,7 @@ const UserProfileContentWallet = ({ onClose, className }: Props) => {
isTooltipDisabled
truncation="dynamic"
fontSize="sm"
- fontWeight={ 700 }
+ fontWeight={ 500 }
/>