diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 981b71eb82..4baecdb1e7 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -58,6 +58,7 @@ import { HorizonOperation, UserNotification, CollectibleContract, + DiscoverData, } from "./types"; import { AccountBalancesInterface, @@ -621,7 +622,7 @@ export const getTokenPrices = async (tokens: string[]) => { return parsedResponse.data; }; -export const getDiscoverData = async () => { +export const getDiscoverData = async (): Promise => { const url = new URL(`${INDEXER_V2_URL}/protocols`); const response = await fetch(url.href); const parsedResponse = (await response.json()) as { @@ -633,6 +634,8 @@ export const getDiscoverData = async () => { website_url: string; tags: string[]; is_blacklisted: boolean; + background_url?: string; + is_trending: boolean; }[]; }; }; @@ -652,6 +655,8 @@ export const getDiscoverData = async () => { websiteUrl: entry.website_url, tags: entry.tags, isBlacklisted: entry.is_blacklisted, + backgroundUrl: entry.background_url, + isTrending: entry.is_trending, })); }; diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index ca3fbb376c..3ec7ad1504 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -396,14 +396,18 @@ export interface ApiTokenPrices { [key: string]: ApiTokenPrice | null; } -export type DiscoverData = { +export interface ProtocolEntry { description: string; iconUrl: string; name: string; websiteUrl: string; tags: string[]; isBlacklisted: boolean; -}[]; + backgroundUrl?: string; + isTrending: boolean; +} + +export type DiscoverData = ProtocolEntry[]; export interface LedgerKeyAccount { account_id: string; diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index dc23fec8ad..38d1285761 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -60,7 +60,6 @@ import { ManageNetwork } from "popup/views/ManageNetwork"; import { LeaveFeedback } from "popup/views/LeaveFeedback"; import { AccountMigration } from "popup/views/AccountMigration"; import { AddFunds } from "popup/views/AddFunds"; -import { Discover } from "popup/views/Discover"; import { Wallets } from "popup/views/Wallets"; import { DEV_SERVER } from "@shared/constants/services"; @@ -275,7 +274,6 @@ export const Router = () => ( element={} > } /> - } /> } /> {DEV_SERVER && ( diff --git a/extension/src/popup/basics/layout/View/index.tsx b/extension/src/popup/basics/layout/View/index.tsx index c21815a166..69fc490ebe 100644 --- a/extension/src/popup/basics/layout/View/index.tsx +++ b/extension/src/popup/basics/layout/View/index.tsx @@ -90,7 +90,8 @@ const ViewAppHeader: React.FC = ({
Promise; isCollectibleHidden: (collectionAddress: string, tokenId: string) => boolean; + onDiscoverClick: () => void; } export const AccountHeader = ({ @@ -55,6 +56,7 @@ export const AccountHeader = ({ roundedTotalBalanceUsd, refreshHiddenCollectibles, isCollectibleHidden, + onDiscoverClick, }: AccountHeaderProps) => { const { t } = useTranslation(); const networkDetails = useSelector(settingsNetworkDetailsSelector); @@ -309,7 +311,7 @@ export const AccountHeader = ({ rightContent={
navigateTo(ROUTES.discover, navigate)} + onClick={onDiscoverClick} > {t("Discover")}
diff --git a/extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx b/extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx index a7bdcc1a1c..1b4638b8b4 100644 --- a/extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx +++ b/extension/src/popup/components/hardwareConnect/HardwareSign/index.tsx @@ -148,7 +148,7 @@ export const HardwareSign = ({ onCancel(); } }} - customBackIcon={} + customBackIcon={} title={t("Connect {walletType}", { walletType })} />
@@ -199,7 +199,7 @@ export const HardwareSign = ({
} + customBackIcon={} title={t("Connect {walletType}", { walletType })} />
diff --git a/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx b/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx index 63b5ff020a..184e7c6fc6 100644 --- a/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx +++ b/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx @@ -93,7 +93,7 @@ export const ChooseAsset = ({ : undefined + !domainState.data?.isManagingAssets ? : undefined } customBackAction={goBack} rightButton={ diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 675e8b3969..d888e1200a 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -88,7 +88,12 @@ export const METRIC_NAMES = { viewEditNetwork: "loaded screen: edit network", viewNetworkSettings: "loaded screen: network settings", viewAddFunds: "loaded screen: add fund", - discover: "loaded screen: discover", + + viewDiscover: "loaded screen: discover", + discoverProtocolOpened: "discover: protocol opened", + discoverProtocolDetailsViewed: "discover: protocol details viewed", + discoverProtocolOpenedFromDetails: "discover: protocol opened from details", + discoverWelcomeModalViewed: "discover: welcome modal viewed", manageAssetAddAsset: "manage asset: add asset", manageAssetAddToken: "manage asset: add token", diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 69a65d11af..86fb2133d6 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -53,6 +53,5 @@ export enum ROUTES { accountMigrationConfirmMigration = "/account-migration/confirm-migration", accountMigrationMigrationComplete = "/account-migration/migration-complete", - discover = "/discover", wallets = "/wallets", } diff --git a/extension/src/popup/helpers/recentProtocols.ts b/extension/src/popup/helpers/recentProtocols.ts new file mode 100644 index 0000000000..2cb70b3d64 --- /dev/null +++ b/extension/src/popup/helpers/recentProtocols.ts @@ -0,0 +1,28 @@ +import browser from "webextension-polyfill"; + +const STORAGE_KEY = "recentProtocols"; +const MAX_RECENT = 5; + +export interface RecentProtocolEntry { + websiteUrl: string; + lastAccessed: number; +} + +export const getRecentProtocols = async (): Promise => { + const result = await browser.storage.local.get(STORAGE_KEY); + return (result[STORAGE_KEY] as RecentProtocolEntry[]) || []; +}; + +export const addRecentProtocol = async (websiteUrl: string): Promise => { + const existing = await getRecentProtocols(); + const filtered = existing.filter((entry) => entry.websiteUrl !== websiteUrl); + const updated = [{ websiteUrl, lastAccessed: Date.now() }, ...filtered].slice( + 0, + MAX_RECENT, + ); + await browser.storage.local.set({ [STORAGE_KEY]: updated }); +}; + +export const clearRecentProtocols = async (): Promise => { + await browser.storage.local.remove(STORAGE_KEY); +}; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 9dcf9861ac..24f6de105d 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -97,6 +97,7 @@ "Clear": "Clear", "Clear Flags": "Clear Flags", "Clear Override": "Clear Override", + "Clear recents": "Clear recents", "Close": "Close", "Coinbase Logo": "Coinbase Logo", "Collectible": "Collectible", @@ -146,6 +147,7 @@ "Create new wallet": "Create new wallet", "Current Network": "Current Network", "Custom": "Custom", + "dApps": "dApps", "Dapps": "Dapps", "data entries": "data entries", "Debug": "Debug", @@ -165,6 +167,7 @@ "Discover": "Discover", "Do not proceed": "Do not proceed", "Do this later": "Do this later", + "Domain": "Domain", "Don’t share this phrase with anyone": "Don’t share this phrase with anyone", "Done": "Done", "Download on iOS or Android today": "Download on iOS or Android today", @@ -311,6 +314,7 @@ "Leave Feedback": "Leave Feedback", "Leave feedback about Blockaid warnings and messages": "Leave feedback about Blockaid warnings and messages", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.", + "Let's go": "Let's go", "Limit": "Limit", "Links": "Links", "Liquidity Pool ID": "Liquidity Pool ID", @@ -397,6 +401,7 @@ "Order is incorrect, try again": "Order is incorrect, try again", "Overridden response": "Overridden response", "Override Blockaid security responses for testing different security states (DEV only)": "Override Blockaid security responses for testing different security states (DEV only)", + "Overview": "Overview", "Parameters": "Parameters", "Passphrase": "Passphrase", "Password": "Password", @@ -429,6 +434,7 @@ "Ready to migrate": "Ready to migrate", "Receive": "Receive", "Received": "Received", + "Recent": "Recent", "Recents": "Recents", "Recovery Phrase": "Recovery Phrase", "Refresh": "Refresh", @@ -534,6 +540,7 @@ "Swapped!": "Swapped!", "Swapping": "Swapping", "Switch to this network": "Switch to this network", + "Tags": "Tags", "Terms of Service": "Terms of Service", "Terms of Use": "Terms of Use", "The authorization entry is for": "The authorization entry is for", @@ -550,7 +557,9 @@ "The transaction you’re trying to sign is on": "The transaction you’re trying to sign is on", "The website <1>{url} does not use an SSL certificate.": "The website <1>{url} does not use an SSL certificate.", "There are no sites to display at this moment.": "There are no sites to display at this moment.", + "There was an error fetching protocols. Please refresh and try again.": "There was an error fetching protocols. Please refresh and try again.", "These assets are not on any of your lists. Proceed with caution before adding.": "These assets are not on any of your lists. Proceed with caution before adding.", + "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "These words are your wallet’s keys—store them securely to keep your funds safe.", "This asset does not appear safe for the following reasons.": "This asset does not appear safe for the following reasons.", "This asset has a balance": "This asset has a balance", @@ -609,11 +618,13 @@ "Transaction Timeout": "Transaction Timeout", "Transfer from another account": "Transfer from another account", "Transfer from Coinbase, buy with debit and credit cards or bank transfer *": "Transfer from Coinbase, buy with debit and credit cards or bank transfer *", + "Trending": "Trending", "trustlines": "trustlines", "Trustor": "Trustor", "Type": "Type", "Type your memo": "Type your memo", "Unable to connect to": "Unable to connect to", + "Unable to fetch protocols": "Unable to fetch protocols", "Unable to find your asset.": "Unable to find your asset.", "Unable to load network details": "Unable to load network details", "Unable to migrate": "Unable to migrate", @@ -667,6 +678,7 @@ "We were unable to scan this transaction for security threats": "We were unable to scan this transaction for security threats", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", "Welcome back": "Welcome back", + "Welcome to Discover!": "Welcome to Discover!", "What is this transaction for? (optional)": "What is this transaction for? (optional)", "What’s new": "What’s new", "Wrong simulation result": "Wrong simulation result", @@ -703,6 +715,7 @@ "Your account data could not be fetched at this time.": "Your account data could not be fetched at this time.", "Your assets": "Your assets", "Your available XLM balance is not enough to pay for the transaction fee.": "Your available XLM balance is not enough to pay for the transaction fee.", + "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.", "Your recovery phrase": "Your recovery phrase", "Your Recovery Phrase": "Your Recovery Phrase", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 6d79ea39a2..0a0a662072 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -79,7 +79,7 @@ "Balance ID": "ID do Saldo", "Before we start with migration, please read": "Antes de começarmos com a migration, por favor leia", "Blockaid": "Blockaid", - "Blockaid Response Override": "Blockaid Response Override", + "Blockaid Response Override": "Substituição de Resposta do Blockaid", "Blockaid unfunded destination": "Esta é uma nova conta e precisa de 1 XLM para começar. Qualquer transação para enviar não-XLM para uma conta não financiada falhará.", "Blockaid unfunded destination native": "Esta é uma nova conta e precisa de pelo menos 1 XLM para ser criada. Enviar menos de 1 XLM para criá-la falhará.", "Bump To": "Bump Para", @@ -96,7 +96,8 @@ "Choose your method": "Escolha seu método", "Clear": "Limpar", "Clear Flags": "Limpar Flags", - "Clear Override": "Clear Override", + "Clear Override": "Limpar Substituição", + "Clear recents": "Limpar recentes", "Close": "Fechar", "Coinbase Logo": "Logo Coinbase", "Collectible": "Colecionável", @@ -146,10 +147,11 @@ "Create new wallet": "Criar nova carteira", "Current Network": "Rede Atual", "Custom": "Personalizado", + "dApps": "dApps", "Dapps": "Dapps", "data entries": "entradas de dados", "Debug": "Depuração", - "Debug menu is only available in development mode.": "Debug menu is only available in development mode.", + "Debug menu is only available in development mode.": "O menu de depuração está disponível apenas no modo de desenvolvimento.", "Default": "Padrão", "Description": "Descrição", "Destination": "Destino", @@ -165,6 +167,7 @@ "Discover": "Descobrir", "Do not proceed": "Não prosseguir", "Do this later": "Fazer isso depois", + "Domain": "Domínio", "Don’t share this phrase with anyone": "Não compartilhe esta frase com ninguém", "Done": "Concluído", "Download on iOS or Android today": "Baixe no iOS ou Android hoje", @@ -311,6 +314,7 @@ "Leave Feedback": "Deixar Feedback", "Leave feedback about Blockaid warnings and messages": "Deixar feedback sobre avisos e mensagens do Blockaid", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "O Ledger não exibirá os detalhes da transação na tela do dispositivo antes de assinar, então certifique-se de interagir apenas com aplicativos que você conhece e confia.", + "Let's go": "Vamos lá", "Limit": "Limite", "Links": "Links", "Liquidity Pool ID": "ID do Pool de Liquidez", @@ -323,7 +327,7 @@ "Make sure you have your 12 word backup phrase": "Certifique-se de ter sua backup phrase de 12 palavras", "Make sure you have your current 12 words backup phrase before continuing.": "Certifique-se de ter sua frase de backup atual de 12 palavras antes de continuar.", "Make sure your Ledger wallet is connected to your computer and the Stellar app is open on the Ledger wallet.": "Certifique-se de que sua carteira Ledger está conectada ao seu computador e o aplicativo Stellar está aberto na carteira Ledger.", - "Malicious": "Malicious", + "Malicious": "Malicioso", "Manage assets": "Gerenciar ativos", "Manage List": "Gerenciar Lista", "Manage tokens": "Gerenciar tokens", @@ -335,8 +339,6 @@ "Medium": "Média", "Medium Threshold": "Limite Médio", "Memo": "Memo", - "Memo is disabled for this transaction": "Memo está desabilitado para esta transação", - "Memo is not supported for this operation": "Memo não é suportado para esta operação", "Memo is required": "Memo é obrigatório", "Memo is too long. Maximum {{max}} bytes allowed": "O memo é muito longo. Máximo de {{max}} bytes permitidos", "Memo required": "Memo obrigatório", @@ -355,7 +357,7 @@ "must be at least": "deve ser pelo menos", "must be below": "deve estar abaixo de", "Muxed address not supported": "Endereço muxed não suportado", - "N/A": "N/A", + "N/A": "N/D", "Name": "Nome", "Network": "Rede", "Network fees": "Taxas de rede", @@ -397,8 +399,9 @@ "Operations": "Operações", "Optional": "Opcional", "Order is incorrect, try again": "A ordem está incorreta, tente novamente", - "Overridden response": "Overridden response", - "Override Blockaid security responses for testing different security states (DEV only)": "Override Blockaid security responses for testing different security states (DEV only)", + "Overridden response": "Resposta substituída", + "Override Blockaid security responses for testing different security states (DEV only)": "Substituir respostas de segurança do Blockaid para testar diferentes estados de segurança (apenas DEV)", + "Overview": "Visão geral", "Parameters": "Parâmetros", "Passphrase": "Frase secreta", "Password": "Senha", @@ -426,11 +429,12 @@ "Preferences": "Preferências", "Price": "Preço", "Privacy Policy": "Política de Privacidade", - "Proceed with caution": "Proceed with caution", + "Proceed with caution": "Prossiga com cautela", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", "Receive": "Receber", "Received": "Recebido", + "Recent": "Recentes", "Recents": "Recentes", "Recovery Phrase": "Frase de Recuperação", "Refresh": "Atualizar", @@ -450,7 +454,7 @@ "Review swap": "Revisar troca", "Review transaction on device": "Revisar transação no dispositivo", "Running integration tests ...": "Executando testes de integração ...", - "Safe": "Safe", + "Safe": "Seguro", "Salt": "Salt", "Save": "Salvar", "Save failed!": "Falha ao salvar!", @@ -504,7 +508,7 @@ "Some destination accounts on the Stellar network require a memo to identify your payment.": "Algumas contas de destino na rede Stellar exigem um memo para identificar seu pagamento.", "Some features may be disabled at this time": "Alguns recursos podem estar desabilitados neste momento.", "Some of your assets may not appear, but they are still safe on the network!": "Alguns de seus ativos podem não aparecer, mas ainda estão seguros na rede!", - "Soroban is temporarily experiencing issues": "Soroban is temporarily experiencing issues", + "Soroban is temporarily experiencing issues": "O Soroban está temporariamente com problemas", "Soroban RPC is temporarily experiencing issues": "O Soroban RPC está temporariamente com problemas", "SOROBAN RPC URL": "URL RPC SOROBAN", "Source": "Origem", @@ -522,7 +526,7 @@ "Submitting": "Enviando", "Success": "Sucesso", "Success!": "Sucesso!", - "Suspicious": "Suspicious", + "Suspicious": "Suspeito", "Suspicious Request": "Solicitação Suspeita", "Swap": "Trocar", "Swap destination": "Destino da troca", @@ -536,6 +540,7 @@ "Swapped!": "Trocado!", "Swapping": "Trocando", "Switch to this network": "Alternar para esta rede", + "Tags": "Categorias", "Terms of Service": "Termos de Serviço", "Terms of Use": "Termos de Uso", "The authorization entry is for": "A entrada de autorização é para", @@ -552,9 +557,11 @@ "The transaction you’re trying to sign is on": "A transação que você está tentando assinar está em", "The website <1>{url} does not use an SSL certificate.": "O site <1>{url} não usa um certificado SSL.", "There are no sites to display at this moment.": "Não há sites para exibir no momento.", + "There was an error fetching protocols. Please refresh and try again.": "Ocorreu um erro ao buscar os protocolos. Atualize e tente novamente.", "These assets are not on any of your lists. Proceed with caution before adding.": "Esses ativos não estão em nenhuma de suas listas. Prossiga com cautela antes de adicionar.", + "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "Estes serviços são operados por terceiros independentes, não pela Freighter ou SDF. A inclusão aqui não constitui um endosso. DeFi envolve riscos, incluindo perda de fundos. Use por sua conta e risco.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "Essas palavras são as chaves da sua carteira—guarde-as com segurança para manter seus fundos seguros.", - "This asset does not appear safe for the following reasons.": "This asset does not appear safe for the following reasons.", + "This asset does not appear safe for the following reasons.": "Este ativo não parece seguro pelos seguintes motivos.", "This asset has a balance": "Este ativo tem um saldo", "This asset has been flagged as malicious for the following reasons.": "Este ativo foi marcado como malicioso pelos seguintes motivos.", "This asset has been flagged as spam for the following reasons.": "Este ativo foi marcado como spam pelos seguintes motivos.", @@ -563,16 +570,16 @@ "This asset is not on your lists": "Este ativo não está em suas listas", "This asset is on your lists": "Este ativo está em suas listas", "This asset was flagged as malicious": "Este ativo foi marcado como malicioso", - "This asset was flagged as malicious (override active)": "This asset was flagged as malicious (override active)", + "This asset was flagged as malicious (override active)": "Este ativo foi sinalizado como malicioso (substituição ativa)", "This asset was flagged as spam": "Este ativo foi marcado como spam", "This asset was flagged as suspicious": "Este ativo foi marcado como suspeito", - "This asset was flagged as suspicious (override active)": "This asset was flagged as suspicious (override active)", + "This asset was flagged as suspicious (override active)": "Este ativo foi sinalizado como suspeito (substituição ativa)", "This can be used to sign arbitrary transaction hashes without having to decode them first.": "Isso pode ser usado para assinar hashes de transação arbitrários sem precisar decodificá-los primeiro.", "This collectible is hidden": "Este colecionável está oculto", "This is not a valid contract id.": "Este não é um ID de contrato válido.", "This setting enables access to the Futurenet network and disables access to Pubnet.": "Esta configuração permite acesso à rede Futurenet e desabilita o acesso ao Pubnet.", - "This site does not appear safe for the following reasons": "This site does not appear safe for the following reasons", - "This site has been flagged with potential concerns": "This site has been flagged with potential concerns", + "This site does not appear safe for the following reasons": "Este site não parece seguro pelos seguintes motivos", + "This site has been flagged with potential concerns": "Este site foi sinalizado com possíveis preocupações", "This site was flagged as malicious": "Este site foi marcado como malicioso", "This token does not support muxed address (M-) as a target destination.": "Este token não suporta endereço muxed (M-) como destino.", "This transaction could not be completed.": "Esta transação não pôde ser concluída.", @@ -581,11 +588,11 @@ "This transaction is expected to fail": "Esta transação deve falhar", "This transaction is expected to fail for the following reasons.": "Esta transação deve falhar pelos seguintes motivos.", "This transaction was flagged as malicious": "Esta transação foi marcada como maliciosa", - "This transaction was flagged as malicious (override active)": "This transaction was flagged as malicious (override active)", + "This transaction was flagged as malicious (override active)": "Esta transação foi sinalizada como maliciosa (substituição ativa)", "This transaction was flagged as suspicious": "Esta transação foi marcada como suspeita", - "This transaction was flagged as suspicious (override active)": "This transaction was flagged as suspicious (override active)", + "This transaction was flagged as suspicious (override active)": "Esta transação foi sinalizada como suspeita (substituição ativa)", "This will be used to unlock your wallet": "Isso será usado para desbloquear sua carteira", - "Timeout": "Timeout", + "Timeout": "Tempo esgotado", "Timeout (seconds)": "Timeout (segundos)", "to": "para", "To access your wallet, click Freighter from your browser Extensions browser menu.": "Para acessar sua carteira, clique em Freighter no menu de Extensões do seu navegador.", @@ -611,21 +618,24 @@ "Transaction Timeout": "Tempo Limite da Transação", "Transfer from another account": "Transferir de outra conta", "Transfer from Coinbase, buy with debit and credit cards or bank transfer *": "Transferir do Coinbase, comprar com cartões de débito e crédito ou transferência bancária *", + "Trending": "Em alta", "trustlines": "linhas de confiança", "Trustor": "Fidedigno", "Type": "Tipo", "Type your memo": "Digite seu memo", "Unable to connect to": "Não foi possível conectar a", + "Unable to fetch protocols": "Não foi possível buscar os protocolos", "Unable to find your asset.": "Não foi possível encontrar seu ativo.", "Unable to load network details": "Não foi possível carregar os detalhes da rede", "Unable to migrate": "Não foi possível migrar", "Unable to parse assets lists": "Não foi possível fazer parse das asset lists", - "Unable to Scan": "Unable to Scan", - "Unable to scan asset": "Unable to scan asset", - "Unable to scan site": "Unable to scan site", + "Unable to Scan": "Não foi possível verificar", + "Unable to scan asset": "Não foi possível verificar o ativo", + "Unable to scan destination token": "Não foi possível verificar o token de destino", + "Unable to scan site": "Não foi possível verificar o site", "Unable to scan site for malicious behavior": "Não foi possível escanear o site para comportamento malicioso", - "Unable to scan token": "Unable to scan token", - "Unable to scan transaction": "Unable to scan transaction", + "Unable to scan token": "Não foi possível verificar o token", + "Unable to scan transaction": "Não foi possível verificar a transação", "Unable to sign out": "Não foi possível sair", "Unexpected Error": "Erro Inesperado", "Unknown error occured": "Erro desconhecido ocorreu", @@ -646,6 +656,7 @@ "View": "Ver", "View maintenance details": "Ver detalhes da manutenção", "View on": "Ver em", + "View on stellar": "Ver na Stellar", "View on stellar.expert": "Ver em stellar.expert", "View options": "Ver opções", "View transaction": "Ver transação", @@ -659,11 +670,13 @@ "was swapped to": "foi trocado para", "wasm": "wasm", "Wasm Hash": "Hash Wasm", - "We were unable to scan this site for security issues": "We were unable to scan this site for security issues", - "We were unable to scan this token for security threats": "We were unable to scan this token for security threats", - "We were unable to scan this transaction for security issues": "We were unable to scan this transaction for security issues", + "We were unable to scan this site for security issues": "Não foi possível verificar este site quanto a problemas de segurança", + "We were unable to scan this token for security threats": "Não foi possível verificar este token quanto a ameaças de segurança", + "We were unable to scan this transaction for security issues": "Não foi possível verificar esta transação quanto a problemas de segurança", + "We were unable to scan this transaction for security threats": "Não foi possível verificar esta transação quanto a ameaças de segurança", "WEBSITE CONNECTION IS NOT SECURE": "CONEXÃO DO WEBSITE NÃO É SEGURA", "Welcome back": "Bem-vindo de volta", + "Welcome to Discover!": "Bem-vindo ao Discover!", "What is this transaction for? (optional)": "Para que é esta transação? (opcional)", "What’s new": "O que há de novo", "Wrong simulation result": "Resultado de simulação incorreto", @@ -700,6 +713,7 @@ "Your account data could not be fetched at this time.": "Os dados da sua conta não puderam ser buscados neste momento.", "Your assets": "Seus ativos", "Your available XLM balance is not enough to pay for the transaction fee.": "Seu saldo XLM disponível não é suficiente para pagar a taxa de transação.", + "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Sua porta de entrada para o ecossistema Stellar. Navegue e conecte-se a aplicações descentralizadas construídas na Stellar.", "Your recovery phrase": "Sua frase de recuperação", "Your Recovery Phrase": "Sua Frase de Recuperação", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Sua frase de recuperação lhe dá acesso à sua conta e é a única maneira de acessá-la em um novo navegador.", diff --git a/extension/src/popup/metrics/discover.ts b/extension/src/popup/metrics/discover.ts new file mode 100644 index 0000000000..eff37e4a8d --- /dev/null +++ b/extension/src/popup/metrics/discover.ts @@ -0,0 +1,72 @@ +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +/** Strip query parameters and fragments from a URL to avoid leaking + * sensitive data (tokens, session IDs) to the analytics backend. + * This is not a big risk right now on extension as we only allow + * known protocols that come from our backend but we include this + * function for future-proofing and also to match mobile behavior. + */ +export const stripQueryParams = (url: string): string => { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + } catch { + return url.split(/[?#]/)[0]; + } +}; + +export const DISCOVER_SOURCE = { + TRENDING_CAROUSEL: "trending_carousel", + RECENT_LIST: "recent_list", + DAPPS_LIST: "dapps_list", + EXPANDED_RECENT_LIST: "expanded_recent_list", + EXPANDED_DAPPS_LIST: "expanded_dapps_list", +} as const; + +export type DiscoverSource = + (typeof DISCOVER_SOURCE)[keyof typeof DISCOVER_SOURCE]; + +export const trackDiscoverProtocolOpened = ( + protocolName: string, + url: string, + source: DiscoverSource, +): void => { + emitMetric(METRIC_NAMES.discoverProtocolOpened, { + url: stripQueryParams(url), + protocolName, + source, + // We currently only allow known protocols in the Discover view for extension, + // but this field is included for future-proofing in case we later expand to + // allowing unknown protocols (e.g. from a search bar like we have on mobile). + isKnownProtocol: true, + }); +}; + +export const trackDiscoverProtocolDetailsViewed = ( + protocolName: string, + tags: string[], +): void => { + emitMetric(METRIC_NAMES.discoverProtocolDetailsViewed, { + protocolName, + tags, + }); +}; + +export const trackDiscoverProtocolOpenedFromDetails = ( + protocolName: string, + url: string, +): void => { + emitMetric(METRIC_NAMES.discoverProtocolOpenedFromDetails, { + protocolName, + url: stripQueryParams(url), + }); +}; + +export const trackDiscoverViewed = (): void => { + emitMetric(METRIC_NAMES.viewDiscover); +}; + +export const trackDiscoverWelcomeModalViewed = (): void => { + emitMetric(METRIC_NAMES.discoverWelcomeModalViewed); +}; diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 0d63f6fa91..b01ae42369 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -66,7 +66,6 @@ const routeToEventName = { METRIC_NAMES.viewAccountMigrationMigrationComplete, [ROUTES.advancedSettings]: METRIC_NAMES.viewAdvancedSettings, [ROUTES.addFunds]: METRIC_NAMES.viewAddFunds, - [ROUTES.discover]: METRIC_NAMES.discover, [ROUTES.wallets]: METRIC_NAMES.wallets, }; diff --git a/extension/src/popup/styles/global.scss b/extension/src/popup/styles/global.scss index 891f46e4bb..409aeb4238 100644 --- a/extension/src/popup/styles/global.scss +++ b/extension/src/popup/styles/global.scss @@ -16,7 +16,7 @@ --fullscreen--max-width: 1100px; - --back--button-dimension: 1.25rem; + --back--button-dimension: 1.5rem; --back--button-z-index: 2; --dropdown-animation: 0.3s ease-out; @@ -28,6 +28,15 @@ --z-index-maintenance-screen: 100; } +html, +body { + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + body { color: var(--sds-clr-gray-12); overscroll-behavior: none; diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 822f84c463..a6a8208184 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useContext } from "react"; +import React, { useEffect, useRef, useContext, useState } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; import { Notification } from "@stellar/design-system"; @@ -40,6 +40,14 @@ import { } from "./hooks/useGetIcons"; import { AccountTabsContext, TabsList } from "./contexts/activeTabContext"; +import { + Sheet, + SheetContent, + ScreenReaderOnly, + SheetTitle, +} from "popup/basics/shadcn/Sheet"; +import { Discover } from "popup/views/Discover"; + import "popup/metrics/authServices"; import "./styles.scss"; @@ -50,6 +58,7 @@ export const Account = () => { const { userNotification } = useSelector(settingsSelector); const currentAccountName = useSelector(accountNameSelector); const { activeTab } = useContext(AccountTabsContext); + const [isDiscoverOpen, setIsDiscoverOpen] = useState(false); const isFullscreenModeEnabled = isFullscreenMode(); const { @@ -204,6 +213,7 @@ export const Account = () => { isFunded={!!resolvedData?.balances?.isFunded} refreshHiddenCollectibles={refreshHiddenCollectibles} isCollectibleHidden={isCollectibleHidden} + onDiscoverClick={() => setIsDiscoverOpen(true)} />
@@ -300,6 +310,19 @@ export const Account = () => { /> )} + + e.preventDefault()} + aria-describedby={undefined} + side="bottom" + className="AccountView__discover-sheet" + > + + {t("Discover")} + + setIsDiscoverOpen(false)} /> + + ); }; diff --git a/extension/src/popup/views/Account/styles.scss b/extension/src/popup/views/Account/styles.scss index fdfad87b25..778f80fbef 100644 --- a/extension/src/popup/views/Account/styles.scss +++ b/extension/src/popup/views/Account/styles.scss @@ -91,4 +91,10 @@ padding-top: var(--View-inset-padding-top); padding-bottom: 1.5rem; } + + &__discover-sheet { + background: var(--sds-clr-gray-01); + height: 100%; + width: 100%; + } } diff --git a/extension/src/popup/views/Discover/components/DiscoverError/index.tsx b/extension/src/popup/views/Discover/components/DiscoverError/index.tsx new file mode 100644 index 0000000000..8514252fed --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverError/index.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import "./styles.scss"; + +interface DiscoverErrorProps { + onRetry: () => void; +} + +export const DiscoverError = ({ onRetry }: DiscoverErrorProps) => { + const { t } = useTranslation(); + + return ( +
+
+ +
+
+ + {t("Unable to fetch protocols")} + + + {t( + "There was an error fetching protocols. Please refresh and try again.", + )} + +
+ +
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/DiscoverError/styles.scss b/extension/src/popup/views/Discover/components/DiscoverError/styles.scss new file mode 100644 index 0000000000..990f0431d1 --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverError/styles.scss @@ -0,0 +1,41 @@ +@use "../../../../styles/utils.scss" as *; + +.DiscoverError { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: pxToRem(16); + padding: pxToRem(16); + border-radius: pxToRem(24); + background-color: var(--sds-clr-gray-03); + text-align: center; + + &__icon { + width: pxToRem(24); + height: pxToRem(24); + border-radius: pxToRem(6); + background: var(--sds-clr-amber-03); + border: 1px solid var(--sds-clr-amber-06); + display: flex; + align-items: center; + justify-content: center; + color: var(--sds-clr-amber-11); + + svg { + width: pxToRem(16); + height: pxToRem(16); + } + } + + &__text { + display: flex; + flex-direction: column; + gap: pxToRem(8); + width: 100%; + + &__subtitle { + color: var(--sds-clr-gray-11); + } + } +} diff --git a/extension/src/popup/views/Discover/components/DiscoverHome/index.tsx b/extension/src/popup/views/Discover/components/DiscoverHome/index.tsx new file mode 100644 index 0000000000..e74ac9491a --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverHome/index.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { DiscoverData, ProtocolEntry } from "@shared/api/types"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; +import { TrendingCarousel } from "../TrendingCarousel"; +import { DiscoverSection } from "../DiscoverSection"; +import "./styles.scss"; + +interface DiscoverHomeProps { + trendingItems: DiscoverData; + recentItems: DiscoverData; + dappsItems: DiscoverData; + onClose: () => void; + onExpandRecent: () => void; + onExpandDapps: () => void; + onCardClick: (protocol: ProtocolEntry) => void; + onRecentRowClick: (protocol: ProtocolEntry) => void; + onDappsRowClick: (protocol: ProtocolEntry) => void; + onOpenRecentClick: (protocol: ProtocolEntry) => void; + onOpenDappsClick: (protocol: ProtocolEntry) => void; +} + +export const DiscoverHome = ({ + trendingItems, + recentItems, + dappsItems, + onClose, + onExpandRecent, + onExpandDapps, + onCardClick, + onRecentRowClick, + onDappsRowClick, + onOpenRecentClick, + onOpenDappsClick, +}: DiscoverHomeProps) => { + const { t } = useTranslation(); + + return ( + + } + customBackAction={onClose} + /> + +
+ + + +
+ {dappsItems.length > 0 && ( +
+ + {t( + "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.", + )} + +
+ )} +
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/DiscoverHome/styles.scss b/extension/src/popup/views/Discover/components/DiscoverHome/styles.scss new file mode 100644 index 0000000000..fcef226cce --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverHome/styles.scss @@ -0,0 +1,23 @@ +@use "../../../../styles/utils.scss" as *; + +.DiscoverHome { + &__sections { + display: flex; + flex-direction: column; + gap: pxToRem(32); + } + + &__footer { + display: flex; + flex-direction: column; + gap: pxToRem(8); + border-radius: pxToRem(16); + background-color: var(--sds-clr-gray-02); + padding: pxToRem(16); + margin-top: pxToRem(32); + + &__copy { + color: var(--sds-clr-gray-11); + } + } +} diff --git a/extension/src/popup/views/Discover/components/DiscoverSection/index.tsx b/extension/src/popup/views/Discover/components/DiscoverSection/index.tsx new file mode 100644 index 0000000000..92cf173028 --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverSection/index.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; + +import { DiscoverData, ProtocolEntry } from "@shared/api/types"; +import { ProtocolRow } from "../ProtocolRow"; +import "./styles.scss"; + +const MAX_VISIBLE = 5; + +interface DiscoverSectionProps { + title: string; + items: DiscoverData; + onExpand: () => void; + onRowClick: (protocol: ProtocolEntry) => void; + onOpenClick: (protocol: ProtocolEntry) => void; +} + +export const DiscoverSection = ({ + title, + items, + onExpand, + onRowClick, + onOpenClick, +}: DiscoverSectionProps) => { + if (items.length === 0) { + return null; + } + + const visibleItems = items.slice(0, MAX_VISIBLE); + + return ( +
+
+ + {title} + + +
+
+ {visibleItems.map((protocol) => ( + + ))} +
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/DiscoverSection/styles.scss b/extension/src/popup/views/Discover/components/DiscoverSection/styles.scss new file mode 100644 index 0000000000..ea2c8b58f1 --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverSection/styles.scss @@ -0,0 +1,24 @@ +@use "../../../../styles/utils.scss" as *; + +.DiscoverSection { + &__header { + display: flex; + align-items: center; + gap: pxToRem(4); + cursor: pointer; + margin-bottom: pxToRem(24); + color: var(--sds-clr-gray-12); + + svg { + width: pxToRem(16); + height: pxToRem(16); + color: var(--sds-clr-gray-11); + } + } + + &__list { + display: flex; + flex-direction: column; + gap: pxToRem(24); + } +} diff --git a/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/index.tsx b/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/index.tsx new file mode 100644 index 0000000000..d86b504601 --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/index.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { trackDiscoverWelcomeModalViewed } from "popup/metrics/discover"; +import { LoadingBackground } from "popup/basics/LoadingBackground"; + +import "./styles.scss"; + +interface DiscoverWelcomeModalProps { + onDismiss: () => void; +} + +export const DiscoverWelcomeModal = ({ + onDismiss, +}: DiscoverWelcomeModalProps) => { + const { t } = useTranslation(); + + useEffect(() => { + trackDiscoverWelcomeModalViewed(); + }, []); + + return ( + <> +
+
+
+ +
+
+ + {t("Welcome to Discover!")} + +
+
+ + {t( + "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.", + )} + + + {t( + "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.", + )} + +
+ +
+
+ + + ); +}; diff --git a/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/styles.scss b/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/styles.scss new file mode 100644 index 0000000000..a0a315c8aa --- /dev/null +++ b/extension/src/popup/views/Discover/components/DiscoverWelcomeModal/styles.scss @@ -0,0 +1,57 @@ +@use "../../../../styles/utils.scss" as *; + +.DiscoverWelcomeModal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-index-modal); + padding: pxToRem(24); + + &__card { + background: var(--sds-clr-gray-01); + border: 1px solid var(--sds-clr-gray-06); + border-radius: pxToRem(16); + padding: pxToRem(24); + position: relative; + z-index: 2; + max-width: pxToRem(312); + width: 100%; + } + + &__icon { + width: pxToRem(32); + height: pxToRem(32); + border-radius: pxToRem(8); + background: var(--sds-clr-lilac-03, var(--sds-clr-gray-03)); + border: 1px solid var(--sds-clr-lilac-06, var(--sds-clr-gray-06)); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: pxToRem(16); + color: var(--sds-clr-lilac-11); + + svg { + width: pxToRem(20); + height: pxToRem(20); + } + } + + &__title { + margin-bottom: pxToRem(12); + } + + &__body { + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(24); + + p { + margin-bottom: pxToRem(12); + + &:last-child { + margin-bottom: 0; + } + } + } +} diff --git a/extension/src/popup/views/Discover/components/ExpandedDapps/index.tsx b/extension/src/popup/views/Discover/components/ExpandedDapps/index.tsx new file mode 100644 index 0000000000..3b34cf0641 --- /dev/null +++ b/extension/src/popup/views/Discover/components/ExpandedDapps/index.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { DiscoverData, ProtocolEntry } from "@shared/api/types"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; +import { ProtocolRow } from "../ProtocolRow"; +import "./styles.scss"; + +interface ExpandedDappsProps { + items: DiscoverData; + onBack: () => void; + onRowClick: (protocol: ProtocolEntry) => void; + onOpenClick: (protocol: ProtocolEntry) => void; +} + +export const ExpandedDapps = ({ + items, + onBack, + onRowClick, + onOpenClick, +}: ExpandedDappsProps) => { + const { t } = useTranslation(); + + return ( + + + +
+ {items.map((protocol) => ( + + ))} +
+
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/ExpandedDapps/styles.scss b/extension/src/popup/views/Discover/components/ExpandedDapps/styles.scss new file mode 100644 index 0000000000..67ec545e2f --- /dev/null +++ b/extension/src/popup/views/Discover/components/ExpandedDapps/styles.scss @@ -0,0 +1,9 @@ +@use "../../../../styles/utils.scss" as *; + +.ExpandedDapps { + &__list { + display: flex; + flex-direction: column; + gap: pxToRem(24); + } +} diff --git a/extension/src/popup/views/Discover/components/ExpandedRecent/index.tsx b/extension/src/popup/views/Discover/components/ExpandedRecent/index.tsx new file mode 100644 index 0000000000..8b24a13524 --- /dev/null +++ b/extension/src/popup/views/Discover/components/ExpandedRecent/index.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { DiscoverData, ProtocolEntry } from "@shared/api/types"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "popup/basics/shadcn/Popover"; +import { ProtocolRow } from "../ProtocolRow"; +import "./styles.scss"; + +interface ExpandedRecentProps { + items: DiscoverData; + onBack: () => void; + onRowClick: (protocol: ProtocolEntry) => void; + onOpenClick: (protocol: ProtocolEntry) => void; + onClearRecent: () => void; +} + +export const ExpandedRecent = ({ + items, + onBack, + onRowClick, + onOpenClick, + onClearRecent, +}: ExpandedRecentProps) => { + const { t } = useTranslation(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + + + + + +
{ + setIsPopoverOpen(false); + onClearRecent(); + }} + data-testid="clear-recents-button" + > + + + {t("Clear recents")} + +
+
+ + } + /> + +
+ {items.map((protocol) => ( + + ))} +
+
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/ExpandedRecent/styles.scss b/extension/src/popup/views/Discover/components/ExpandedRecent/styles.scss new file mode 100644 index 0000000000..a34d88ab99 --- /dev/null +++ b/extension/src/popup/views/Discover/components/ExpandedRecent/styles.scss @@ -0,0 +1,51 @@ +@use "../../../../styles/utils.scss" as *; + +.ExpandedRecent { + &__list { + display: flex; + flex-direction: column; + gap: pxToRem(24); + } + + &__menu-trigger { + background: transparent; + border: none; + cursor: pointer; + color: var(--sds-clr-gray-12); + padding: pxToRem(4); + + svg { + width: pxToRem(20); + height: pxToRem(20); + } + } + + &__dropdown { + width: pxToRem(180); + background: var(--sds-clr-gray-01); + border: 1px solid var(--sds-clr-gray-06); + border-radius: pxToRem(6); + box-shadow: 0 pxToRem(10) pxToRem(20) pxToRem(-10) rgba(0, 0, 0, 0.1); + padding: pxToRem(4); + z-index: 100; + } + + &__dropdown-item { + display: flex; + align-items: center; + gap: pxToRem(8); + padding: pxToRem(6) pxToRem(8); + cursor: pointer; + border-radius: pxToRem(4); + color: var(--sds-clr-red-09); + + svg { + width: pxToRem(16); + height: pxToRem(16); + } + + &:hover { + background: var(--sds-clr-gray-03); + } + } +} diff --git a/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/index.tsx b/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/index.tsx new file mode 100644 index 0000000000..4aa922ea9e --- /dev/null +++ b/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/index.tsx @@ -0,0 +1,105 @@ +import React, { useEffect } from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { ProtocolEntry } from "@shared/api/types"; +import { trackDiscoverProtocolDetailsViewed } from "popup/metrics/discover"; + +import "./styles.scss"; + +interface ProtocolDetailsPanelProps { + protocol: ProtocolEntry; + onOpen: (protocol: ProtocolEntry) => void; +} + +const getHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +export const ProtocolDetailsPanel = ({ + protocol, + onOpen, +}: ProtocolDetailsPanelProps) => { + const { t } = useTranslation(); + + useEffect(() => { + trackDiscoverProtocolDetailsViewed(protocol.name, protocol.tags); + }, [protocol]); + + return ( +
+
+ {protocol.name} +
+ + {protocol.name} + +
+ +
+ +
+
+ + {t("Domain")} + +
+
+ + + {getHostname(protocol.websiteUrl)} + +
+
+ +
+
+ + {t("Tags")} + +
+
+ {protocol.tags.map((tag) => ( +
+ + {tag} + +
+ ))} +
+
+ +
+
+ + {t("Overview")} + +
+
+ + {protocol.description} + +
+
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/styles.scss b/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/styles.scss new file mode 100644 index 0000000000..bdac2a2723 --- /dev/null +++ b/extension/src/popup/views/Discover/components/ProtocolDetailsPanel/styles.scss @@ -0,0 +1,93 @@ +@use "../../../../styles/utils.scss" as *; + +.ProtocolDetailsPanel { + padding: pxToRem(24); + + &__header { + display: flex; + align-items: center; + gap: pxToRem(12); + margin-bottom: pxToRem(24); + } + + &__icon { + width: pxToRem(40); + height: pxToRem(40); + border-radius: pxToRem(10); + object-fit: cover; + flex-shrink: 0; + } + + &__name { + flex: 1 0 0; + min-width: 0; + } + + &__open-button { + background: var(--sds-clr-gray-12); + color: var(--sds-clr-gray-01); + border: none; + border-radius: 100px; + display: flex; + gap: pxToRem(4); + height: pxToRem(32); + padding: 0 pxToRem(10); + justify-content: center; + align-items: center; + cursor: pointer; + flex-shrink: 0; + + &__icon { + height: pxToRem(14); + width: pxToRem(14); + + svg { + width: pxToRem(14); + height: pxToRem(14); + } + } + } + + &__section { + margin-bottom: pxToRem(24); + + &__label { + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(8); + } + } + + &__domain { + display: flex; + align-items: center; + gap: pxToRem(4); + + svg { + width: pxToRem(16); + height: pxToRem(16); + color: var(--sds-clr-gray-11); + } + } + + &__tags { + display: flex; + gap: pxToRem(8); + flex-wrap: wrap; + } + + &__tag { + border-radius: 100px; + background: var(--sds-clr-lime-02); + border: 1px solid var(--sds-clr-lime-06); + padding: pxToRem(2) pxToRem(8); + color: var(--sds-clr-lime-11); + + .Text { + line-height: pxToRem(20); + } + } + + &__description { + color: var(--sds-clr-gray-12); + } +} diff --git a/extension/src/popup/views/Discover/components/ProtocolRow/index.tsx b/extension/src/popup/views/Discover/components/ProtocolRow/index.tsx new file mode 100644 index 0000000000..8d08f6e9ce --- /dev/null +++ b/extension/src/popup/views/Discover/components/ProtocolRow/index.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { ProtocolEntry } from "@shared/api/types"; + +import "./styles.scss"; + +interface ProtocolRowProps { + protocol: ProtocolEntry; + onRowClick: (protocol: ProtocolEntry) => void; + onOpenClick: (protocol: ProtocolEntry) => void; +} + +export const ProtocolRow = ({ + protocol, + onRowClick, + onOpenClick, +}: ProtocolRowProps) => { + const { t } = useTranslation(); + + return ( +
onRowClick(protocol)} + > + {protocol.name} +
+ + {protocol.name} + +
+ + {protocol.tags[0] ?? ""} + +
+
+ +
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/ProtocolRow/styles.scss b/extension/src/popup/views/Discover/components/ProtocolRow/styles.scss new file mode 100644 index 0000000000..6b3e5795dc --- /dev/null +++ b/extension/src/popup/views/Discover/components/ProtocolRow/styles.scss @@ -0,0 +1,54 @@ +@use "../../../../styles/utils.scss" as *; + +.ProtocolRow { + display: flex; + gap: pxToRem(12); + align-items: center; + cursor: pointer; + + &__icon { + height: pxToRem(32); + width: pxToRem(32); + border-radius: pxToRem(10); + object-fit: cover; + flex-shrink: 0; + } + + &__label { + display: flex; + flex-direction: column; + flex: 1 0 0; + min-width: 0; + + &__subtitle { + color: var(--sds-clr-gray-11); + } + } + + &__open-button { + border-radius: 100px; + border: 1px solid var(--sds-clr-gray-06); + color: var(--sds-clr-gray-12); + display: flex; + gap: pxToRem(4); + height: pxToRem(32); + padding: 0 pxToRem(10); + justify-content: center; + align-items: center; + cursor: pointer; + background: transparent; + text-decoration: none; + flex-shrink: 0; + + &__icon { + height: pxToRem(14); + width: pxToRem(14); + color: var(--sds-clr-gray-09); + + svg { + width: pxToRem(14); + height: pxToRem(14); + } + } + } +} diff --git a/extension/src/popup/views/Discover/components/TrendingCarousel/index.tsx b/extension/src/popup/views/Discover/components/TrendingCarousel/index.tsx new file mode 100644 index 0000000000..7d530d4d29 --- /dev/null +++ b/extension/src/popup/views/Discover/components/TrendingCarousel/index.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { DiscoverData, ProtocolEntry } from "@shared/api/types"; + +import "./styles.scss"; + +const TrendingCard = ({ + protocol, + onCardClick, +}: { + protocol: ProtocolEntry; + onCardClick: (protocol: ProtocolEntry) => void; +}) => { + const [imgFailed, setImgFailed] = useState(false); + const showPlaceholder = !protocol.backgroundUrl || imgFailed; + + return ( +
onCardClick(protocol)} + > + {showPlaceholder ? ( +
+ +
+ ) : ( + {`${protocol.name} setImgFailed(true)} + /> + )} +
+
+ + {protocol.name} + +
+ + {protocol.tags[0] ?? ""} + +
+
+
+ ); +}; + +interface TrendingCarouselProps { + items: DiscoverData; + onCardClick: (protocol: ProtocolEntry) => void; +} + +export const TrendingCarousel = ({ + items, + onCardClick, +}: TrendingCarouselProps) => { + const { t } = useTranslation(); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ + {t("Trending")} + +
+
+ {items.map((protocol) => ( + + ))} +
+
+ ); +}; diff --git a/extension/src/popup/views/Discover/components/TrendingCarousel/styles.scss b/extension/src/popup/views/Discover/components/TrendingCarousel/styles.scss new file mode 100644 index 0000000000..9fed55568f --- /dev/null +++ b/extension/src/popup/views/Discover/components/TrendingCarousel/styles.scss @@ -0,0 +1,81 @@ +@use "../../../../styles/utils.scss" as *; + +.TrendingCarousel { + &__label { + margin-bottom: pxToRem(12); + } + + &__scroll-container { + display: flex; + gap: pxToRem(16); + overflow-x: auto; + scroll-behavior: smooth; + -ms-overflow-style: none; + scrollbar-width: none; + margin-left: calc(-1 * var(--popup--side-padding)); + margin-right: calc(-1 * var(--popup--side-padding)); + padding-left: var(--popup--side-padding); + padding-right: var(--popup--side-padding); + + &::-webkit-scrollbar { + display: none; + } + } + + &__card { + min-width: pxToRem(288); + height: pxToRem(162); + border-radius: pxToRem(12); + background-color: var(--sds-clr-gray-03); + position: relative; + overflow: hidden; + cursor: pointer; + flex-shrink: 0; + + &__bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + &__placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--sds-clr-gray-11); + + svg { + width: pxToRem(40); + height: pxToRem(40); + } + } + + &__gradient { + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + transparent 40%, + var(--sds-clr-gray-03) 100% + ); + } + + &__content { + position: absolute; + bottom: pxToRem(12); + left: pxToRem(12); + right: pxToRem(12); + display: flex; + justify-content: space-between; + align-items: flex-end; + } + + &__tag { + color: var(--sds-clr-gray-11); + } + } +} diff --git a/extension/src/popup/views/Discover/hooks/useDiscoverData.ts b/extension/src/popup/views/Discover/hooks/useDiscoverData.ts new file mode 100644 index 0000000000..fcff0b3a38 --- /dev/null +++ b/extension/src/popup/views/Discover/hooks/useDiscoverData.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useReducer } from "react"; +import { captureException } from "@sentry/browser"; + +import { getDiscoverData } from "@shared/api/internal"; +import { DiscoverData } from "@shared/api/types"; +import { + getRecentProtocols, + RecentProtocolEntry, +} from "popup/helpers/recentProtocols"; + +interface DiscoverDataState { + isLoading: boolean; + error: unknown | null; + allProtocols: DiscoverData; + recentEntries: RecentProtocolEntry[]; +} + +type Action = + | { type: "FETCH_START" } + | { + type: "FETCH_SUCCESS"; + payload: { + protocols: DiscoverData; + recentEntries: RecentProtocolEntry[]; + }; + } + | { type: "FETCH_ERROR"; payload: unknown } + | { type: "REFRESH_RECENT"; payload: RecentProtocolEntry[] }; + +const initialState: DiscoverDataState = { + isLoading: true, + error: null, + allProtocols: [], + recentEntries: [], +}; + +const reducer = ( + state: DiscoverDataState, + action: Action, +): DiscoverDataState => { + switch (action.type) { + case "FETCH_START": + return { ...state, isLoading: true, error: null }; + case "FETCH_SUCCESS": + return { + isLoading: false, + error: null, + allProtocols: action.payload.protocols, + recentEntries: action.payload.recentEntries, + }; + case "FETCH_ERROR": + return { ...state, isLoading: false, error: action.payload }; + case "REFRESH_RECENT": + return { ...state, recentEntries: action.payload }; + default: + return state; + } +}; + +export const useDiscoverData = () => { + const [state, dispatch] = useReducer(reducer, initialState); + + const fetchData = useCallback(async () => { + dispatch({ type: "FETCH_START" }); + try { + const [protocols, recentEntries] = await Promise.all([ + getDiscoverData(), + getRecentProtocols(), + ]); + dispatch({ + type: "FETCH_SUCCESS", + payload: { protocols, recentEntries }, + }); + } catch (error) { + dispatch({ type: "FETCH_ERROR", payload: error }); + captureException(`Error loading discover data - ${error}`); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const refreshRecent = useCallback(async () => { + const recentEntries = await getRecentProtocols(); + dispatch({ type: "REFRESH_RECENT", payload: recentEntries }); + }, []); + + const allowedProtocols = state.allProtocols.filter((p) => !p.isBlacklisted); + + const trendingItems = allowedProtocols.filter((p) => p.isTrending); + + const recentItems = state.recentEntries + .map((entry) => + allowedProtocols.find((p) => p.websiteUrl === entry.websiteUrl), + ) + .filter(Boolean) as DiscoverData; + + const dappsItems = allowedProtocols; + + return { + isLoading: state.isLoading, + error: state.error, + trendingItems, + recentItems, + dappsItems, + refreshRecent, + retry: fetchData, + }; +}; diff --git a/extension/src/popup/views/Discover/hooks/useDiscoverWelcome.ts b/extension/src/popup/views/Discover/hooks/useDiscoverWelcome.ts new file mode 100644 index 0000000000..300577f288 --- /dev/null +++ b/extension/src/popup/views/Discover/hooks/useDiscoverWelcome.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useState } from "react"; +import browser from "webextension-polyfill"; + +const STORAGE_KEY = "hasSeenDiscoverWelcome"; + +export const useDiscoverWelcome = () => { + const [showWelcome, setShowWelcome] = useState(false); + + useEffect(() => { + const checkWelcome = async () => { + const result = await browser.storage.local.get(STORAGE_KEY); + if (!result[STORAGE_KEY]) { + setShowWelcome(true); + } + }; + checkWelcome(); + }, []); + + const dismissWelcome = useCallback(async () => { + setShowWelcome(false); + await browser.storage.local.set({ [STORAGE_KEY]: true }); + }, []); + + return { showWelcome, dismissWelcome }; +}; diff --git a/extension/src/popup/views/Discover/hooks/useGetDiscoverData.ts b/extension/src/popup/views/Discover/hooks/useGetDiscoverData.ts deleted file mode 100644 index 9b1e936976..0000000000 --- a/extension/src/popup/views/Discover/hooks/useGetDiscoverData.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useReducer } from "react"; -import { captureException } from "@sentry/browser"; - -import { RequestState } from "constants/request"; -import { initialState, reducer } from "helpers/request"; -import { getDiscoverData } from "@shared/api/internal"; -import { DiscoverData } from "@shared/api/types"; - -interface DiscoverDataPayload { - discoverData: DiscoverData; -} - -const useGetDiscoverData = () => { - const [state, dispatch] = useReducer( - reducer, - initialState, - ); - - const fetchData = async () => { - dispatch({ type: "FETCH_DATA_START" }); - try { - const discoverData = await getDiscoverData(); - - const payload = { - discoverData, - }; - dispatch({ type: "FETCH_DATA_SUCCESS", payload }); - return payload; - } catch (error) { - dispatch({ type: "FETCH_DATA_ERROR", payload: error }); - captureException(`Error loading discover protocols - ${error}`); - return error; - } - }; - - return { - state, - fetchData, - }; -}; - -export { useGetDiscoverData, RequestState }; diff --git a/extension/src/popup/views/Discover/index.tsx b/extension/src/popup/views/Discover/index.tsx index 5b8a3bc1b3..0c1a81887f 100644 --- a/extension/src/popup/views/Discover/index.tsx +++ b/extension/src/popup/views/Discover/index.tsx @@ -1,111 +1,205 @@ -import React, { useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; +import { Icon } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { Icon, Text } from "@stellar/design-system"; -import { View } from "popup/basics/layout/View"; +import { ProtocolEntry } from "@shared/api/types"; +import { + trackDiscoverViewed, + trackDiscoverProtocolOpened, + trackDiscoverProtocolOpenedFromDetails, + DiscoverSource, + DISCOVER_SOURCE, +} from "popup/metrics/discover"; import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; +import { openTab } from "popup/helpers/navigate"; +import { + addRecentProtocol, + clearRecentProtocols, +} from "popup/helpers/recentProtocols"; +import { SlideupModal } from "popup/components/SlideupModal"; import { Loading } from "popup/components/Loading"; -import { DiscoverData } from "@shared/api/types"; -import { RequestState, useGetDiscoverData } from "./hooks/useGetDiscoverData"; +import { useDiscoverData } from "./hooks/useDiscoverData"; +import { useDiscoverWelcome } from "./hooks/useDiscoverWelcome"; +import { DiscoverHome } from "./components/DiscoverHome"; +import { ExpandedRecent } from "./components/ExpandedRecent"; +import { ExpandedDapps } from "./components/ExpandedDapps"; +import { ProtocolDetailsPanel } from "./components/ProtocolDetailsPanel"; +import { DiscoverWelcomeModal } from "./components/DiscoverWelcomeModal"; +import { DiscoverError } from "./components/DiscoverError"; import "./styles.scss"; -export const Discover = () => { +type DiscoverView = "main" | "recent" | "dapps"; + +interface DiscoverProps { + onClose?: () => void; +} + +export const Discover = ({ onClose = () => {} }: DiscoverProps) => { const { t } = useTranslation(); - const { state: discoverData, fetchData } = useGetDiscoverData(); + const [activeView, setActiveView] = useState("main"); + const [selectedProtocol, setSelectedProtocol] = + useState(null); + const [selectedSource, setSelectedSource] = useState( + DISCOVER_SOURCE.DAPPS_LIST, + ); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + const { + isLoading, + error, + trendingItems, + recentItems, + dappsItems, + refreshRecent, + retry, + } = useDiscoverData(); + const { showWelcome, dismissWelcome } = useDiscoverWelcome(); useEffect(() => { - const getData = async () => { - await fetchData(); - }; - getData(); - // eslint-disable-next-line react-hooks/exhaustive-deps + trackDiscoverViewed(); }, []); - const { state, data } = discoverData; - const isLoading = - state === RequestState.IDLE || state === RequestState.LOADING; - let allowedDiscoverRows = [] as DiscoverData; + + const handleOpenProtocol = useCallback( + async (protocol: ProtocolEntry, source: DiscoverSource) => { + trackDiscoverProtocolOpened(protocol.name, protocol.websiteUrl, source); + await addRecentProtocol(protocol.websiteUrl); + await refreshRecent(); + openTab(protocol.websiteUrl); + }, + [refreshRecent], + ); + + const handleRowClick = useCallback( + (protocol: ProtocolEntry, source: DiscoverSource) => { + setSelectedProtocol(protocol); + setSelectedSource(source); + setIsDetailsOpen(true); + }, + [], + ); + + const handleDetailsOpen = useCallback( + async (protocol: ProtocolEntry) => { + trackDiscoverProtocolOpenedFromDetails( + protocol.name, + protocol.websiteUrl, + ); + setIsDetailsOpen(false); + // Wait for the SlideupModal close animation before clearing state + setTimeout(async () => { + setSelectedProtocol(null); + await handleOpenProtocol(protocol, selectedSource); + }, 200); + }, + [handleOpenProtocol, selectedSource], + ); + + const handleClearRecent = useCallback(async () => { + await clearRecentProtocols(); + await refreshRecent(); + setActiveView("main"); + }, [refreshRecent]); if (isLoading) { - return ; + return ( +
+ +
+ ); } - if (state !== RequestState.ERROR) { - allowedDiscoverRows = data.discoverData.filter((row) => !row.isBlacklisted); + if (error) { + return ( +
+ + } + customBackAction={onClose} + /> + + + + +
+ ); } return ( - <> - - -
- - - {t("Dapps")} - -
-
- {allowedDiscoverRows.length ? ( - allowedDiscoverRows.map((row) => ( -
- {row.name} -
- - {row.name} - -
- - {row.tags.join(", ")} - -
-
- - - {t("Open")} - -
- -
-
-
- )) - ) : ( -
- - {t("There are no sites to display at this moment.")} - -
- )} - {allowedDiscoverRows.length ? ( -
-
- {`${t( - "Freighter provides access to third-party dApps, protocols, and tokens for informational purposes only.", - )} ${t("Freighter does not endorse any listed items.")}`} -
-
- {t( - "By using these services, you act at your own risk, and Freighter or Stellar Development Foundation (SDF) bears no liability for any resulting losses or damages.", - )} -
-
- ) : null} -
-
- +
+ {activeView === "main" && ( + setActiveView("recent")} + onExpandDapps={() => setActiveView("dapps")} + onCardClick={(p: ProtocolEntry) => + handleRowClick(p, DISCOVER_SOURCE.TRENDING_CAROUSEL) + } + onRecentRowClick={(p: ProtocolEntry) => + handleRowClick(p, DISCOVER_SOURCE.RECENT_LIST) + } + onDappsRowClick={(p: ProtocolEntry) => + handleRowClick(p, DISCOVER_SOURCE.DAPPS_LIST) + } + onOpenRecentClick={(p: ProtocolEntry) => + handleOpenProtocol(p, DISCOVER_SOURCE.RECENT_LIST) + } + onOpenDappsClick={(p: ProtocolEntry) => + handleOpenProtocol(p, DISCOVER_SOURCE.DAPPS_LIST) + } + /> + )} + {activeView === "recent" && ( + setActiveView("main")} + onRowClick={(p: ProtocolEntry) => + handleRowClick(p, DISCOVER_SOURCE.EXPANDED_RECENT_LIST) + } + onOpenClick={(p) => + handleOpenProtocol(p, DISCOVER_SOURCE.EXPANDED_RECENT_LIST) + } + onClearRecent={handleClearRecent} + /> + )} + {activeView === "dapps" && ( + setActiveView("main")} + onRowClick={(p: ProtocolEntry) => + handleRowClick(p, DISCOVER_SOURCE.EXPANDED_DAPPS_LIST) + } + onOpenClick={(p) => + handleOpenProtocol(p, DISCOVER_SOURCE.EXPANDED_DAPPS_LIST) + } + /> + )} + + { + setIsDetailsOpen(open); + if (!open) { + setSelectedProtocol(null); + } + }} + > + {selectedProtocol ? ( + + ) : ( +
+ )} + + + {showWelcome && } +
); }; diff --git a/extension/src/popup/views/Discover/styles.scss b/extension/src/popup/views/Discover/styles.scss index 468206eb21..1d57e090f2 100644 --- a/extension/src/popup/views/Discover/styles.scss +++ b/extension/src/popup/views/Discover/styles.scss @@ -1,70 +1,6 @@ -@use "../../styles/utils.scss" as *; - .Discover { - &__eyebrow { - align-items: center; - color: var(--sds-clr-gray-11); - display: flex; - gap: pxToRem(8); - margin-bottom: pxToRem(16); - } - - &__content { - display: flex; - flex-direction: column; - gap: pxToRem(24); - } - - &__row { - display: flex; - gap: pxToRem(12); - align-items: center; - - &__icon { - height: pxToRem(32); - width: pxToRem(32); - } - - &__label { - display: flex; - flex-direction: column; - flex: 1 0 0; - - &__subtitle { - color: var(--sds-clr-gray-11); - } - } - - &__button { - border-radius: 100px; - border: 1px solid var(--sds-clr-gray-06); - color: var(--sds-clr-gray-12); - display: flex; - gap: pxToRem(4); - padding: pxToRem(6) pxToRem(10); - justify-content: center; - align-items: center; - - &__icon { - height: pxToRem(14); - width: pxToRem(14); - color: var(--sds-clr-gray-09); - } - } - } - - &__footer { - display: flex; - flex-direction: column; - gap: pxToRem(8); - border-radius: pxToRem(16); - background-color: var(--sds-clr-gray-02); - padding: pxToRem(16); - - &__copy { - color: var(--sds-clr-gray-11); - font-size: pxToRem(12px); - line-height: pxToRem(14); - } - } + height: 100%; + display: flex; + flex-direction: column; + background: var(--sds-clr-gray-01); } diff --git a/extension/src/popup/views/Wallets/index.tsx b/extension/src/popup/views/Wallets/index.tsx index cdfaac6126..2f9097ddf5 100644 --- a/extension/src/popup/views/Wallets/index.tsx +++ b/extension/src/popup/views/Wallets/index.tsx @@ -380,7 +380,7 @@ export const Wallets = () => { navigateTo(ROUTES.account, navigate)} - customBackIcon={} + customBackIcon={} /> { - it("displays Discover protocols with links that are not blacklisted", async () => { - jest.spyOn(ApiInternal, "getDiscoverData").mockImplementation(() => - Promise.resolve([ - { - description: "description text", - name: "Foo", - iconUrl: "https://example.com/icon.png", - websiteUrl: "https://foo.com", - tags: ["tag1", "tag2"], - isBlacklisted: false, - }, - { - description: "description text", - name: "Baz", - iconUrl: "https://example.com/icon.png", - websiteUrl: "https://baz.com", - tags: ["tag1", "tag2"], - isBlacklisted: true, - }, - ]), - ); +// Mock browser.storage.local +const mockStorageGet = jest.fn().mockResolvedValue({}); +const mockStorageSet = jest.fn().mockResolvedValue(undefined); +const mockStorageRemove = jest.fn().mockResolvedValue(undefined); + +jest.mock("webextension-polyfill", () => ({ + storage: { + local: { + get: (...args: unknown[]) => mockStorageGet(...args), + set: (...args: unknown[]) => mockStorageSet(...args), + remove: (...args: unknown[]) => mockStorageRemove(...args), + }, + }, + tabs: { + create: jest.fn(), + }, +})); + +const mockProtocols: DiscoverData = [ + { + description: "A lending protocol", + name: "Blend", + iconUrl: "https://example.com/blend.png", + websiteUrl: "https://blend.capital", + tags: ["Lending", "DeFi"], + isBlacklisted: false, + backgroundUrl: "https://example.com/blend-bg.png", + isTrending: true, + }, + { + description: "An exchange", + name: "Soroswap", + iconUrl: "https://example.com/soroswap.png", + websiteUrl: "https://soroswap.finance", + tags: ["Exchange"], + isBlacklisted: false, + isTrending: false, + }, + { + description: "Blacklisted protocol", + name: "BadProtocol", + iconUrl: "https://example.com/bad.png", + websiteUrl: "https://bad.com", + tags: ["Scam"], + isBlacklisted: true, + isTrending: false, + }, +]; + +describe("Discover", () => { + let openTabSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(ApiInternal, "getDiscoverData").mockResolvedValue(mockProtocols); + jest.spyOn(RecentProtocols, "getRecentProtocols").mockResolvedValue([]); + jest.spyOn(RecentProtocols, "addRecentProtocol").mockResolvedValue(); + jest.spyOn(RecentProtocols, "clearRecentProtocols").mockResolvedValue(); + openTabSpy = jest.spyOn(Navigate, "openTab").mockResolvedValue({} as any); + mockStorageGet.mockResolvedValue({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const renderDiscover = () => render( { }, }} > - + , ); - await waitFor(() => { - expect(screen.getByTestId("AppHeaderPageTitle")).toHaveTextContent( - "Discover", + describe("rendering", () => { + it("displays trending protocols in the carousel", async () => { + renderDiscover(); + + await waitFor(() => { + expect(screen.getByTestId("trending-carousel")).toBeInTheDocument(); + }); + + const trendingCards = screen.getAllByTestId("trending-card"); + expect(trendingCards).toHaveLength(1); + expect(trendingCards[0]).toHaveTextContent("Blend"); + }); + + it("displays dApps section with non-blacklisted protocols", async () => { + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + const protocolRows = screen.getAllByTestId("protocol-row"); + // Blend + Soroswap (BadProtocol is blacklisted) + expect(protocolRows).toHaveLength(2); + expect(protocolRows[0]).toHaveTextContent("Blend"); + expect(protocolRows[1]).toHaveTextContent("Soroswap"); + }); + + it("hides recent section when no recent protocols exist", async () => { + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId("discover-section-recent"), + ).not.toBeInTheDocument(); + }); + + it("shows recent section when recent protocols exist", async () => { + jest + .spyOn(RecentProtocols, "getRecentProtocols") + .mockResolvedValue([ + { websiteUrl: "https://blend.capital", lastAccessed: Date.now() }, + ]); + + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-recent"), + ).toBeInTheDocument(); + }); + }); + + it("filters blacklisted protocols from trending carousel", async () => { + renderDiscover(); + + await waitFor(() => { + expect(screen.getByTestId("trending-carousel")).toBeInTheDocument(); + }); + + const trendingCards = screen.getAllByTestId("trending-card"); + trendingCards.forEach((card) => { + expect(card).not.toHaveTextContent("BadProtocol"); + }); + }); + }); + + describe("error state", () => { + it("shows error screen with retry when API fails", async () => { + jest + .spyOn(ApiInternal, "getDiscoverData") + .mockRejectedValue(new Error("Network error")); + + renderDiscover(); + + await waitFor(() => { + expect(screen.getByTestId("discover-error")).toBeInTheDocument(); + }); + + expect(screen.getByText("Unable to fetch protocols")).toBeInTheDocument(); + }); + + it("retries fetching data when Refresh is clicked", async () => { + const getDiscoverSpy = jest + .spyOn(ApiInternal, "getDiscoverData") + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(mockProtocols); + + renderDiscover(); + + await waitFor(() => { + expect(screen.getByTestId("discover-error")).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByTestId("discover-error-retry")); + + await waitFor(() => { + expect(screen.getByTestId("trending-carousel")).toBeInTheDocument(); + }); + + expect(getDiscoverSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("open protocol", () => { + it("saves to recents before opening a new tab", async () => { + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + const openButtons = screen.getAllByTestId("protocol-row-open"); + await userEvent.click(openButtons[0]); + + expect(RecentProtocols.addRecentProtocol).toHaveBeenCalledWith( + "https://blend.capital", ); + expect(openTabSpy).toHaveBeenCalledWith("https://blend.capital"); + + // Verify order: addRecentProtocol called before openTab + const addCall = (RecentProtocols.addRecentProtocol as jest.Mock).mock + .invocationCallOrder[0]; + const openCall = openTabSpy.mock.invocationCallOrder[0]; + expect(addCall).toBeLessThan(openCall); }); - const protocolLinks = screen.queryAllByTestId("discover-row"); + }); - expect(protocolLinks).toHaveLength(1); - expect(protocolLinks[0]).toHaveTextContent("Foo"); - expect(screen.getByTestId("discover-row-button")).toHaveAttribute( - "href", - "https://foo.com", - ); + describe("protocol details panel", () => { + it("opens details panel when clicking a protocol row", async () => { + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + const protocolRows = screen.getAllByTestId("protocol-row"); + await userEvent.click(protocolRows[0]); + + await waitFor(() => { + expect( + screen.getByTestId("protocol-details-panel"), + ).toBeInTheDocument(); + }); + + const panel = screen.getByTestId("protocol-details-panel"); + expect(panel).toHaveTextContent("blend.capital"); + expect(panel).toHaveTextContent("Lending"); + expect(panel).toHaveTextContent("DeFi"); + expect(panel).toHaveTextContent("A lending protocol"); + }); + }); + + describe("welcome modal", () => { + it("shows welcome modal on first visit", async () => { + mockStorageGet.mockResolvedValue({}); + + renderDiscover(); + + await waitFor(() => { + expect(screen.getByText("Welcome to Discover!")).toBeInTheDocument(); + }); + }); + + it("hides welcome modal when already dismissed", async () => { + mockStorageGet.mockResolvedValue({ hasSeenDiscoverWelcome: true }); + + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText("Welcome to Discover!"), + ).not.toBeInTheDocument(); + }); + + it("dismisses welcome modal and persists to storage", async () => { + mockStorageGet.mockResolvedValue({}); + + renderDiscover(); + + await waitFor(() => { + expect(screen.getByText("Welcome to Discover!")).toBeInTheDocument(); + }); + + const dismissButton = screen.getByTestId("discover-welcome-dismiss"); + await userEvent.click(dismissButton); + + expect( + screen.queryByText("Welcome to Discover!"), + ).not.toBeInTheDocument(); + expect(mockStorageSet).toHaveBeenCalledWith({ + hasSeenDiscoverWelcome: true, + }); + }); + }); + + describe("sub-view navigation", () => { + it("navigates to expanded dApps view", async () => { + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + + const expandButton = screen.getByTestId("discover-section-expand-dapps"); + await userEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText("dApps")).toBeInTheDocument(); + }); + + // Should show all non-blacklisted protocols + const rows = screen.getAllByTestId("protocol-row"); + expect(rows).toHaveLength(2); + }); + + it("navigates to expanded recent view and back", async () => { + jest + .spyOn(RecentProtocols, "getRecentProtocols") + .mockResolvedValue([ + { websiteUrl: "https://blend.capital", lastAccessed: Date.now() }, + ]); + + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-recent"), + ).toBeInTheDocument(); + }); + + const expandButton = screen.getByTestId("discover-section-expand-recent"); + await userEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByText("Recent")).toBeInTheDocument(); + expect(screen.getByTestId("expanded-recent-menu")).toBeInTheDocument(); + }); + }); + }); + + describe("clear recents", () => { + it("clears recent protocols and returns to main view", async () => { + jest + .spyOn(RecentProtocols, "getRecentProtocols") + .mockResolvedValueOnce([ + { websiteUrl: "https://blend.capital", lastAccessed: Date.now() }, + ]) + // After clear, return empty + .mockResolvedValue([]); + + renderDiscover(); + + await waitFor(() => { + expect( + screen.getByTestId("discover-section-recent"), + ).toBeInTheDocument(); + }); + + // Navigate to expanded recent + const expandButton = screen.getByTestId("discover-section-expand-recent"); + await userEvent.click(expandButton); + + await waitFor(() => { + expect(screen.getByTestId("expanded-recent-menu")).toBeInTheDocument(); + }); + + // Open menu and clear + const menuTrigger = screen.getByTestId("expanded-recent-menu"); + await userEvent.click(menuTrigger); + + const clearButton = screen.getByTestId("clear-recents-button"); + await userEvent.click(clearButton); + + expect(RecentProtocols.clearRecentProtocols).toHaveBeenCalled(); + + // Should return to main view + await waitFor(() => { + expect( + screen.getByTestId("discover-section-dapps"), + ).toBeInTheDocument(); + }); + }); }); });