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 program } + /> + + 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 } />