Smart wallet auth (#3220)

* basic login button implementation

* refactoring and add dynamic auth button to the other pages

* connect wallet without authenticating an user

* login and logout user in blockscout account

* refactoring and RPC overrides

* authenticate user with connected wallet

* add ENS to profile button

* override evm chains and fix contract write with waas wallet

* disconnect wallet and fix docker build

* add dynamic imports to components that use web3 wallet packages

* refactor user profile directory

* clean up

* tweaks

* update pw ci script

* fix "connect wallet" button behavior

* handle linking email to profile with dynamic provider

* fix test

* async loading web3 hooks and remove dynamic loading from components that use them

* fix pw tests

* add account navigation in horizontal layout when auth provider is dynamic

* fix address verification modal

* review fixes

* spelling fixes
This commit is contained in:
tom goriunov
2026-02-18 19:42:44 +01:00
committed by GitHub
parent 4f614c4677
commit 401dc58402
90 changed files with 2754 additions and 315 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ RUN set -a && \
# ENV NEXT_TELEMETRY_DISABLED 1
# Build app for production
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN yarn build
+30 -8
View File
@@ -1,4 +1,5 @@
import type { Feature } from './types';
import type { AuthProvider } from 'types/client/account';
import app from '../app';
import services from '../services';
@@ -6,18 +7,39 @@ import { getEnvValue } from '../utils';
const title = 'My account';
const config: Feature<{ isEnabled: true; recaptchaSiteKey: string }> = (() => {
const config: Feature<{
isEnabled: true;
authProvider: AuthProvider;
dynamic?: {
environmentId: string;
};
}> = (() => {
if (
!app.isPrivateMode &&
getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' &&
services.reCaptchaV2.siteKey
getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true'
) {
return Object.freeze({
title,
isEnabled: true,
recaptchaSiteKey: services.reCaptchaV2.siteKey,
});
const authProvider = getEnvValue('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER');
const dynamicEnvironmentId = getEnvValue('NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID');
if (authProvider === 'dynamic' && dynamicEnvironmentId) {
return Object.freeze({
title,
isEnabled: true,
authProvider: 'dynamic',
dynamic: {
environmentId: dynamicEnvironmentId,
},
});
}
if (services.reCaptchaV2.siteKey) {
return Object.freeze({
title,
isEnabled: true,
authProvider: 'auth0',
});
}
}
return Object.freeze({
+31 -11
View File
@@ -3,13 +3,22 @@ import type { Feature } from './types';
import app from '../app';
import chain from '../chain';
import { getEnvValue, parseEnvJson } from '../utils';
import accountFeature from './account';
import opSuperchain from './opSuperchain';
const walletConnectProjectId = getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID');
const title = 'Blockchain interaction (writing to contract, etc.)';
const config: Feature<{ walletConnect: { projectId: string; featuredWalletIds: Array<string> } }> = (() => {
type FeaturePayload = {
connectorType: 'reown';
reown: { projectId: string; featuredWalletIds: Array<string> };
} | {
connectorType: 'dynamic';
dynamic: { environmentId: string };
};
const config: Feature<FeaturePayload> = (() => {
// all chain parameters are required for wagmi provider
// @wagmi/chains/dist/index.d.ts
@@ -26,17 +35,28 @@ const config: Feature<{ walletConnect: { projectId: string; featuredWalletIds: A
if (
!app.isPrivateMode &&
(isSingleChain || isOpSuperchain) &&
walletConnectProjectId
(isSingleChain || isOpSuperchain)
) {
return Object.freeze({
title,
isEnabled: true,
walletConnect: {
projectId: walletConnectProjectId,
featuredWalletIds: parseEnvJson<Array<string>>(getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_FEATURED_WALLET_IDS')) ?? [],
},
});
if (accountFeature.isEnabled && accountFeature.authProvider === 'dynamic' && accountFeature.dynamic?.environmentId) {
return Object.freeze({
title,
isEnabled: true,
connectorType: 'dynamic',
dynamic: {
environmentId: accountFeature.dynamic.environmentId,
},
});
} else if (walletConnectProjectId) {
return Object.freeze({
title,
isEnabled: true,
connectorType: 'reown',
reown: {
projectId: walletConnectProjectId,
featuredWalletIds: parseEnvJson<Array<string>>(getEnvValue('NEXT_PUBLIC_WALLET_CONNECT_FEATURED_WALLET_IDS')) ?? [],
},
});
}
}
return Object.freeze({
+2 -1
View File
@@ -8,7 +8,8 @@ import blockchainInteraction from './blockchainInteraction';
const title = 'Rewards service integration';
const config: Feature<{}> = (() => {
if (!app.isPrivateMode && apis.rewards && account.isEnabled && blockchainInteraction.isEnabled) {
// @0xdeval: as of now, we won't support rewards programs with dynamic auth provider
if (!app.isPrivateMode && apis.rewards && account.isEnabled && account.authProvider === 'auth0' && blockchainInteraction.isEnabled) {
return Object.freeze({
title,
isEnabled: true,
+2 -1
View File
@@ -10,6 +10,7 @@ NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/homepage-highlights/test.json
# NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic
# Instance ENVs
NEXT_PUBLIC_AD_BANNER_ENABLE_SPECIFY=true
@@ -64,7 +65,7 @@ NEXT_PUBLIC_NETWORK_ID=11155111
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_NAME=Sepolia
NEXT_PUBLIC_NETWORK_RPC_URL=https://sepolia.drpc.org
NEXT_PUBLIC_NETWORK_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png
+3 -4
View File
@@ -25,7 +25,9 @@
// Specify publisher key
"spk_\\w+",
// Posthog project key
"phc_\\w+"
"phc_\\w+",
// GitHub account names
"@(\\w|-)+",
],
// words - list of words to be always considered correct
"words": [
@@ -105,7 +107,6 @@
"explorable",
"facebookexternalhit",
"favicons",
"fedoseev",
"filecoin",
"flashblock",
"flashblocks",
@@ -131,7 +132,6 @@
"inpage",
"internaltx",
"ipfs",
"isstuev",
"iszero",
"jazzicon",
"jfif",
@@ -252,7 +252,6 @@
"utka",
"utko",
"UUPS",
"vbaranov",
"VCALENDAR",
"vercel",
"verifreg",
+1 -1
View File
@@ -103,7 +103,6 @@ const schema = yup
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: yup.boolean(),
NEXT_PUBLIC_ADVANCED_FILTER_ENABLED: yup.boolean(),
NEXT_PUBLIC_CELO_ENABLED: yup.boolean(),
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED: yup.boolean(),
NEXT_PUBLIC_DEX_POOLS_ENABLED: yup.boolean()
.when('NEXT_PUBLIC_CONTRACT_INFO_API_HOST', {
is: (value: string) => Boolean(value),
@@ -159,6 +158,7 @@ const schema = yup
.concat(uiSchemas.footerSchema)
.concat(uiSchemas.miscSchema)
.concat(uiSchemas.viewsSchema)
.concat(featuresSchemas.accountSchema)
.concat(featuresSchemas.address3rdPartyWidgetsConfigSchema)
.concat(featuresSchemas.adsSchema)
.concat(featuresSchemas.apiDocsSchema)
@@ -0,0 +1,22 @@
import { AuthProvider } from 'types/client/account';
import * as yup from 'yup';
export const accountSchema = yup
.object()
.shape({
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED: yup.boolean(),
NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER: yup
.string<AuthProvider>()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', {
is: (value: boolean) => value === true,
then: (schema) => schema.oneOf([ 'auth0', 'dynamic' ]),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not defined'),
}),
NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID: yup
.string()
.when('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER', {
is: (value: AuthProvider) => value === 'dynamic',
then: (schema) => schema.required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'dynamic\' '),
}),
});
@@ -1,3 +1,4 @@
export * from './account';
export * from './address3rdPartyWidgets';
export * from './ads';
export * from './apiDocs';
@@ -10,3 +10,6 @@ NEXT_PUBLIC_METADATA_ADDRESS_TAGS_UPDATE_ENABLED=false
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://example.com
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG={'img_url': {'small': 'https://example.com/promo-sm.png', 'large': 'https://example.com/promo-lg.png'}, 'link_url': 'https://example.com'}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic
NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID=xxx
+3 -1
View File
@@ -428,7 +428,9 @@ Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED | `boolean` | Set to true if network has account feature | Required | - | `true` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `boolean` | See [below](#google-recaptcha) | Required | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER | `auth0 \| dynamic` | Auth provider that enables basic user authentication. | - | `auth0` | `dynamic` | upcoming |
| NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID | `string` | Environment ID of the Dynamic project. | Required, if provider is `dynamic` | - | `<your-secret>` | upcoming |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `boolean` | See [below](#google-recaptcha) | Required, if provided is `auth0` | - | `<your-secret>` | v1.0.x+ |
&nbsp;
+3
View File
@@ -58,6 +58,9 @@ export const GENERAL_API_ACCOUNT_RESOURCES = {
auth_logout: {
path: '/api/account/auth/logout',
},
auth_dynamic: {
path: '/api/account/v2/authenticate_via_dynamic',
},
} satisfies Record<string, ApiResource>;
export type GeneralApiAccountResourceName = `general:${ keyof typeof GENERAL_API_ACCOUNT_RESOURCES }`;
+5
View File
@@ -0,0 +1,5 @@
import type React from 'react';
export const FallbackProvider = ({ children }: { children: React.ReactNode }) => {
return children;
};
+1 -1
View File
@@ -73,7 +73,7 @@ Type extends EventTypes.LOGIN ? (
Source: 'Email';
} | {
Action: 'Success';
Source: 'Email' | 'Wallet';
Source: 'Email' | 'Wallet' | 'Dynamic';
}
) :
Type extends EventTypes.ACCOUNT_LINK_INFO ? {
+40
View File
@@ -0,0 +1,40 @@
import { useDynamicContext, useUserWallets } from '@dynamic-labs/sdk-react-core';
import React from 'react';
import type { UseAccountReturnType } from 'wagmi';
export default function useAccountDynamic(): UseAccountReturnType {
const userWallets = useUserWallets();
const { primaryWallet } = useDynamicContext();
const address = (primaryWallet?.address || userWallets[0]?.address) as `0x${ string }` | undefined;
return React.useMemo(() => {
if (!address) {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
return {
address,
addresses: [ address ],
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: true,
isConnecting: false,
isDisconnected: false,
isReconnecting: false,
status: 'connected',
} as unknown as UseAccountReturnType;
}, [ address ]);
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import type { UseAccountReturnType } from 'wagmi';
export default function useAccountFallback(): UseAccountReturnType {
return React.useMemo(() => ({
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
}), []);
}
+10 -3
View File
@@ -4,7 +4,11 @@ import appConfig from 'configs/app';
import essentialDappsChainsConfig from 'configs/essential-dapps-chains';
import multichainConfig from 'configs/multichain';
const getChainInfo = (config: Partial<typeof appConfig> = appConfig, contracts?: Chain['contracts']): Chain | undefined => {
const getChainInfo = (
config: Partial<typeof appConfig> = appConfig,
contracts?: Chain['contracts'],
logoUrl?: string,
): Chain | undefined => {
if (!config.chain || !config.app) {
return;
}
@@ -30,6 +34,9 @@ const getChainInfo = (config: Partial<typeof appConfig> = appConfig, contracts?:
},
testnet: config.chain.isTestnet,
contracts,
custom: {
logoUrl: logoUrl ?? config.UI?.navigation.icon.default,
},
};
};
@@ -74,7 +81,7 @@ export const clusterChains: Array<Chain> | undefined = (() => {
return;
}
return config.chains.map(({ app_config: config }) => getChainInfo(config)).filter(Boolean);
return config.chains.map(({ app_config: config, logo }) => getChainInfo(config, undefined, logo)).filter(Boolean);
})();
export const essentialDappsChains: Array<Chain> | undefined = (() => {
@@ -84,7 +91,7 @@ export const essentialDappsChains: Array<Chain> | undefined = (() => {
return;
}
return config.chains.map(({ app_config: config, contracts }) => getChainInfo(config, contracts)).filter(Boolean);
return config.chains.map(({ app_config: config, contracts, logo }) => getChainInfo(config, contracts, logo)).filter(Boolean);
})();
export const chains = (() => {
+8 -19
View File
@@ -1,23 +1,12 @@
import type { UseAccountReturnType } from 'wagmi';
import { useAccount } from 'wagmi';
import config from 'configs/app';
function useAccountFallback(): UseAccountReturnType {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
const feature = config.features.blockchainInteraction;
const hook = config.features.blockchainInteraction.isEnabled ? useAccount : useAccountFallback;
// eslint-disable-next-line no-nested-ternary
const useAccount = (feature.isEnabled && feature.connectorType === 'dynamic') ?
(await import('./account/useAccountDynamic')).default :
(feature.isEnabled && feature.connectorType === 'reown') ?
(await import('wagmi')).useAccount :
(await import('./account/useAccountFallback')).default;
export default hook;
export default useAccount;
+9 -60
View File
@@ -1,63 +1,12 @@
import { useAppKit, useAppKitState } from '@reown/appkit/react';
import React from 'react';
import { useDisconnect, useAccountEffect } from 'wagmi';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import useAccount from 'lib/web3/useAccount';
const feature = config.features.blockchainInteraction;
interface Params {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
onConnect?: () => void;
}
// eslint-disable-next-line no-nested-ternary
const useWallet = (feature.isEnabled && feature.connectorType === 'dynamic') ?
(await import('./wallet/useWalletDynamic')).default :
(feature.isEnabled && feature.connectorType === 'reown') ?
(await import('./wallet/useWalletReown')).default :
(await import('./wallet/useWalletFallback')).default;
export default function useWeb3Wallet({ source, onConnect }: Params) {
const { open: openModal } = useAppKit();
const { open: isOpen } = useAppKitState();
const { disconnect } = useDisconnect();
const [ isOpening, setIsOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const isConnectionStarted = React.useRef(false);
React.useEffect(() => {
setIsClientLoaded(true);
}, []);
const handleConnect = React.useCallback(async() => {
setIsOpening(true);
await openModal();
setIsOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
isConnectionStarted.current = true;
}, [ openModal, source ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
if (!isReconnected && isConnectionStarted.current) {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Connected' });
mixpanel.userProfile.setOnce({
'With Connected Wallet': true,
});
onConnect?.();
}
isConnectionStarted.current = false;
}, [ source, onConnect ]);
const handleDisconnect = React.useCallback(() => {
disconnect();
}, [ disconnect ]);
useAccountEffect({ onConnect: handleAccountConnected });
const account = useAccount();
const address = account.address;
const isConnected = isClientLoaded && !account.isDisconnected && account.address !== undefined;
return React.useMemo(() => ({
connect: handleConnect,
disconnect: handleDisconnect,
isOpen: isOpening || isOpen,
isConnected,
isReconnecting: account.isReconnecting,
address,
openModal,
}), [ handleConnect, handleDisconnect, isOpening, isOpen, isConnected, account.isReconnecting, address, openModal ]);
}
export default useWallet;
+3 -2
View File
@@ -47,7 +47,7 @@ const reduceExternalChainsToTransportConfig = (readOnly: boolean): Record<string
const wagmi = (() => {
if (!feature.isEnabled) {
if (!feature.isEnabled || feature.connectorType === 'dynamic') {
const wagmiConfig = createConfig({
chains: chains as [Chain, ...Array<Chain>],
transports: {
@@ -57,6 +57,7 @@ const wagmi = (() => {
},
ssr: true,
batch: { multicall: { wait: 100, batchSize: 5 } },
multiInjectedProviderDiscovery: feature.isEnabled && feature.connectorType === 'dynamic' ? false : true,
});
return { config: wagmiConfig, adapter: null };
@@ -70,7 +71,7 @@ const wagmi = (() => {
...(parentChain ? { [parentChain.id]: http() } : {}),
...reduceExternalChainsToTransportConfig(false),
},
projectId: feature.walletConnect.projectId,
projectId: feature.reown.projectId,
ssr: true,
batch: { multicall: { wait: 100, batchSize: 5 } },
syncConnectedChain: false,
+16
View File
@@ -0,0 +1,16 @@
import type * as mixpanel from 'lib/mixpanel/index';
export interface Params {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
onConnect?: () => void;
}
export interface Result {
connect: () => void;
disconnect: () => void;
isOpen: boolean;
isConnected: boolean;
isReconnecting: boolean;
address: string | undefined;
openModal: () => void;
}
+72
View File
@@ -0,0 +1,72 @@
import { useDynamicContext, useDynamicEvents, useUserWallets } from '@dynamic-labs/sdk-react-core';
import React from 'react';
import { useAccountEffect } from 'wagmi';
import type { Params, Result } from './types';
import * as mixpanel from 'lib/mixpanel/index';
import useAccountDynamic from '../account/useAccountDynamic';
export default function useWalletDynamic({ source, onConnect }: Params): Result {
const isConnectionStarted = React.useRef(false);
const [ isOpen ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const { setShowDynamicUserProfile, setShowAuthFlow, setAuthMode, removeWallet } = useDynamicContext();
const openModal = React.useCallback(() => {
setShowDynamicUserProfile(true);
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Dropdown open' });
}, [ setShowDynamicUserProfile ]);
useDynamicEvents('authFlowOpen', async() => {
if (!isConnectionStarted.current) {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
isConnectionStarted.current = true;
}
});
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
if (!isReconnected && isConnectionStarted.current) {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Connected' });
mixpanel.userProfile.setOnce({
'With Connected Wallet': true,
});
onConnect?.();
}
isConnectionStarted.current = false;
}, [ source, onConnect ]);
const handleConnect = React.useCallback(() => {
setAuthMode('connect-only');
setShowAuthFlow(true);
}, [ setAuthMode, setShowAuthFlow ]);
const userWallets = useUserWallets();
const primaryWalletId = userWallets[0]?.id;
const handleDisconnect = React.useCallback(() => {
primaryWalletId && removeWallet(primaryWalletId);
}, [ primaryWalletId, removeWallet ]);
useAccountEffect({ onConnect: handleAccountConnected });
React.useEffect(() => {
setIsClientLoaded(true);
}, []);
const account = useAccountDynamic();
const address = account.address;
const isConnected = isClientLoaded && !account.isDisconnected && account.address !== undefined;
return React.useMemo(() => ({
connect: handleConnect,
disconnect: handleDisconnect,
isOpen,
isConnected,
isReconnecting: false,
address,
openModal,
}), [ handleConnect, handleDisconnect, isOpen, isConnected, address, openModal ]);
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import type { Result } from './types';
export default function useWalletFallback(): Result {
return React.useMemo(() => ({
connect: () => {},
disconnect: () => {},
isOpen: false,
isConnected: false,
isReconnecting: false,
address: undefined,
openModal: () => {},
}), []);
}
+59
View File
@@ -0,0 +1,59 @@
import { useAppKit, useAppKitState } from '@reown/appkit/react';
import React from 'react';
import { useAccountEffect, useAccount, useDisconnect } from 'wagmi';
import type { Params, Result } from './types';
import * as mixpanel from 'lib/mixpanel/index';
export default function useWalletReown({ source, onConnect }: Params): Result {
const { open: openModal } = useAppKit();
const { open: isOpen } = useAppKitState();
const { disconnect } = useDisconnect();
const [ isOpening, setIsOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const isConnectionStarted = React.useRef(false);
React.useEffect(() => {
setIsClientLoaded(true);
}, []);
const handleConnect = React.useCallback(async() => {
setIsOpening(true);
await openModal();
setIsOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
isConnectionStarted.current = true;
}, [ openModal, source ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
if (!isReconnected && isConnectionStarted.current) {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Connected' });
mixpanel.userProfile.setOnce({
'With Connected Wallet': true,
});
onConnect?.();
}
isConnectionStarted.current = false;
}, [ source, onConnect ]);
const handleDisconnect = React.useCallback(() => {
disconnect();
}, [ disconnect ]);
useAccountEffect({ onConnect: handleAccountConnected });
const account = useAccount();
const address = account.address;
const isConnected = isClientLoaded && !account.isDisconnected && account.address !== undefined;
return React.useMemo(() => ({
connect: handleConnect,
disconnect: handleDisconnect,
isOpen: isOpening || isOpen,
isConnected,
isReconnecting: account.isReconnecting,
address,
openModal,
}), [ handleConnect, handleDisconnect, isOpening, isOpen, isConnected, account.isReconnecting, address, openModal ]);
}
+9 -1
View File
@@ -25,6 +25,14 @@ const moduleExports = {
);
config.resolve.fallback = { fs: false, net: false, tls: false };
config.externals.push('pino-pretty', 'lokijs', 'encoding');
config.experiments = { ...config.experiments, topLevelAwait: true };
// Tell webpack the target supports async/await so it stops warning about top-level await
// Top-level await is belong to ES2017 specification that is adopted by all major browsers and Node.js.
config.output.environment = {
...config.output.environment,
asyncFunction: true,
};
return config;
},
@@ -36,7 +44,7 @@ const moduleExports = {
redirects,
headers,
output: 'standalone',
productionBrowserSourceMaps: true,
productionBrowserSourceMaps: false,
serverExternalPackages: ["@opentelemetry/sdk-node", "@opentelemetry/auto-instrumentations-node"],
experimental: {
staleTimes: {
+1 -1
View File
@@ -6,6 +6,7 @@ function generateCspPolicy(isPrivateMode = false) {
descriptors.app(isPrivateMode),
// Exclude tracking/analytics sources in private mode
isPrivateMode ? {} : descriptors.ad(),
isPrivateMode ? {} : descriptors.blockchainInteraction(),
descriptors.cloudFlare(),
descriptors.flashblocks(),
descriptors.gasHawk(),
@@ -23,7 +24,6 @@ function generateCspPolicy(isPrivateMode = false) {
descriptors.rollup(),
descriptors.safe(),
descriptors.usernameApi(),
isPrivateMode ? {} : descriptors.walletConnect(),
descriptors.zetachain(),
);
@@ -0,0 +1,63 @@
import type CspDev from 'csp-dev';
import config from 'configs/app';
import { KEY_WORDS } from '../utils';
const feature = config.features.blockchainInteraction;
export function blockchainInteraction(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}
switch (feature.connectorType) {
case 'reown': {
return {
'connect-src': [
'*.web3modal.com',
'*.web3modal.org',
'*.walletconnect.com',
'*.walletconnect.org',
'wss://relay.walletconnect.com',
'wss://relay.walletconnect.org',
'wss://www.walletlink.org',
],
'frame-ancestors': [
'*.walletconnect.org',
'*.walletconnect.com',
],
'img-src': [
KEY_WORDS.BLOB,
'*.walletconnect.com',
],
};
}
case 'dynamic': {
return {
'connect-src': [
'https://dynamic-static-assets.com',
'https://app.dynamicauth.com',
'https://logs.dynamicauth.com',
],
'font-src': [
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-400-normal.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-400-normal.woff',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-400-italic.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-400-italic.woff',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-500-normal.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-500-normal.woff',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-500-italic.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-500-italic.woff',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-700-normal.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-700-normal.woff',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-700-italic.woff2',
'https://cdn.jsdelivr.net/npm/@fontsource/dm-sans/files/dm-sans-latin-700-italic.woff',
],
'style-src': [
'https://app.dynamic.xyz',
],
};
}
}
}
+1 -1
View File
@@ -1,5 +1,6 @@
export { ad } from './ad';
export { app } from './app';
export { blockchainInteraction } from './blockchainInteraction';
export { cloudFlare } from './cloudFlare';
export { flashblocks } from './flashblocks';
export { gasHawk } from './gasHawk';
@@ -17,5 +18,4 @@ export { rollbar } from './rollbar';
export { rollup } from './rollup';
export { safe } from './safe';
export { usernameApi } from './usernameApi';
export { walletConnect } from './walletConnect';
export { zetachain } from './zetachain';
-31
View File
@@ -1,31 +0,0 @@
import type CspDev from 'csp-dev';
import config from 'configs/app';
import { KEY_WORDS } from '../utils';
export function walletConnect(): CspDev.DirectiveDescriptor {
if (!config.features.blockchainInteraction.isEnabled) {
return {};
}
return {
'connect-src': [
'*.web3modal.com',
'*.web3modal.org',
'*.walletconnect.com',
'*.walletconnect.org',
'wss://relay.walletconnect.com',
'wss://relay.walletconnect.org',
'wss://www.walletlink.org',
],
'frame-ancestors': [
'*.walletconnect.org',
'*.walletconnect.com',
],
'img-src': [
KEY_WORDS.BLOB,
'*.walletconnect.com',
],
};
}
+9
View File
@@ -18,6 +18,15 @@ export const account: Guard = (chainConfig: typeof config) => async() => {
}
};
export const accountAuth0: Guard = (chainConfig: typeof config) => async() => {
const feature = chainConfig.features.account;
if (!feature.isEnabled || feature.authProvider !== 'auth0') {
return {
notFound: true,
};
}
};
export const verifiedAddresses: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.addressVerification.isEnabled) {
return {
+1
View File
@@ -6,6 +6,7 @@ export const block = factory([ guards.notOpSuperchain ]);
export const tx = factory([ guards.notOpSuperchain ]);
export const token = factory([ guards.notOpSuperchain ]);
export const account = factory([ guards.account ]);
export const accountAuth0 = factory([ guards.accountAuth0 ]);
export const verifiedAddresses = factory([ guards.account, guards.verifiedAddresses ]);
export const userOps = factory([ guards.userOps ]);
export const marketplace = factory([ guards.marketplace ]);
+6 -1
View File
@@ -51,6 +51,9 @@
"@blockscout/visualizer-types": "0.2.0",
"@blockscout/zetachain-cctx-types": "^1.0.0-rc.6",
"@chakra-ui/react": "3.15.0",
"@dynamic-labs/ethereum": "4.52.2",
"@dynamic-labs/sdk-react-core": "4.52.2",
"@dynamic-labs/wagmi-connector": "4.52.2",
"@emotion/react": "11.14.0",
"@growthbook/growthbook-react": "0.21.0",
"@helia/verified-fetch": "2.6.12",
@@ -208,7 +211,9 @@
"@reown/appkit-adapter-wagmi/**/node-forge": "1.3.2",
"eslint/**/brace-expansion": "1.1.12",
"swagger-ui-react/**/js-yaml": "4.1.1",
"dappscout-iframe/**/ws": "8.17.1"
"dappscout-iframe/**/ws": "8.17.1",
"viem/**/@noble/hashes": "1.8.0",
"@walletconnect/ethereum-provider/**/@reown/appkit/**/@noble/hashes": "1.8.0"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+17 -12
View File
@@ -3,6 +3,7 @@ import { GrowthBookProvider } from '@growthbook/growthbook-react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { AppProps } from 'next/app';
import dynamic from 'next/dynamic';
import React from 'react';
import type { NextPageWithLayout } from 'nextjs/types';
@@ -11,8 +12,8 @@ import config from 'configs/app';
import getSocketUrl from 'lib/api/getSocketUrl';
import useQueryClientConfig from 'lib/api/useQueryClientConfig';
import { AppContextProvider } from 'lib/contexts/app';
import { FallbackProvider } from 'lib/contexts/fallback';
import { MarketplaceContextProvider } from 'lib/contexts/marketplace';
import { RewardsContextProvider } from 'lib/contexts/rewards';
import { SettingsContextProvider } from 'lib/contexts/settings';
import { initGrowthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
@@ -20,13 +21,15 @@ import { clientConfig as rollbarConfig, Provider as RollbarProvider } from 'lib/
import { SocketProvider } from 'lib/socket/context';
import { Provider as ChakraProvider } from 'toolkit/chakra/provider';
import { Toaster } from 'toolkit/chakra/toaster';
import RewardsLoginModal from 'ui/rewards/login/RewardsLoginModal';
import RewardsActivityTracker from 'ui/rewards/RewardsActivityTracker';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import AppErrorGlobalContainer from 'ui/shared/AppError/AppErrorGlobalContainer';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
import Layout from 'ui/shared/layout/Layout';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import Web3Provider from 'ui/shared/web3/Web3Provider';
const RewardsContextProvider = dynamic(() => import('lib/contexts/rewards').then(module => module.RewardsContextProvider), { ssr: false });
const RewardsLoginModal = dynamic(() => import('ui/rewards/login/RewardsLoginModal'), { ssr: false });
const RewardsActivityTracker = dynamic(() => import('ui/rewards/RewardsActivityTracker'), { ssr: false });
import 'lib/setLocale';
// import 'focus-visible/dist/focus-visible';
@@ -88,6 +91,8 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
);
})();
const RewardsProvider = config.features.rewards.isEnabled ? RewardsContextProvider : FallbackProvider;
const socketUrl = !config.features.opSuperchain.isEnabled ? getSocketUrl() : undefined;
return (
@@ -97,25 +102,25 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
{ ...ERROR_SCREEN_STYLES }
Container={ AppErrorGlobalContainer }
>
<Web3ModalProvider>
<AppContextProvider pageProps={ pageProps }>
<QueryClientProvider client={ queryClient }>
<QueryClientProvider client={ queryClient }>
<Web3Provider>
<AppContextProvider pageProps={ pageProps }>
<GrowthBookProvider growthbook={ growthBook }>
<SocketProvider url={ socketUrl }>
<RewardsContextProvider>
<RewardsProvider>
<MarketplaceContextProvider>
<SettingsContextProvider>
{ content }
</SettingsContextProvider>
</MarketplaceContextProvider>
</RewardsContextProvider>
</RewardsProvider>
</SocketProvider>
</GrowthBookProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
<GoogleAnalytics/>
</QueryClientProvider>
</AppContextProvider>
</Web3ModalProvider>
</AppContextProvider>
</Web3Provider>
</QueryClientProvider>
</AppErrorBoundary>
</RollbarProvider>
</ChakraProvider>
+1 -1
View File
@@ -15,4 +15,4 @@ const Page: NextPage = () => {
export default Page;
export { account as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { accountAuth0 as getServerSideProps } from 'nextjs/getServerSideProps/main';
+15
View File
@@ -68,6 +68,21 @@ const config: PlaywrightTestConfig = defineConfig({
// https://github.com/storybookjs/builder-vite/issues/409#issuecomment-1152848986
sourcemap: false,
minify: false,
rollupOptions: {
output: {
// Ensure __envs exists in every chunk so code (e.g. next/router, configs/app) that
// runs before index.ts/envs.js doesn't throw. Real envs are set when envs.js runs.
//
// Explanation:
// With await import(...) in useAccount/useWallet, Vite turns those into separate chunks.
// In Playwright CT, the component’s chunk (and its dependency graph, including useAccount → config → getEnvValue → __envs)
// can load and run before the main entry that runs envs.js.
// So when that chunk runs, window.__envs isn’t set yet and you get ReferenceError: __envs is not defined.
//
// https://rollupjs.org/configuration-options/#output-banner-output-footer
banner: '(function(){if(typeof window!==\'undefined\')window.__envs=window.__envs||{};})();',
},
},
},
resolve: {
alias: [
+1 -1
View File
@@ -78,7 +78,7 @@ echo "Affected flag: $affected_flag"
echo "Files to run: $files_to_run"
dotenv \
-v NODE_OPTIONS=\"--max-old-space-size=4096\" \
-v NODE_OPTIONS=\"--max-old-space-size=8192\" \
-e $config_file \
-- playwright test -c playwright-ct.config.ts $files_to_run $args
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
+2
View File
@@ -1,3 +1,5 @@
export interface CsrfData {
token: string;
}
export type AuthProvider = 'auth0' | 'dynamic';
@@ -12,7 +12,7 @@ import { Skeleton } from 'toolkit/chakra/skeleton';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import AuthGuard from 'ui/snippets/auth/guard/AuthGuard';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import ContractAbi from './ContractAbi';
@@ -26,7 +26,7 @@ import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './util
import type { ContractMethodFormFields } from './utils';
// eslint-disable-next-line max-len
const NO_WALLET_CLIENT_TEXT = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
const NO_WALLET_CLIENT_TEXT = 'Blockchain interaction is not available at the moment since a wallet connector is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
interface Props {
data: SmartContractMethod;
@@ -1,6 +1,6 @@
import React from 'react';
import { getAddress, type Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import { encodeFunctionData, getAddress, type Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain, usePublicClient } from 'wagmi';
import type { FormSubmitResult, SmartContractMethod } from './types';
@@ -10,6 +10,8 @@ import useRewardsActivity from 'lib/hooks/useRewardsActivity';
import { getNativeCoinValue } from './utils';
const feature = config.features.blockchainInteraction;
interface Params {
item: SmartContractMethod;
args: Array<unknown>;
@@ -19,8 +21,10 @@ interface Params {
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const multichainContext = useMultichainContext();
const chainConfig = (multichainContext?.chain.app_config ?? config).chain;
const targetChainId = chainConfig?.id ? Number(chainConfig.id) : undefined;
const { data: walletClient } = useWalletClient({ chainId: Number(chainConfig?.id) });
const { data: walletClient } = useWalletClient({ chainId: targetChainId });
const publicClient = usePublicClient({ chainId: targetChainId });
const { isConnected, chainId, address: account } = useAccount();
const { switchChainAsync } = useSwitchChain();
const { trackTransaction, trackTransactionConfirm } = useRewardsActivity();
@@ -34,8 +38,8 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
throw new Error('Wallet Client is not defined');
}
if (chainId && String(chainId) !== chainConfig?.id) {
await switchChainAsync({ chainId: Number(chainConfig?.id) });
if (chainId && targetChainId && chainId !== targetChainId) {
await switchChainAsync({ chainId: targetChainId });
}
const address = getAddress(addressHash);
@@ -50,9 +54,20 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
// if the fallback method acts as a read method, it can only have one input of type bytes
// so we pass the input value as data without encoding it
const data = typeof _args[0] === 'string' && _args[0].startsWith('0x') ? _args[0] as `0x${ string }` : undefined;
// seems like Dynamic WaaS (assigned when user signed up with email) does not estimate gas for transactions
// so we have to do it manually
const estimatedGas = feature.isEnabled && feature.connectorType === 'dynamic' ? await publicClient?.estimateGas({
account,
to: address,
value,
data: item.type === 'fallback' ? data : undefined,
}) : undefined;
const hash = await walletClient.sendTransaction({
to: address,
value,
gas: estimatedGas,
...(data ? { data } : {}),
});
@@ -69,6 +84,19 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
throw new Error('Method name is not defined');
}
// seems like Dynamic WaaS (assigned when user signed up with email) does not estimate gas for transactions
// so we have to do it manually
const estimatedGas = feature.isEnabled && feature.connectorType === 'dynamic' ? await publicClient?.estimateGas({
account,
to: address,
value,
data: encodeFunctionData({
abi: [ item ],
functionName: methodName,
args: _args,
}),
}) : undefined;
const hash = await walletClient.writeContract({
args: _args,
// Here we provide the ABI as an array containing only one item from the submitted form.
@@ -83,6 +111,7 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
address,
value,
account,
gas: estimatedGas,
});
if (activityResponse?.token) {
@@ -90,5 +119,5 @@ export default function useCallMethodWalletClient(): (params: Params) => Promise
}
return { source: 'wallet_client', data: { hash } };
}, [ chainId, chainConfig, isConnected, switchChainAsync, walletClient, account, trackTransaction, trackTransactionConfirm ]);
}, [ isConnected, walletClient, chainId, targetChainId, trackTransaction, account, publicClient, switchChainAsync, trackTransactionConfirm ]);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { IconButton } from 'toolkit/chakra/icon-button';
import { Tooltip } from 'toolkit/chakra/tooltip';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import AuthGuard from 'ui/snippets/auth/guard/AuthGuard';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
@@ -1,12 +1,15 @@
import dynamic from 'next/dynamic';
import React from 'react';
import type { AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess } from './types';
import type { VerifiedAddress } from 'types/api/account';
import config from 'configs/app';
import { FallbackProvider } from 'lib/contexts/fallback';
import * as mixpanel from 'lib/mixpanel/index';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'toolkit/chakra/dialog';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
const Web3ProviderBase = dynamic(() => import('ui/shared/web3/Web3Provider'), { ssr: false });
import AddressVerificationStepAddress from './steps/AddressVerificationStepAddress';
import AddressVerificationStepSignature from './steps/AddressVerificationStepSignature';
@@ -14,6 +17,10 @@ import AddressVerificationStepSuccess from './steps/AddressVerificationStepSucce
type StateData = AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess & { isToken?: boolean };
const feature = config.features.blockchainInteraction;
// Dynamic providers cannot be nested, so a dummy provider is used here
const Web3Provider = feature.isEnabled && feature.connectorType === 'dynamic' ? FallbackProvider : Web3ProviderBase;
interface Props {
open: boolean;
onOpenChange: ({ open }: { open: boolean }) => void;
@@ -115,9 +122,9 @@ const AddressVerificationModal = ({ defaultAddress, open, onOpenChange, onSubmit
{ step.title }
</DialogHeader>
<DialogBody mb={ 0 }>
<Web3ModalProvider>
<Web3Provider>
{ step.content }
</Web3ModalProvider>
</Web3Provider>
</DialogBody>
</DialogContent>
</DialogRoot>
@@ -1,5 +1,4 @@
import { Box, chakra, Flex } from '@chakra-ui/react';
import { useAppKit } from '@reown/appkit/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
@@ -18,6 +17,7 @@ import type { VerifiedAddress } from 'types/api/account';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import shortenString from 'lib/shortenString';
import useWallet from 'lib/web3/useWallet';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
@@ -39,8 +39,8 @@ interface Props extends AddressVerificationFormFirstStepFields, AddressCheckStat
const AddressVerificationStepSignature = ({ address, signingMessage, contractCreator, contractOwner, onContinue, noWeb3Provider }: Props) => {
const [ signMethod, setSignMethod ] = React.useState<SignMethod>(noWeb3Provider ? 'manual' : 'wallet');
const { open: openWeb3Modal } = useAppKit();
const { isConnected } = useAccount();
const { openModal: openWeb3Modal } = useWallet({ source: 'Smart contracts' });
const formApi = useForm<Fields>({
mode: 'onBlur',
+1 -1
View File
@@ -78,7 +78,7 @@ const GraphQL = () => {
url: graphqlUrl,
// graphql ws implementation with absinthe plugin is incompatible with graphiql-ws protocol
// or the older one subscriptions-transport-ws
// so we (isstuev & vbaranov) decided to configure playground without subscriptions
// so we (@isstuev & @vbaranov) decided to configure playground without subscriptions
// in case of any complaint consider reconfigure the graphql ws server with absinthe_graphql_ws package
// subscriptionUrl: `wss://${config.app.host}/socket/`,
});
+2 -6
View File
@@ -9,8 +9,7 @@ import RewardsButton from 'ui/rewards/RewardsButton';
import AdBanner from 'ui/shared/ad/AdBanner';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import SearchBarMobile from 'ui/snippets/searchBar/SearchBarMobile';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
export const BACKGROUND_DEFAULT =
'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)';
@@ -78,10 +77,7 @@ const HeroBanner = () => {
{ config.UI.navigation.layout === 'vertical' && (
<Box display={{ base: 'none', lg: 'flex' }} gap={ 2 }>
{ config.features.rewards.isEnabled && <RewardsButton variant="hero"/> }
{
(config.features.account.isEnabled && <UserProfileDesktop buttonVariant="hero"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonVariant="hero"/>)
}
<UserProfileDesktop buttonVariant="hero"/>
</Box>
) }
</Flex>
+2 -6
View File
@@ -14,8 +14,7 @@ import { BackToButton } from 'toolkit/components/buttons/BackToButton';
import { makePrettyLink } from 'toolkit/utils/url';
import RewardsButton from 'ui/rewards/RewardsButton';
import NetworkIcon from 'ui/snippets/networkLogo/NetworkIcon';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
import MarketplaceAppInfo from './MarketplaceAppInfo';
import Rating from './Rating/Rating';
@@ -76,10 +75,7 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading }: Props) => {
{ !isMobile && (
<Flex ml="auto" gap={ 2 }>
{ config.features.rewards.isEnabled && <RewardsButton size="sm"/> }
{
(config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonSize="sm"/>)
}
<UserProfileDesktop buttonSize="sm"/>
</Flex>
) }
</Flex>
@@ -13,7 +13,7 @@ import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AddressModal from 'ui/privateTags/AddressModal/AddressModal';
import TransactionModal from 'ui/privateTags/TransactionModal/TransactionModal';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import AuthGuard from 'ui/snippets/auth/guard/AuthGuard';
import ButtonItem from '../parts/ButtonItem';
@@ -11,7 +11,7 @@ import { MenuItem } from 'toolkit/chakra/menu';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import AuthGuard from 'ui/snippets/auth/guard/AuthGuard';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import ButtonItem from '../parts/ButtonItem';
+2 -7
View File
@@ -3,7 +3,6 @@ import React from 'react';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
@@ -11,16 +10,14 @@ import getErrorProp from 'lib/errors/getErrorProp';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import AdBannerContent from 'ui/shared/ad/AdBannerContent';
import AdBanner from '../ad/AdBanner';
import AppErrorIcon from './AppErrorIcon';
import AppErrorTitle from './AppErrorTitle';
import AppErrorBlockConsensus from './custom/AppErrorBlockConsensus';
import AppErrorTooManyRequests from './custom/AppErrorTooManyRequests';
import AppErrorTxNotFound from './custom/AppErrorTxNotFound';
const adBannerConfig = config.features.adsBanner;
interface Props {
className?: string;
error: Error | undefined;
@@ -91,8 +88,6 @@ const AppError = ({ error, className }: Props) => {
default: {
const { title, text } = ERROR_TEXTS[String(statusCode)] ?? ERROR_TEXTS[500];
const adBannerProvider = adBannerConfig.isEnabled ? adBannerConfig.provider : null;
return (
<>
<AppErrorIcon statusCode={ statusCode }/>
@@ -109,7 +104,7 @@ const AppError = ({ error, className }: Props) => {
Back to home
</Button>
</Link>
{ statusCode === 404 && adBannerProvider && <AdBannerContent mt={ 12 } provider={ adBannerProvider }/> }
{ statusCode === 404 && <AdBanner mt={ 12 }/> }
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import dynamic from 'next/dynamic';
import config from 'configs/app';
const ReownProvider = dynamic(() => import('./providers/ReownProvider'), { ssr: false });
const DynamicProvider = dynamic(() => import('./providers/DynamicProvider'), { ssr: false });
const WagmiProvider = dynamic(() => import('./providers/WagmiProvider'), { ssr: false });
const feature = config.features.blockchainInteraction;
const Web3Provider = (() => {
if (feature.isEnabled && feature.connectorType === 'reown') {
return ReownProvider;
}
if (feature.isEnabled && feature.connectorType === 'dynamic') {
return DynamicProvider;
}
return WagmiProvider;
})();
export default Web3Provider;
@@ -0,0 +1,115 @@
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum';
import type { DynamicContextProps, OnAuthSuccess } from '@dynamic-labs/sdk-react-core';
import { DynamicContextProvider, getAuthToken } from '@dynamic-labs/sdk-react-core';
import { DynamicWagmiConnector } from '@dynamic-labs/wagmi-connector';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import { getResourceKey } from 'lib/api/useApiQuery';
import getErrorMessage from 'lib/errors/getErrorMessage';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel/index';
import { chains } from 'lib/web3/chains';
import { toaster } from 'toolkit/chakra/toaster';
import { castToString } from 'toolkit/utils/guards';
import useLogout from 'ui/snippets/auth/useLogout';
import WagmiProvider from './WagmiProvider';
const feature = config.features.blockchainInteraction;
const walletConnectors = [
EthereumWalletConnectors,
];
const overrides: DynamicContextProps['settings']['overrides'] = {
evmNetworks: chains.map((chain) => {
const logoUrl = castToString(chain.custom?.logoUrl);
return {
chainId: chain.id,
name: chain.name,
rpcUrls: chain.rpcUrls.default.http.slice(),
nativeCurrency: chain.nativeCurrency,
blockExplorerUrls: chain.blockExplorers ? [ chain.blockExplorers.default.url ] : [],
networkId: chain.id,
iconUrls: logoUrl ? [ logoUrl ] : [],
isTestnet: chain.testnet,
};
}),
};
interface Props {
children: React.ReactNode;
}
const DynamicProvider = ({ children }: Props) => {
const apiFetch = useApiFetch();
const queryClient = useQueryClient();
const csrfQuery = useGetCsrfToken();
const onLogout = useLogout();
const onAuthSuccess: OnAuthSuccess = React.useCallback(async() => {
try {
const authToken = getAuthToken();
const response = await apiFetch<'general:auth_dynamic', UserInfo, unknown>('general:auth_dynamic', {
fetchParams: {
headers: {
Authorization: `Bearer ${ authToken }`,
},
},
});
if (!('name' in response)) {
throw Error('Something went wrong');
}
queryClient.setQueryData(getResourceKey('general:user_info'), () => response);
csrfQuery.refetch();
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'Success',
Source: 'Dynamic',
});
} catch (error) {
toaster.error({
title: 'Error',
description: getErrorMessage(error),
});
}
}, [ apiFetch, csrfQuery, queryClient ]);
const settings: DynamicContextProps['settings'] = React.useMemo(() => {
const environmentId = feature.isEnabled && feature.connectorType === 'dynamic' ? feature.dynamic.environmentId : undefined;
if (!environmentId) {
throw new Error('Dynamic environment ID is not set');
}
return {
walletConnectors,
environmentId,
useMetamaskSdk: false,
overrides,
events: {
onAuthSuccess,
onLogout,
},
};
}, [ onAuthSuccess, onLogout ]);
return (
<DynamicContextProvider
settings={ settings }
>
<WagmiProvider>
<DynamicWagmiConnector>
{ children }
</DynamicWagmiConnector>
</WagmiProvider>
</DynamicContextProvider>
);
};
export default React.memo(DynamicProvider);
@@ -1,8 +1,6 @@
/* eslint-disable consistent-default-export-name/default-export-match-filename */
import type { AppKitNetwork } from '@reown/appkit/networks';
import { createAppKit, useAppKitTheme } from '@reown/appkit/react';
import React from 'react';
import { WagmiProvider } from 'wagmi';
import config from 'configs/app';
import { chains } from 'lib/web3/chains';
@@ -12,11 +10,13 @@ import colors from 'toolkit/theme/foundations/colors';
import { BODY_TYPEFACE } from 'toolkit/theme/foundations/typography';
import zIndex from 'toolkit/theme/foundations/zIndex';
import WagmiProvider from './WagmiProvider';
const feature = config.features.blockchainInteraction;
const init = () => {
const initReown = () => {
try {
if (!feature.isEnabled || !wagmiConfig.adapter) {
if (!feature.isEnabled || !wagmiConfig.adapter || feature.connectorType === 'dynamic') {
return;
}
@@ -29,7 +29,7 @@ const init = () => {
url: config.app.baseUrl,
icons: [ config.UI.navigation.icon.default ].filter(Boolean),
},
projectId: feature.walletConnect.projectId,
projectId: feature.reown.projectId,
features: {
analytics: false,
email: false,
@@ -43,27 +43,19 @@ const init = () => {
'--w3m-border-radius-master': '2px',
'--w3m-z-index': zIndex?.modal2?.value,
},
featuredWalletIds: feature.walletConnect.featuredWalletIds,
featuredWalletIds: feature.reown.featuredWalletIds,
allowUnsupportedChain: true,
});
} catch (error) {}
};
init();
initReown();
interface Props {
children: React.ReactNode;
}
const DefaultProvider = ({ children }: Props) => {
return (
<WagmiProvider config={ wagmiConfig.config }>
{ children }
</WagmiProvider>
);
};
const Web3ModalProvider = ({ children }: Props) => {
const ReownProvider = ({ children }: Props) => {
const { colorMode } = useColorMode();
const { setThemeMode } = useAppKitTheme();
@@ -72,12 +64,10 @@ const Web3ModalProvider = ({ children }: Props) => {
}, [ colorMode, setThemeMode ]);
return (
<DefaultProvider>
<WagmiProvider>
{ children }
</DefaultProvider>
</WagmiProvider>
);
};
const Provider = feature.isEnabled ? Web3ModalProvider : DefaultProvider;
export default Provider;
export default React.memo(ReownProvider);
@@ -0,0 +1,18 @@
import React from 'react';
import { WagmiProvider as WagmiProviderCore } from 'wagmi';
import wagmiConfig from 'lib/web3/wagmiConfig';
interface Props {
children: React.ReactNode;
}
const WagmiProvider = ({ children }: Props) => {
return (
<WagmiProviderCore config={ wagmiConfig.config }>
{ children }
</WagmiProviderCore>
);
};
export default React.memo(WagmiProvider);
+18
View File
@@ -0,0 +1,18 @@
import dynamic from 'next/dynamic';
import config from 'configs/app';
const AuthGuardAuth0 = dynamic(() => import('./AuthGuardAuth0'), { ssr: false });
const AuthGuardDynamic = dynamic(() => import('./AuthGuardDynamic'), { ssr: false });
const feature = config.features.account;
const AuthGuard = (() => {
if (feature.isEnabled && feature.authProvider === 'dynamic') {
return AuthGuardDynamic;
}
return AuthGuardAuth0;
})();
export default AuthGuard;
@@ -2,8 +2,8 @@ import React from 'react';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AuthModal from './AuthModal';
import useProfileQuery from './useProfileQuery';
import AuthModal from '../AuthModal';
import useProfileQuery from '../useProfileQuery';
interface InjectedProps {
onClick: () => void;
@@ -15,7 +15,7 @@ interface Props {
ensureEmail?: boolean;
}
const AuthGuard = ({ children, onAuthSuccess, ensureEmail }: Props) => {
const AuthGuardAuth0 = ({ children, onAuthSuccess, ensureEmail }: Props) => {
const authModal = useDisclosure();
const profileQuery = useProfileQuery();
@@ -61,4 +61,4 @@ const AuthGuard = ({ children, onAuthSuccess, ensureEmail }: Props) => {
);
};
export default React.memo(AuthGuard);
export default React.memo(AuthGuardAuth0);
@@ -0,0 +1,64 @@
import { useDynamicContext, useDynamicEvents } from '@dynamic-labs/sdk-react-core';
import React from 'react';
import useLinkEmailDynamic from '../linkEmail/useLinkEmailDynamic';
import useProfileQuery from '../useProfileQuery';
interface InjectedProps {
onClick: () => void;
}
interface Props {
children: (props: InjectedProps) => React.ReactNode;
onAuthSuccess: () => void;
ensureEmail?: boolean;
}
const AuthGuardDynamic = ({ children, onAuthSuccess, ensureEmail }: Props) => {
const isAuthStartedRef = React.useRef(false);
const profileQuery = useProfileQuery();
const { setShowAuthFlow } = useDynamicContext();
const { user } = useDynamicContext();
const linkEmailToProfile = useLinkEmailDynamic();
const handleClick = React.useCallback(() => {
if (profileQuery.data) {
if (ensureEmail && !profileQuery.data?.email) {
linkEmailToProfile();
} else {
onAuthSuccess();
}
} else {
setShowAuthFlow(true);
isAuthStartedRef.current = true;
}
}, [ profileQuery.data, ensureEmail, linkEmailToProfile, onAuthSuccess, setShowAuthFlow ]);
const onAuthFlowEnd = React.useCallback(() => {
if (!user || !isAuthStartedRef.current) {
return;
}
isAuthStartedRef.current = false;
if (ensureEmail && !user?.email) {
linkEmailToProfile();
} else {
onAuthSuccess();
}
}, [ ensureEmail, linkEmailToProfile, onAuthSuccess, user ]);
// This event is triggered when user is logged in with wallet
useDynamicEvents('authFlowClose', async() => {
onAuthFlowEnd();
});
// This event is triggered when user is logged in with email
useDynamicEvents('emailVerificationResult', () => {
onAuthFlowEnd();
});
return children({ onClick: handleClick });
};
export default React.memo(AuthGuardDynamic);
@@ -0,0 +1,9 @@
import config from 'configs/app';
const feature = config.features.account;
const useLinkEmail = (feature.isEnabled && feature.authProvider === 'dynamic') ?
(await import('./useLinkEmailDynamic')).default :
(await import('./useLinkEmailFallback')).default;
export default useLinkEmail;
@@ -0,0 +1,31 @@
import { useUserUpdateRequest } from '@dynamic-labs/sdk-react-core';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as mixpanel from 'lib/mixpanel/index';
export default function useLinkEmailDynamic(): () => void {
const { updateUserWithModal } = useUserUpdateRequest();
const queryClient = useQueryClient();
return React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Status: 'Started',
Type: 'Email',
Source: 'Profile dropdown',
});
updateUserWithModal([ 'email' ]).then((fields) => {
queryClient.setQueryData(getResourceKey('general:user_info'), (prevData: UserInfo | undefined) => {
return { ...prevData, email: fields.email };
});
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Status: 'Finished',
Type: 'Email',
Source: 'Profile dropdown',
});
});
}, [ queryClient, updateUserWithModal ]);
}
@@ -0,0 +1,5 @@
import React from 'react';
export default function useLinkEmailFallback() {
return React.useCallback(() => {}, []);
}
+2 -2
View File
@@ -11,7 +11,7 @@ import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import type * as mixpanel from 'lib/mixpanel';
import useWeb3Wallet from 'lib/web3/useWallet';
import useWalletReown from 'lib/web3/wallet/useWalletReown';
import { toaster } from 'toolkit/chakra/toaster';
import { YEAR } from 'toolkit/utils/consts';
@@ -49,7 +49,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log
const isConnectingWalletRef = React.useRef(false);
const apiFetch = useApiFetch();
const web3Wallet = useWeb3Wallet({ source });
const web3Wallet = useWalletReown({ source });
const { signMessageAsync } = useSignMessage();
const { switchChainAsync } = useSwitchChain();
+2 -6
View File
@@ -4,8 +4,7 @@ import React from 'react';
import config from 'configs/app';
import RewardsButton from 'ui/rewards/RewardsButton';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
type Props = {
renderSearchBar?: () => React.ReactNode;
@@ -30,10 +29,7 @@ const HeaderDesktop = ({ renderSearchBar }: Props) => {
{ config.UI.navigation.layout === 'vertical' && (
<Box display="flex" gap={ 2 } flexShrink={ 0 }>
{ config.features.rewards.isEnabled && <RewardsButton/> }
{
(config.features.account.isEnabled && <UserProfileDesktop/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop/>)
}
<UserProfileDesktop buttonVariant="header"/>
</Box>
) }
</HStack>
+22 -4
View File
@@ -1,11 +1,12 @@
import { Box, Flex } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
import config from 'configs/app';
import { useIsSticky } from 'toolkit/hooks/useIsSticky';
import RewardsButton from 'ui/rewards/RewardsButton';
import NetworkIcon from 'ui/snippets/networkLogo/NetworkIcon';
import UserProfileMobile from 'ui/snippets/user/profile/UserProfileMobile';
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileMobile';
import UserWalletMobile from 'ui/snippets/user/wallet/UserWalletMobile';
import RollupStageBadge from '../navigation/RollupStageBadge';
@@ -13,6 +14,8 @@ import TestnetBadge from '../navigation/TestnetBadge';
import SearchBarMobile from '../searchBar/SearchBarMobile';
import Burger from './Burger';
const UserProfileDynamic = dynamic(() => import('ui/snippets/user/profile/dynamic/UserProfile'), { ssr: false });
type Props = {
hideSearchButton?: boolean;
onGoToSearchResults?: (searchTerm: string) => void;
@@ -22,6 +25,23 @@ const HeaderMobile = ({ hideSearchButton, onGoToSearchResults }: Props) => {
const ref = React.useRef<HTMLDivElement>(null);
const isSticky = useIsSticky(ref, 5);
const userProfile = (() => {
const accountFeature = config.features.account;
if (accountFeature.isEnabled) {
switch (accountFeature.authProvider) {
case 'auth0':
return <UserProfileAuth0/>;
case 'dynamic':
return <UserProfileDynamic/>;
default:
return null;
}
}
if (config.features.blockchainInteraction.isEnabled) {
return <UserWalletMobile/>;
}
})();
return (
<Box
ref={ ref }
@@ -54,9 +74,7 @@ const HeaderMobile = ({ hideSearchButton, onGoToSearchResults }: Props) => {
<Flex columnGap={ 2 }>
{ !hideSearchButton && <SearchBarMobile onGoToSearchResults={ onGoToSearchResults }/> }
{ config.features.rewards.isEnabled && <RewardsButton/> }
{ (config.features.account.isEnabled && <UserProfileMobile/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletMobile/>)
}
{ userProfile }
</Flex>
</Flex>
</Box>
@@ -1,13 +1,13 @@
import { Box, chakra, Flex } from '@chakra-ui/react';
import { Box, chakra, Flex, Separator } from '@chakra-ui/react';
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 useIsAuth from 'ui/snippets/auth/useIsAuth';
import NetworkLogo from 'ui/snippets/networkLogo/NetworkLogo';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
import NavigationPromoBanner from '../promoBanner/NavigationPromoBanner';
import RollupStageBadge from '../RollupStageBadge';
@@ -15,8 +15,20 @@ import TestnetBadge from '../TestnetBadge';
import NavLink from './NavLink';
import NavLinkGroup from './NavLinkGroup';
const accountFeature = config.features.account;
const NavigationDesktop = () => {
const { mainNavItems } = useNavItems();
const { mainNavItems, accountNavItems } = useNavItems();
const isAuth = useIsAuth();
const accountNavGroup = React.useMemo(() => {
if (accountFeature.isEnabled && accountFeature.authProvider === 'dynamic' && isAuth) {
return {
text: 'Account',
subItems: accountNavItems,
};
}
}, [ accountNavItems, isAuth ]);
return (
<Box borderColor="border.divider" borderBottomWidth="1px">
@@ -31,8 +43,8 @@ const NavigationDesktop = () => {
<NetworkLogo/>
<TestnetBadge ml={ 3 }/>
<RollupStageBadge ml={ 3 }/>
<chakra.nav ml="auto" mr={ config.features.account.isEnabled || config.features.blockchainInteraction.isEnabled ? 8 : 0 }>
<Flex as="ul" columnGap={ 3 }>
<chakra.nav ml="auto">
<Flex as="ul" columnGap={ 2 } alignItems="center">
{ mainNavItems.map((item) => {
if (isGroupItem(item)) {
return <NavLinkGroup key={ item.text } item={ item }/>;
@@ -40,15 +52,18 @@ const NavigationDesktop = () => {
return <NavLink key={ item.text } item={ item } noIcon py={ 1.5 } w="fit-content"/>;
}
}) }
{ accountNavGroup && (
<>
<Separator orientation="vertical" mx={ 0 } h={ 4 }/>
<NavLinkGroup key={ accountNavGroup.text } item={ accountNavGroup }/>
</>
) }
</Flex>
</chakra.nav>
<Flex gap={ 2 }>
<Flex gap={ 2 } ml={ 8 } _empty={{ display: 'none' }}>
<NavigationPromoBanner/>
{ config.features.rewards.isEnabled && <RewardsButton size="sm"/> }
{
(config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonSize="sm"/>)
}
<UserProfileDesktop buttonSize="sm"/>
</Flex>
</Flex>
</Box>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+33
View File
@@ -0,0 +1,33 @@
import { type ButtonProps } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import config from 'configs/app';
import UserProfileAuth0 from 'ui/snippets/user/profile/auth0/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
const UserProfileDynamic = dynamic(() => import('ui/snippets/user/profile/dynamic/UserProfile'), { ssr: false });
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const accountFeature = config.features.account;
if (accountFeature.isEnabled) {
switch (accountFeature.authProvider) {
case 'auth0':
return <UserProfileAuth0 buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
case 'dynamic':
return <UserProfileDynamic buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
default:
return null;
}
}
if (config.features.blockchainInteraction.isEnabled) {
return <UserWalletDesktop buttonSize={ buttonSize } buttonVariant={ buttonVariant }/>;
}
return null;
};
export default UserProfileDesktop;
@@ -13,8 +13,8 @@ import { Button } from 'toolkit/chakra/button';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
import UserIdenticon from '../UserIdenticon';
import { getUserHandle } from './utils';
import UserIdenticon from '../../UserIdenticon';
import { getUserHandle } from '../common/utils';
interface Props {
profileQuery: UseQueryResult<UserInfo, unknown>;
@@ -50,7 +50,7 @@ const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending, ..
return (
<HStack gap={ 2 }>
<UserIdenticon address={ web3AccountWithDomain.address } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<Box display={{ base: 'none', md: 'block' }}>
<Box display={{ base: 'none', md: 'block' }} maxW="200px" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
{ web3AccountWithDomain.domain || shortenString(web3AccountWithDomain.address) }
</Box>
</HStack>
@@ -15,9 +15,9 @@ import { Hint } from 'toolkit/components/Hint/Hint';
import { TruncatedText } from 'toolkit/components/truncation/TruncatedText';
import useLogout from 'ui/snippets/auth/useLogout';
import UserWalletAutoConnectAlert from '../UserWalletAutoConnectAlert';
import UserWalletAutoConnectAlert from '../../UserWalletAutoConnectAlert';
import UserProfileContentWallet from '../common/UserProfileContentWallet';
import UserProfileContentNavLink from './UserProfileContentNavLink';
import UserProfileContentWallet from './UserProfileContentWallet';
const navLinks: Array<NavLink> = [
{
@@ -0,0 +1,68 @@
import { type ButtonProps } from '@chakra-ui/react';
import { DynamicConnectButton, DynamicUserProfile, useDynamicContext, useIsLoggedIn } from '@dynamic-labs/sdk-react-core';
import React from 'react';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useWalletDynamic from 'lib/web3/wallet/useWalletDynamic';
import UserProfileButton from './UserProfileButton';
import UserProfilePopover from './UserProfilePopover';
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const UserProfile = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const isLoggedIn = useIsLoggedIn();
const { user, authMode } = useDynamicContext();
const wallet = useWalletDynamic({ source: isLoggedIn ? 'Profile dropdown' : 'Login' });
const { isAutoConnectDisabled } = useMarketplaceContext();
const handleOpenModal = React.useCallback(() => {
wallet.openModal();
}, [ wallet ]);
if (isLoggedIn && authMode === 'connect-and-sign') {
return (
<>
<UserProfileButton
email={ user?.email }
size={ buttonSize }
variant={ buttonVariant }
onClick={ handleOpenModal }
selected
/>
<DynamicUserProfile variant="modal"/>
</>
);
}
if (wallet.isConnected) {
return (
<>
<UserProfilePopover isAutoConnectDisabled={ isAutoConnectDisabled }>
<UserProfileButton
email={ user?.email }
size={ buttonSize }
variant={ buttonVariant }
selected
/>
</UserProfilePopover>
<DynamicUserProfile variant="modal"/>
</>
);
}
return (
<DynamicConnectButton>
<UserProfileButton
as="div"
size={ buttonSize }
variant={ buttonVariant }
/>
</DynamicConnectButton>
);
};
export default React.memo(UserProfile);
@@ -0,0 +1,57 @@
import { Box, HStack } from '@chakra-ui/react';
import React from 'react';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import shortenString from 'lib/shortenString';
import useAccountWithDomain from 'lib/web3/useAccountWithDomain';
import { Button, type ButtonProps } from 'toolkit/chakra/button';
import IconSvg from 'ui/shared/IconSvg';
import UserIdenticon from '../../UserIdenticon';
import { getUserHandle } from '../common/utils';
interface Props extends ButtonProps {
email?: string;
}
const UserProfileButton = ({ selected, email, ...rest }: Props) => {
const { isAutoConnectDisabled } = useMarketplaceContext();
const accountWithDomain = useAccountWithDomain(true);
const isLoading = accountWithDomain.isLoading;
const content = (() => {
if (selected && !isLoading) {
return accountWithDomain.address ? (
<HStack gap={ 2 }>
<UserIdenticon address={ accountWithDomain.address } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<Box display={{ base: 'none', md: 'block' }} maxW="200px" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
{ accountWithDomain.domain || shortenString(accountWithDomain.address) }
</Box>
</HStack>
) : (
<HStack gap={ 2 }>
<IconSvg name="profile" boxSize={ 5 }/>
<Box display={{ base: 'none', md: 'block' }}>{ email ? getUserHandle(email) : 'My profile' }</Box>
</HStack>
);
}
return 'Log in';
})();
return (
<Button
px={{ base: 2.5, lg: 3 }}
selected={ selected }
highlighted={ isAutoConnectDisabled }
fontWeight={ selected ? 700 : undefined }
loading={ isLoading }
{ ...rest }
>
{ content }
</Button>
);
};
export default React.memo(UserProfileButton);
@@ -0,0 +1,43 @@
import { DynamicConnectButton, useAuthenticateConnectedUser } from '@dynamic-labs/sdk-react-core';
import React from 'react';
import { Button } from 'toolkit/chakra/button';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import UserWalletAutoConnectAlert from '../../UserWalletAutoConnectAlert';
import UserProfileContentWallet from '../common/UserProfileContentWallet';
interface Props {
children: React.ReactNode;
isAutoConnectDisabled: boolean;
}
const UserProfilePopover = ({ children, isAutoConnectDisabled }: Props) => {
const { authenticateUser } = useAuthenticateConnectedUser();
return (
<PopoverRoot positioning={{ placement: 'bottom-end' }}>
<PopoverTrigger>
{ children }
</PopoverTrigger>
<PopoverContent w="280px">
<PopoverBody
css={{
'& .dynamic-login-button': {
width: '100%',
},
}}
>
{ isAutoConnectDisabled && <UserWalletAutoConnectAlert/> }
<UserProfileContentWallet/>
<DynamicConnectButton buttonClassName="dynamic-login-button">
<Button as="div" mt={ 3 } onClick={ authenticateUser } size="sm" w="100%" >Log in</Button>
</DynamicConnectButton>
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(UserProfilePopover);
@@ -1,12 +1,25 @@
import React from 'react';
import config from 'configs/app';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useLinkEmail from 'ui/snippets/auth/linkEmail/useLinkEmail';
const feature = config.features.account;
const VerifiedAddressesEmailAlert = () => {
const authModal = useDisclosure();
const linkEmail = useLinkEmail();
const handleButtonClick = React.useCallback(() => {
if (feature.isEnabled && feature.authProvider === 'dynamic') {
linkEmail();
} else {
authModal.onOpen();
}
}, [ authModal, linkEmail ]);
return (
<>
@@ -19,7 +32,7 @@ const VerifiedAddressesEmailAlert = () => {
}}
>
You need a valid email address to verify contracts. Please add your email to your account.
<Button variant="outline" size="sm" onClick={ authModal.onOpen }>Add email</Button>
<Button variant="outline" size="sm" onClick={ handleButtonClick }>Add email</Button>
</Alert>
{ authModal.open && <AuthModal initialScreen={{ type: 'email', isAuth: true }} onClose={ authModal.onClose }/> }
</>
+14 -1
View File
@@ -1,12 +1,25 @@
import React from 'react';
import config from 'configs/app';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useLinkEmail from 'ui/snippets/auth/linkEmail/useLinkEmail';
const feature = config.features.account;
const WatchlistEmailAlert = () => {
const authModal = useDisclosure();
const linkEmail = useLinkEmail();
const handleButtonClick = React.useCallback(() => {
if (feature.isEnabled && feature.authProvider === 'dynamic') {
linkEmail();
} else {
authModal.onOpen();
}
}, [ authModal, linkEmail ]);
return (
<>
@@ -17,7 +30,7 @@ const WatchlistEmailAlert = () => {
mb={ 6 }
>
To receive notifications you need to add an email to your profile.
<Button variant="outline" size="sm" onClick={ authModal.onOpen }>Add email</Button>
<Button variant="outline" size="sm" onClick={ handleButtonClick }>Add email</Button>
</Alert>
{ authModal.open && <AuthModal initialScreen={{ type: 'email', isAuth: true }} onClose={ authModal.onClose }/> }
</>
+1627 -61
View File
File diff suppressed because it is too large Load Diff