From 0b1f7befc4eedeb9cedc5d6a179151b469e6e552 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:14:31 -0230 Subject: [PATCH 01/15] chore: New Crowdin translations by Github Action cp-7.71.0 (#27496) Co-authored-by: metamaskbot --- locales/languages/de.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/el.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/es.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/fr.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/hi.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/id.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ja.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/ko.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/pt.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ru.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/tl.json | 293 +++++++++++++++++++++++++++++-------- locales/languages/tr.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/vi.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/zh.json | 297 ++++++++++++++++++++++++++++--------- 14 files changed, 3252 insertions(+), 914 deletions(-) diff --git a/locales/languages/de.json b/locales/languages/de.json index 4fc909b68e6..9310e98c947 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -20,6 +20,12 @@ "update": "Update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Benachrichtigung", @@ -120,8 +126,8 @@ "title": "Senden von Assets an die Burn-Adresse" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token-Contract-Warnung", + "message": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann." }, "gas_sponsorship_reserve_balance": { "message": "Gas-Sponsoring ist für diese Transaktion nicht verfügbar. Sie benötigen mindestens %{minBalance} %{nativeTokenSymbol} auf Ihrem Konto.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Name konnte nicht aufgelöst werden", "invalid_address": "Ungültige Adresse", "contractAddressError": "Sie senden Tokens an die Kontraktadresse des Tokens. Dies kann zum Verlust dieser Tokens führen.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Smart-Contract-Adresse", + "smart_contract_address_warning": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann.", "i_understand": "Ich verstehe", "cancel": "Stornieren" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop-Loss muss {{direction}} {{priceType}} Preis sein", "stop_loss_beyond_liquidation_error": "Stop-Loss muss {{direction}} Liquidationspreis sein", "stop_loss_order_view_warning": "Stop-Loss ist {{direction}} Liquidationspreis", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "Über", "below": "Unter", "done": "Fertig", @@ -2086,14 +2094,15 @@ "a_closer_look": "Ein genauerer Blick", "whats_being_said": "Was gesagt wird", "footer_disclaimer": "KI-Zusammenfassung nur zu Informationszwecken", - "trade_button": "Trade", + "swap_button": "Tauschen", + "buy_button": "Kaufen", "sources_count": "+{{count}} Quellen", "sources_title": "Nachrichtenquellen", "feedback_submitted": "Feedback eingereicht", "helpful_prompt": "War dies hilfreich?", "feedback": { "title": "Feedback", - "description": "Helfen Sie uns, unsere KI-generierten Markteinblicke zu erweitern.", + "description": "Ihre Antwort trägt zur Verbesserung unserer KI-Zusammenfassungen bei.", "not_relevant": "Nicht relevant", "not_accurate": "Nicht genau", "hard_to_understand": "Schwer zu verstehen", @@ -2162,7 +2171,7 @@ "sell_position": "Position verkaufen", "cash_out": "Auszahlung", "cash_out_info": "Gelder werden Ihrem verfügbaren Guthaben hinzugefügt", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} zum {{price}}", "at_price_per_share": "Verkauf von {{size}}-Aktien zu {{price}}", "cashout_info": "{{amount}} bei {{outcome}} zu {{initialPrice}}", "cashout_info_multiple": "{{amount}} bei {{outcomeGroupTitle}} • {{outcome}} zu {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Verfügbares Guthaben", "claim_amount_text": "{{amount}} $ einfordern", "claim_winnings_text": "Gewinne einfordern", - "claiming_text": "Claiming...", + "claiming_text": "Einfordern ...", "unrealized_pnl_label": "Nicht realisierte GuV", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Laden nicht möglich", @@ -2287,7 +2296,7 @@ "try_again": "Erneut versuchen" }, "in_progress": { - "title": "Claim already in progress" + "title": "Einforderung bereits in Bearbeitung" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "An die Börse oder den Markt entrichtete Gebühr", "total_incl_fees": "inkl. Gebühren", "close": "Schließen", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Die angegebenen Preise setzen voraus, dass Ihre Order vollständig ausgeführt wird. Die tatsächlichen Beträge können abweichen, wenn die Order nur teilweise ausgeführt wird.", + "deposit_fee": "Einzahlungsgebühr", + "deposit_fee_description": "Gebühr für die Einzahlung von Geldern auf Ihr Prognosesaldo" }, "error": { "title": "Verbindung zu Prognosen nicht möglich", @@ -3059,6 +3068,7 @@ "networks_no_results": "Keine Netzwerke gefunden", "network_name_label": "Netzwerkname", "network_name_placeholder": "Netzwerkname (optional)", + "required": "Benötigt", "network_rpc_url_label": "RPC-URL", "network_rpc_name_label": "RPC-Name", "network_rpc_placeholder": "Neues RPC-Netzwerk", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Diese Funktion warnt Sie durch die aktive Überprüfung von Transaktions- und Signaturanfragen vor bösartigen Aktivitäten.", "security_alerts": "Sicherheitsbenachrichtigungen", "security_alerts_desc": "Diese Funktion warnt Sie vor böswilligen Aktivitäten, indem sie Ihre Transaktions- und Signaturanfragen lokal überprüft. Führen Sie immer Ihre eigene Prüfung durch, bevor Sie eine Anfrage genehmigen. Es gibt keine Garantie dafür, dass diese Funktion alle bösartigen Aktivitäten erkennt. Mit der Aktivierung dieser Funktion erklären Sie sich mit den Nutzungsbedingungen des Anbieters einverstanden.", + "smart_account_dapp_requests_heading": "Anfragen für Smart-Konten von Dapps", + "smart_account_dapp_requests_desc": "Lassen Sie Dapps die Smart-Konto-Funktionen für Standardkonten anfordern. Dies wirkt sich nicht auf bereits bestehende Smart-Konten aus.", "smart_transactions_opt_in_heading": "Smart Transactions", "smart_transactions_opt_in_desc_supported_networks": "Schalten Sie Smart Transactions für zuverlässigere und sicherere Transaktionen auf unterstützten Netzwerken ein.", "smart_transactions_learn_more": "Mehr erfahren", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}-Aktivität", "disclaimer": "Die Marktdaten werden von Drittquellen wie CoinGecko bereitgestellt. Diese Daten dienen lediglich zu Informationszwecken. MetaMask übernimmt keinerlei Verantwortung für deren Richtigkeit." }, + "security_trust": { + "title": "Sicherheit und Vertrauen", + "malicious": "Bösartig", + "risky": "Riskant", + "malicious_token_title": "Bösartiges Token", + "malicious_token_description": "{{symbol}} ist ein bösartiger Token. Vermeiden Sie die Interaktion mit diesem Token oder dessen Handel.", + "verified_token_title": "Verifizierter Token", + "verified_token_description": "{{symbol}} wird aktiv gehandelt und ist weitgehend anerkannt. Die Verifizierung ist keine Empfehlung von MetaMask.", + "risky_token_title": "Riskanter Token", + "risky_token_description": "Warnsignale für {{symbol}} erkannt. Recherchieren Sie sorgfältig, bevor Sie diesen Token handeln.", + "malicious_token_sheet_description": "Ernsthafte Risikosignale für {{symbol}} erkannt. Wir empfehlen, diesen Token nicht zu handeln.", + "got_it": "Verstanden", + "proceed": "Fortfahren", + "cancel": "Stornieren", + "data_unavailable": "Sicherheitsdaten nicht verfügbar", + "subtitle_known": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_no_issues": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_suspicious": "Prüfen Sie die gekennzeichneten Punkte sorgfältig, bevor Sie dieses Asset handeln.", + "subtitle_malicious": "Ernsthafte Risikosignale erkannt. Wir empfehlen, dieses Asset zu meiden.", + "subtitle_unavailable": "Die Sicherheitsanalyse konnte für dieses Token nicht geladen werden.", + "token_distribution": "Verteilung von Token", + "total_supply": "Gesamtvorrat", + "top_10_holders": "Top 10 Inhaber", + "other": "Sonstiges", + "no_hidden_fees_detected": "Keine versteckten Gebühren erkannt", + "buy_sell_tax": "Kauf-/Verkaufssteuer", + "buy_tax": "Kaufsteuer", + "sell_tax": "Verkaufssteuer", + "transfer": "Übertragung", + "token_info": "Token-Infos", + "created": "Erstellt", + "token_age": "Token-Alter", + "network": "Netzwerk", + "type": "Geben Sie ein,", + "official_links": "Offizielle Links", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N. z.", + "verified": "Verifiziert", + "no_issues": "Keine Probleme", + "suspicious": "Verdächtig", + "malicious_label": "Bösartig", + "more": "Mehr", + "evaluation_disclaimer": "Diese Sicherheitsüberprüfung dient nur der Bewertung und stellt keine Empfehlung zum Handel dar." + }, "account_details": { "title": "Kontodetails", "share_account": "Teilen", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Anspruchsberechtigter Bonus", "claim_bonus": "Bonus einfordern", "claim_bonus_subtitle": "Der Bonus wird auf {{networkName}} ausgezahlt.", + "percentage_bonus_on_linea": "{{percentage}} % Bonus auf Linea", + "claim": "Einfordern", + "sounds_good": "Klingt gut", + "claimable_bonus_tooltip_with_percentage": "{{percentage}} % jährlicher Bonus, den Sie für das Halten von mUSD verdient haben. Ihr Bonus kann täglich auf Linea eingefordert werden.", "empty_state_cta": { "heading": "Verleihen Sie {{tokenSymbol}} und verdienen Sie", "body": "Verleihen Sie Ihre {{tokenSymbol}} mit {{protocol}} und verdienen Sie", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ihre Stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Verdienen", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Sie haben nicht genügend Ressourcen, um diese Aktion durchzuführen." }, - "trx_unstaking_in_progress": "Unstaken {{amount}} TRX in Bearbeitung. Das Unstaken erfordert 14 Tage.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaken von {{amount}} TRX in Bearbeitung", + "description": "Das Unstaken erfordert 14 Tage" + }, + "unstaked_banner": { + "title": "Unstaken von {{amount}} TRX abgeschlossen", + "description": "Ihr unstaken TRX kann jetzt ausgezahlt werden", + "button": "Auszahlen", + "error": "Auszahlung fehlgeschlagen" + } }, "stake_eth": "ETH staken", "unstake_eth": "ETH unstaken", @@ -6376,7 +6498,8 @@ "approve": "Anfrage genehmigen", "perps_deposit": "Gelder hinzufügen", "predict_deposit": "Prognosegelder hinzufügen", - "predict_withdraw": "Auszahlen" + "predict_withdraw": "Auszahlen", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaktion {{index}}", "transaction": "Transaktion", "available_balance": "Verfügbares Guthaben: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Fortfahren", "deposit_edit_amount_done": "Gelder hinzufügen", "deposit_edit_amount_predict_withdraw": "Auszahlen", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hardware-Wallets werden noch nicht unterstützt. Verwenden Sie eine Hot Wallet, um fortzufahren.", "hardware_wallet_not_supported_solana": "Hardware-Wallets werden für Solana noch nicht unterstützt. Verwenden Sie ein Hot Wallet, um fortzufahren.", "price_impact_info_title": "Preiseinfluss", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Auf diese Weise verändert Ihr Handel den Marktpreis eines Tokens. Dies ist abhängig von der Handelsgröße, der verfügbaren Liquidität und den Gebühren des Anbieters. MetaMask hat keinen Einfluss auf den Preis.", "price_impact_info_gasless_description": "Die Preisauswirkung spiegelt wider, wie Ihre Swap-Order den Marktpreis des Assets beeinflusst. Falls Sie nicht über genügend Gelder für Gas halten, wird ein Teil Ihres Quelltokens automatisch zur Deckung der Gebühren verwendet, was die Preisauswirkung erhöht. MetaMask hat keinerlei Kontrolle über die Preisauswirkung.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Aufgrund Ihrer Handelsgröße und der verfügbaren Liquidität erhalten Sie etwa {{priceImpact}} unter dem Marktpreis. Dies ist bereits in Ihrem Angebot berücksichtigt.", "price_impact_high": "Hohe Preisauswirkungen", "price_impact_execution_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} des Wertes Ihres Tokens. Versuchen Sie, den Betrag zu senken oder eine liquidere Route zu wählen.", "proceed": "Fortfahren", @@ -6627,8 +6751,8 @@ "total_cost": "Gesamtkosten", "got_it": "Verstanden", "price_impact_warning_title": "Hohe Preisauswirkungen", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Sehr hohe Preisauswirkungen", + "price_impact_error_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} den Marktpreis Ihres Tokens. Versuchen Sie einen kleineren Handel oder eine liquidere Route, um Ihren Kurs zu verbessern." }, "quote_expired_modal": { "title": "Neue Angebote sind verfügbar", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade auf Metall", "continue_button": "Fortfahren", "virtual_card": { - "name": "Virtual Card", + "name": "Virtuelle Karte", "price": "Kostenlos", "feature_1": "Virtuelle Karte für Apple Pay und Google Pay", "feature_2": "Bezahlen Sie mit Kryptowährung (USDC, USDT, WETH und mehr)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metallkarte", "price": "199 $/Jahr", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Alles in virtuell, plus:", + "feature_1": "Erstklassige gravierte Metallkarte", + "feature_2": "3 % Cashback auf die ersten 10.000 $/Jahr", "feature_3": "Keine Auslandstransaktionsgebühren" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Verdienen Sie jährlich bis zu 300 $ an Cashback", + "upgrade_to_metal_label": "Oder upgraden Sie auf Metall für das 3-fache an Belohnungen" }, "review_order": { "title": "Prüfen Sie Ihre Bestellung", @@ -7104,7 +7228,7 @@ "ssn_description": "Vom Kartenaussteller gefordert. Es wird keine Bonitätsprüfung durchgeführt.", "invalid_ssn": "Ungültige SSN", "invalid_date_of_birth": "Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein.", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Vor- und Nachname müssen mit Ihrer verifizierten Identität übereinstimmen" }, "physical_address": { "title": "Fügen Sie Ihre Adresse hinzu", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Sie sind Ihrem Ausgabenlimit nahe", "description": "Aktualisieren Sie, um Ablehnungen zu vermeiden", - "confirm_button_label": "Neues Limit festlegen" + "confirm_button_label": "Neues Limit festlegen", + "dismiss_button_label": "Verwerfen" }, "need_delegation": { "title": "Sie müssen Ihre Karte aktivieren", @@ -7301,7 +7426,6 @@ "dismiss": "Verwerfen", "update_success": "Ausgabenlimit erfolgreich aktualisiert", "update_error": "Aktualisierung des Ausgabenlimits fehlgeschlagen", - "solana_not_supported": "Aktivieren Sie Solana-Token auf card.metamask.io", "select_token": "Token auswählen", "loading": "Verfügbare Tokens werden geladen ...", "load_error": "Tokens können nicht geladen werden. Bitte versuchen Sie es erneut.", @@ -7343,9 +7467,7 @@ "limited": "Limitiert", "not_enabled": "Nicht aktiviert", "update_success": "Ausgabenpriorität erfolgreich aktualisiert", - "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen", - "solana_not_supported_button_title": "Andere Token auf Solana", - "solana_not_supported_button_description": "Auf card.metamask.io aktivieren" + "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen" }, "card_authentication": { "title": "Melden Sie sich bei Ihrem Kartenkonto an", @@ -7443,6 +7565,11 @@ "title": "Anmeldung fehlgeschlagen", "description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." }, + "version_guard": { + "title": "Aktualisierung erforderlich", + "description": "Für die Nutzung von Belohnungen ist eine neuere Version von MetaMask erforderlich. Bitte aktualisieren Sie, um fortzufahren.", + "update_button": "MetaMask aktualisieren" + }, "season_error": { "error_fetching_title": "Saison konnten nicht geladen werden", "error_fetching_description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", @@ -7525,7 +7652,6 @@ "main_title": "Belohnungen", "referral_title": "Empfehlungen", "tab_overview_title": "Übersicht", - "tab_snapshots_title": "Schnappschüsse", "tab_activity_title": "Aktivität", "referral_stats_earned_from_referrals": "Durch Empfehlungen verdient", "referral_stats_referrals": "Empfehlungen", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Sie haben in dieser Saison keine Belohnungen erhalten, aber es gibt ja immer ein nächstes Mal.", "verifying_rewards": "Wir stellen sicher, dass alles korrekt ist, bevor Sie Ihre Belohnungen einfordern." }, + "previous_season_view": { + "title": "Vorherige Saison" + }, "season_status": { "points_earned": "Verdiente Punkte" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktive Boosts", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD-Bonus-Rechner", + "description": "Finden Sie heraus, wie viel Sie durch die Konvertierung Ihrer Stablecoins in mUSD verdienen können.", + "amount_label": "Konvertierter Betrag", + "estimated_bonus": "Geschätzter jährlicher Bonus: bis zu 3 %", + "initial_amount": "Anfangsbetrag", + "daily_bonus": "Täglich einforderbarer Bonus", + "annualized_bonus": "Jährlicher Bonus", + "disclaimer": "Dies ist nur eine Schätzung. Der Bonus kann sich noch ändern.", "buy_button": "mUSD kaufen", - "swap_button": "Swap to mUSD" + "swap_button": "Swap zu mUSD" }, "upcoming_rewards": { "title": "Gesperrte Belohnungen", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Laden fehlgeschlagen" }, - "snapshot": { + "campaign": { "starts_date": "Beginnt am {{date}}", "ends_date": "Endet am {{date}}", - "results_coming_soon": "Ergebnisse folgen in Kürze", - "tokens_on_the_way": "Tokens sind unterwegs", + "ended_date": "Ended {{date}}", "pill_up_next": "Als Nächstes", - "pill_live_now": "Jetzt live", - "pill_calculating": "Berechnungsvorgang", - "pill_results_ready": "Ergebnisse bereit", - "pill_complete": "Abgeschlossen" - }, - "snapshots_section": { - "title": "Schnappschüsse", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", - "retry_button": "Erneut versuchen" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Abgeschlossen", + "enter_now": "Jetzt eingeben", + "entered": "Eingegeben", + "participant_count": "Nr. {{count}}", + "opt_in_cta": "Anmelden", + "opt_in_sheet_title": "An der Kampagne teilnehmen", + "opt_in_sheet_description_pre_link": "Indem Sie auf „Anmelden“ klicken, stimmen Sie MetaMask-Belohnungen zu", + "opt_in_sheet_link_text": "Ergänzende Nutzungsbedingungen und Datenschutzhinweis", + "opt_in_sheet_description_post_link": "Wir verfolgen die Onchain-Aktivitäten und belohnen Sie automatisch.", + "geo_restriction_banner_title": "In Ihrer Region nicht verfügbar", + "geo_restriction_banner_description": "Diese Kampagne ist in Ihrer Region aufgrund lokaler Bestimmungen nicht verfügbar." + }, + "campaign_mechanics": { + "title": "Mechaniken" + }, + "campaign_details": { + "start_date": "Beginnt am: {{date}}", + "end_date": "Endet am: {{date}}", + "opt_in": "Anmelden", + "opting_in": "Anmeldevorgang ...", + "opted_in": "Sie haben sich für diese Kampagne angemeldet", + "opt_in_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "join_campaign": "An der Kampagne teilnehmen", + "checking_opt_in_status": "Überprüfung des Anmeldestatus", + "swap": "Tauschen", + "how_it_works": "Wie es funktioniert" + }, + "campaigns_preview": { + "title": "Kampagnen", + "coming_soon": "Demnächst verfügbar", + "notify_me": "Mich benachrichtigen" + }, + "earn_rewards": { + "title": "Belohnungen verdienen", + "musd_title": "Bis zu 3 % Bonus auf Stablecoins", + "musd_subtitle": "Berechnen Sie Ihren mUSD-Bonus", + "card_title": "Bis zu 3 % Cashback", + "card_subtitle": "Sichern Sie sich jetzt Ihre MetaMask Card", + "card_subtitle_cardholder": "Nutzen Sie die Vorteile Ihrer MetaMask Card" + }, + "campaigns_view": { + "title": "Kampagnen", "active_title": "Aktiv", "upcoming_title": "Bevorstehend", "previous_title": "Vorherige", - "empty_state": "Keine Schnappschüsse verfügbar", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "empty_state": "Keine Kampagnen verfügbar", + "error_title": "Kampagnen konnten nicht geladen werden", + "error_description": "Kampagnen konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." } @@ -7953,13 +8112,12 @@ "continue": "Fortfahren" }, "connecting": { - "title": "Verbinden Sie Ihr {{device}}", + "title": "Ihr {{device}} wird verbunden ...", "searching": "Suche nach {{device}} ...", - "tips_header": "Um fortzufahren, stellen Sie Folgendes sicher:", + "tips_header": "Stellen Sie Folgendes sicher:", "tip_unlock": "Ihr {{device}} ist freigeschaltet", "tip_open_app": "Die Ethereum-App ist geöffnet", "tip_enable_bluetooth": "Bluetooth ist eingeschaltet", - "tip_dnd_off": "Nicht stören ist ausgeschaltet", "tip_bluetooth_permission": "Standort- und Bluetooth-Berechtigung werden erteilt", "tip_bluetooth_permission_v12": "Berechtigung für Geräte in der Nähe wird erteilt", "tip_stay_close": "Ihr Gerät bleibt in der Nähe Ihres Telefons" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Berechtigung für Geräte in der Nähe ist erforderlich", "bluetooth_off": "Bitte schalten Sie Bluetooth ein, um eine Verbindung mit Ihrem Gerät herzustellen", "bluetooth_scan_failed": "Scannen nach Geräten fehlgeschlagen. Bitte versuchen Sie es erneut", - "bluetooth_connection_failed": "Aktivieren Sie Bluetooth auf Ihrem Gerät, um fortzufahren", + "bluetooth_connection_failed": "Die Verbindung zu Ihrem Gerät ist fehlgeschlagen. Bitte versuchen Sie es erneut", "not_supported": "Dieser Vorgang wird nicht unterstützt", "unknown_error": "Stellen Sie sicher, dass Ihr {{device}} mit der geheimen Wiederherstellungsphrase oder Passphrase für dieses Konto eingerichtet ist" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Bargeld", + "cash_empty_description": "Sie haben noch keine mUSD. Konvertieren Sie Stablecoins in mUSD im Bereich „Bargeld“ auf der Startseite.", + "cash_empty_description_network_filter": "Kein mUSD in diesem Netzwerk. Wechseln Sie das Netzwerk, um Ihre mUSD einzusehen.", "tokens": "Token", "perpetuals": "Perpetuals", "predictions": "Prognosen", + "whats_happening": "Was ist passiert?", + "whats_happening_categories": { + "geopolitical": "Geopolitisch", + "macro": "Makro", + "regulatory": "Regulatorisch", + "technical": "Technisch", + "social": "Sozial", + "other": "Sonstiges" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs importieren", diff --git a/locales/languages/el.json b/locales/languages/el.json index 0c241834aac..9fc2fc4e2cc 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -20,6 +20,12 @@ "update": "Ενημέρωση" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Ειδοποίηση", @@ -120,8 +126,8 @@ "title": "Αποστολή περιουσιακών στοιχείων σε διεύθυνση καύσης" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Προειδοποίηση συμβολαίου token", + "message": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά." }, "gas_sponsorship_reserve_balance": { "message": "Η κάλυψη των τελών δεν είναι διαθέσιμη για αυτή τη συναλλαγή. Θα χρειαστεί να διατηρείτε τουλάχιστον %{minBalance} %{nativeTokenSymbol} στον λογαριασμό σας.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Δεν ήταν δυνατή η επίλυση του ονόματος", "invalid_address": "Μη έγκυρη διεύθυνση", "contractAddressError": "Πρόκειται να στείλετε tokens στη διεύθυνση συμβολαίου του token. Αυτό μπορεί να οδηγήσει σε απώλεια των token.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Διεύθυνση έξυπνου συμβολαίου", + "smart_contract_address_warning": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά.", "i_understand": "Κατανοώ", "cancel": "Άκυρο" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή {{priceType}}", "stop_loss_beyond_liquidation_error": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή ρευστοποίησης", "stop_loss_order_view_warning": "Η τιμή περιορισμού ζημιάς είναι {{direction}} από την τιμή ρευστοποίησης", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "πάνω", "below": "κάτω", "done": "Τέλος", @@ -2086,14 +2094,15 @@ "a_closer_look": "Μια πιο προσεκτική ματιά", "whats_being_said": "Τι συζητιέται", "footer_disclaimer": "Περίληψη από ΤΝ, μόνο για ενημερωτικούς σκοπούς", - "trade_button": "Συναλλαγές", + "swap_button": "Ανταλλαγή", + "buy_button": "Αγορά", "sources_count": "+{{count}} πηγές", "sources_title": "Πηγές ειδήσεων", "feedback_submitted": "Το σχόλιό σας υποβλήθηκε", "helpful_prompt": "Ήταν χρήσιμο;", "feedback": { "title": "Ανατροφοδότηση", - "description": "Βοηθήστε μας να βελτιώσουμε τις αναλύσεις αγοράς που δημιουργεί η τεχνητή νοημοσύνη.", + "description": "Οι απαντήσεις σας βοηθούν στη βελτίωση των περιλήψεών μας με AI.", "not_relevant": "Δεν είναι σχετικό", "not_accurate": "Δεν είναι ακριβές", "hard_to_understand": "Δύσκολο στην κατανόηση", @@ -2206,7 +2215,7 @@ "available_balance": "Διαθέσιμο υπόλοιπο", "claim_amount_text": "Εξαργυρώστε ${{amount}}", "claim_winnings_text": "Εξαργύρωση κερδών", - "claiming_text": "Claiming...", + "claiming_text": "Λήψη…", "unrealized_pnl_label": "Μη οριστικοποιημένα κέρδη & ζημίες", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Δεν ήταν δυνατή η φόρτωση", @@ -2287,7 +2296,7 @@ "try_again": "Προσπαθήστε ξανά" }, "in_progress": { - "title": "Claim already in progress" + "title": "Η διαδικασία λήψης βρίσκεται ήδη σε εξέλιξη" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Τέλη που καταβάλλονται στην πλατφόρμα συναλλαγών ή στην αγορά", "total_incl_fees": "συμπερ. τέλη", "close": "Κλείσιμο", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Οι τιμές που εμφανίζονται προϋποθέτουν ότι η εντολή σας θα εκτελεστεί πλήρως. Τα πραγματικά ποσά ενδέχεται να διαφέρουν εάν η εντολή εκτελεστεί μόνο μερικώς.", + "deposit_fee": "Τέλη κατάθεσης", + "deposit_fee_description": "Τέλη που επιβάλλονται για την κατάθεση χρημάτων στο υπόλοιπο ππροβλέψεών σας" }, "error": { "title": "Δεν ήταν δυνατή η σύνδεση με την πλατφόρμα προβλέψεων", @@ -3059,6 +3068,7 @@ "networks_no_results": "Δεν βρέθηκαν δίκτυα", "network_name_label": "Όνομα δικτύου", "network_name_placeholder": "Όνομα δικτύου (προαιρετικά)", + "required": "Απαιτείται", "network_rpc_url_label": "Διεύθυνση URL του RPC", "network_rpc_name_label": "Όνομα RPC", "network_rpc_placeholder": "Νέο δίκτυο RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Αυτή η λειτουργία σας προειδοποιεί για κακόβουλη δραστηριότητα, καθώς ελέγχει ενεργά τα αιτήματα συναλλαγών και υπογραφών.", "security_alerts": "Ειδοποιήσεις ασφαλείας", "security_alerts_desc": "Αυτή η λειτουργία σας ειδοποιεί για κακόβουλη δραστηριότητα, ελέγχοντας τοπικά τα αιτήματα συναλλαγών και υπογραφών σας. Κάνετε πάντα τη δική σας ενδελεχή έρευνα πριν εγκρίνετε οποιαδήποτε αιτήματα. Δεν υπάρχει καμία εγγύηση ότι αυτή η λειτουργία θα ανιχνεύσει όλες τις κακόβουλες δραστηριότητες. Ενεργοποιώντας αυτή τη λειτουργία, συμφωνείτε με τους όρους χρήσης του παρόχου.", + "smart_account_dapp_requests_heading": "Αιτήματα έξυπνου λογαριασμού από αποκεντρωμένες εφαρμογές (dapps)", + "smart_account_dapp_requests_desc": "Επιτρέψτε στις αποκεντρωμένες εφαρμογές (dapps) να ζητούν λειτουργίες έξυπνου λογαριασμού για τυπικούς λογαριασμούς. Δεν θα επηρεαστούν οι λογαριασμοί που είναι ήδη έξυπνοι λογαριασμοί.", "smart_transactions_opt_in_heading": "Έξυπνες Συναλλαγές", "smart_transactions_opt_in_desc_supported_networks": "Ενεργοποιήστε τις Έξυπνες Συναλλαγές για πιο αξιόπιστες και ασφαλείς συναλλαγές σε υποστηριζόμενα δίκτυα.", "smart_transactions_learn_more": "Μάθετε περισσότερα", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} δραστηριότητα", "disclaimer": "Τα δεδομένα αγοράς παρέχονται από τρίτους, όπως το CoinGecko. Τα δεδομένα προσφέρονται μόνο για ενημερωτικούς σκοπούς. Το MetaMask δεν φέρει ευθύνη για την ακρίβειά τους." }, + "security_trust": { + "title": "Ασφάλεια και εμπιστοσύνη", + "malicious": "Κακόβουλο", + "risky": "Επικίνδυνο", + "malicious_token_title": "Κακόβουλο token", + "malicious_token_description": "Το {{symbol}} είναι κακόβουλο token. Αποφύγετε την αλληλεπίδραση μαζί του ή να κάνετε συναλλαγές.", + "verified_token_title": "Επαληθευμένο token", + "verified_token_description": "Το {{symbol}} έχει υψηλή αναγνωρισιμότητα και είναι ευρέως γνωστό. Η επαλήθευση δεν αποτελεί έγκριση από το MetaMask.", + "risky_token_title": "Επικίνδυνο token", + "risky_token_description": "Εντοπίστηκαν προειδοποιητικές ενδείξεις για το {{symbol}}. Κάντε προσεκτική έρευνα πριν προχωρήσετε σε συναλλαγές με αυτό το token.", + "malicious_token_sheet_description": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου για το {{symbol}}. Συνιστούμε να μην κάνετε συναλλαγές με αυτό το token.", + "got_it": "Κατανοητό", + "proceed": "Συνεχίστε", + "cancel": "Ακύρωση", + "data_unavailable": "Τα δεδομένα ασφαλείας δεν είναι διαθέσιμα", + "subtitle_known": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_no_issues": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_suspicious": "Εντοπίστηκαν προειδοποιητικές ενδείξεις. Ελέγξτε προσεκτικά τα ζητήματα που έχουν επισημανθεί πριν κάνετε συναλλαγές με αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_malicious": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου. Συνιστούμε να αποφύγετε αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_unavailable": "Δεν ήταν δυνατή η φόρτωση της ανάλυσης ασφαλείας για αυτό το token.", + "token_distribution": "Κατανομή του token", + "total_supply": "Συνολική προσφορά", + "top_10_holders": "Κορυφαίοι 10 χρήστες", + "other": "Άλλο", + "no_hidden_fees_detected": "Δεν εντοπίστηκαν κρυφές χρεώσεις", + "buy_sell_tax": "Φόρος αγοράς/πώλησης", + "buy_tax": "Φόρος αγοράς", + "sell_tax": "Φόρος πώλησης", + "transfer": "Μεταφορά", + "token_info": "Πληροφορίες για το token", + "created": "Δημιουργήθηκε", + "token_age": "Ηλικία του token", + "network": "Δίκτυο", + "type": "Πληκτρολογήστε", + "official_links": "Επίσημοι σύνδεσμοι", + "website": "Ιστότοπος", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Μη διαθέσιμη", + "verified": "Επαληθευμένο", + "no_issues": "Δεν υπάρχουν ζητήματα", + "suspicious": "Ύποπτο", + "malicious_label": "Κακόβουλο", + "more": "περισσότερα", + "evaluation_disclaimer": "Αυτή η αξιολόγηση ασφαλείας παρέχεται αποκλειστικά για ενημερωτικούς σκοπούς και δεν αποτελεί έγκριση ή σύσταση για συναλλαγές." + }, "account_details": { "title": "Στοιχεία λογαριασμού", "share_account": "Κοινοποίηση", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Μπόνους προς εξαργύρωση", "claim_bonus": "Εξαργύρωση του μπόνους", "claim_bonus_subtitle": "Το μπόνους θα καταβληθεί στο δίκτυο {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% μπόνους στο δίκτυο Linea", + "claim": "Εξαργύρωση", + "sounds_good": "Εντάξει", + "claimable_bonus_tooltip_with_percentage": "Έχετε κερδίσει {{percentage}}% ετήσιο μπόνους επειδή έχετε mUSD. Το μπόνους σας μπορείτε να το εξαργυρώσετε καθημερινά στο δίκτυο Linea.", "empty_state_cta": { "heading": "Δανείστε {{tokenSymbol}} και κερδίστε", "body": "Δανείστε τα {{tokenSymbol}} μέσω του {{protocol}} και κερδίστε", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Τα stablecoins σας" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Κερδίστε", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Δεν έχετε αρκετό υπόλοιπο πόρων για να εκτελέσετε αυτή την ενέργεια." }, - "trx_unstaking_in_progress": "Η αποδέσμευση {{amount}} TRX βρίσκεται σε εξέλιξη. Η διαδικασία διαρκεί 14 ημέρες.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Αποδέσμευση {{amount}} TRX σε εξέλιξη", + "description": "Η διαδικασία αποδέσμευσης διαρκεί 14 ημέρες" + }, + "unstaked_banner": { + "title": "Η αποδέσμευση {{amount}} TRX ολοκληρώθηκε", + "description": "Τα TRX που αποδεσμεύσατε είναι πλέον διαθέσιμα για ανάληψη", + "button": "Ανάληψη", + "error": "Η ανάληψη απέτυχε" + } }, "stake_eth": "Ποντάρισμα σε ETH", "unstake_eth": "Ακύρωση πονταρίσματος σε ETH", @@ -6376,7 +6498,8 @@ "approve": "Έγκριση αιτήματος", "perps_deposit": "Προσθήκη κεφαλαίων", "predict_deposit": "Προσθήκη κεφαλαίων για Προβλέψεις", - "predict_withdraw": "Ανάληψη" + "predict_withdraw": "Ανάληψη", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Αυτός ο ιστότοπος θέλει άδεια για να δαπανήσει τα tokens σας.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Συναλλαγή {{index}}", "transaction": "Προστασία", "available_balance": "Διαθέσιμο υπόλοιπο: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Συνεχίστε", "deposit_edit_amount_done": "Προσθήκη κεφαλαίων", "deposit_edit_amount_predict_withdraw": "Ανάληψη", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Τα πορτοφόλια υλικού δεν υποστηρίζονται ακόμη. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "hardware_wallet_not_supported_solana": "Τα πορτοφόλια υλικού δεν υποστηρίζουν ακόμα την Solana. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "price_impact_info_title": "Αντίκτυπος στην τιμή", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Έτσι επηρεάζει η συναλλαγή σας την τιμή αγοράς ενός token. Εξαρτάται από το μέγεθος της συναλλαγής, τη διαθέσιμη ρευστότητα και τις χρεώσεις των παρόχων. Το MetaMask δεν ελέγχει την επίδραση στην τιμή.", "price_impact_info_gasless_description": "Η επίδραση στην τιμή αντικατοπτρίζει το πώς η εντολή ανταλλαγής σας επηρεάζει την τιμή αγοράς του περιουσιακού στοιχείου. Αν δεν διαθέτετε αρκετά κεφάλαια για τα τέλη συναλλαγής, μέρος του αρχικού σας token κατανέμεται αυτόματα για την κάλυψη των χρεώσεων, γεγονός που αυξάνει την επίδραση στην τιμή. Το MetaMask δεν επηρεάζει ούτε ελέγχει την επίδραση στην τιμή.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Λόγω του μεγέθους της συναλλαγής σας και της διαθέσιμης ρευστότητας, θα λάβετε περίπου {{priceImpact}} λιγότερο από την τιμή αγοράς. Αυτό έχει ήδη ληφθεί υπόψη στην προσφορά σας.", "price_impact_high": "Υψηλή επίδραση στην τιμή", "price_impact_execution_description": "Θα χάσετε περίπου {{priceImpact}} από την αξία του token σας σε αυτή την ανταλλαγή. Προσπαθήστε να μειώσετε το ποσό ή να επιλέξετε ένα κανάλι με μεγαλύτερη ρευστότητα.", "proceed": "Συνεχίστε", @@ -6627,8 +6751,8 @@ "total_cost": "Συνολικό κόστος", "got_it": "Κατανοητό", "price_impact_warning_title": "Υψηλή επίδραση στην τιμή", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Πολύ μεγάλη επίδραση στην τιμή", + "price_impact_error_description": "Θα χάσετε περίπου {{priceImpact}} από την τιμή αγοράς του token σε αυτή την ανταλλαγή. Δοκιμάστε με μικρότερο ποσό ή ένα κανάλι με μεγαλύτερη ρευστότητα για να βελτιώσετε την τιμή." }, "quote_expired_modal": { "title": "Υπάρχουν διαθέσιμες νέες προσφορές", @@ -6940,7 +7064,7 @@ "upgrade_title": "Αναβάθμιση σε Metal", "continue_button": "Συνεχίστε", "virtual_card": { - "name": "Virtual Card", + "name": "Εικονική κάρτα", "price": "Δωρεάν", "feature_1": "Εικονική κάρτα για Apple Pay και Google Pay", "feature_2": "Πληρώστε με κρυπτονομίσματα (USDC, USDT, WETH και άλλα)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/έτος", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Όλα όσα περιλαμβάνει η εικονική κάρτα, καθώς και:", + "feature_1": "Μεταλλική κάρτα υψηλής ποιότητας με χάραξη", + "feature_2": "3% επιστροφή χρημάτων στα πρώτα $10.000/έτος", "feature_3": "Χωρίς χρεώσεις για διεθνείς συναλλαγές" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Κερδίστε έως και $300 επιστροφή χρημάτων κάθε χρόνο", + "upgrade_to_metal_label": "Ή κάντε αναβάθμιση σε Μεταλλική κάρτα για 3× ανταμοιβές" }, "review_order": { "title": "Έλεγχος εντολής", @@ -7104,7 +7228,7 @@ "ssn_description": "Απαιτείται από τον εκδότη της κάρτας. Δεν θα πραγματοποιηθεί έλεγχος πιστοληπτικής ικανότητας.", "invalid_ssn": "Μη έγκυρος αριθμός κοινωνικής ασφάλισης (ΑΜΚΑ)", "invalid_date_of_birth": "Μη έγκυρη ημερομηνία γέννησης. Πρέπει να είστε τουλάχιστον 18 ετών", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Το όνομα και το επώνυμό σας πρέπει να ταιριάζουν με την επαληθευμένη ταυτότητά σας" }, "physical_address": { "title": "Προσθέστε τη διεύθυνσή σας", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Έχετε σχεδόν φτάσει το όριο δαπανών σας", "description": "Ενημερώστε για να αποφύγετε απορρίψεις\t", - "confirm_button_label": "Ορισμός νέου ορίου" + "confirm_button_label": "Ορισμός νέου ορίου", + "dismiss_button_label": "Απόρριψη" }, "need_delegation": { "title": "Πρέπει να ενεργοποιήσετε την κάρτα σας", @@ -7301,7 +7426,6 @@ "dismiss": "Απόρριψη", "update_success": "Το όριο δαπανών ενημερώθηκε με επιτυχία", "update_error": "Απέτυχε η ενημέρωση του ορίου δαπανών", - "solana_not_supported": "Ενεργοποιήστε τα tokens στη Solana στο card.metamask.io", "select_token": "Επιλέξτε token", "loading": "Φόρτωση διαθέσιμων tokens...", "load_error": "Δεν ήταν δυνατή η φόρτωση των tokens. Παρακαλούμε δοκιμάστε ξανά.", @@ -7343,9 +7467,7 @@ "limited": "Περιορισμένο", "not_enabled": "Δεν είναι ενεργοποιημένο", "update_success": "Η προτεραιότητα δαπανών ενημερώθηκε με επιτυχία", - "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών", - "solana_not_supported_button_title": "Άλλα tokens στη Solana", - "solana_not_supported_button_description": "Ενεργοποίηση στο card.metamask.io" + "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών" }, "card_authentication": { "title": "Συνδεθείτε στον λογαριασμό της κάρτας σας", @@ -7443,6 +7565,11 @@ "title": "Αποτυχία συμμετοχής", "description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά." }, + "version_guard": { + "title": "Απαιτείται ενημέρωση", + "description": "Απαιτείται νεότερη έκδοση του MetaMask για να χρησιμοποιήσετε τις Ανταμοιβές. Παρακαλούμε ενημερώστε την εφαρμογή για να συνεχίσετε.", + "update_button": "Ενημερώστε το MetaMask" + }, "season_error": { "error_fetching_title": "Αδυναμία φόρτωσης της περιόδου", "error_fetching_description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.", @@ -7525,7 +7652,6 @@ "main_title": "Ανταμοιβές", "referral_title": "Συστάσεις", "tab_overview_title": "Επισκόπηση", - "tab_snapshots_title": "Στιγμιότυπα", "tab_activity_title": "Δραστηριότητα", "referral_stats_earned_from_referrals": "Κερδίσατε από συστάσεις", "referral_stats_referrals": "Συστάσεις", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Δεν κερδίσατε ανταμοιβές αυτή την περίοδο, αλλά υπάρχει πάντα η επόμενη φορά.", "verifying_rewards": "Βεβαιωνόμαστε ότι όλα είναι σωστά πριν διεκδικήσετε τις ανταμοιβές σας." }, + "previous_season_view": { + "title": "Προηγούμενη περίοδος" + }, "season_status": { "points_earned": "Πόντοι που κερδίσατε" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Ενεργές ενισχύσεις", "season_1": "Περίοδος 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Εκτίμηση του μπόνους σε mUSD", + "description": "Δείτε πόσα μπορείτε να κερδίσετε μετατρέποντας τα stablecoins σας σε mUSD.", + "amount_label": "Το ποσό μετατράπηκε", + "estimated_bonus": "Εκτιμώμενο ετήσιο μπόνους: έως 3%", + "initial_amount": "Αρχικό ποσό", + "daily_bonus": "Ημερήσιο διαθέσιμο μπόνους", + "annualized_bonus": "Ετήσιο μπόνους", + "disclaimer": "Πρόκειται μόνο για εκτίμηση. Το μπόνους ενδέχεται να αλλάξει.", "buy_button": "Αγοράστε mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Ανταλλαγή σε mUSD" }, "upcoming_rewards": { "title": "Κλειδωμένες ανταμοιβές", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Δεν ήταν δυνατή η φόρτωση" }, - "snapshot": { + "campaign": { "starts_date": "Αρχίζει {{date}}", "ends_date": "Λήγει {{date}}", - "results_coming_soon": "Τα αποτελέσματα έρχονται σύντομα", - "tokens_on_the_way": "Token καθ' οδόν", + "ended_date": "Ended {{date}}", "pill_up_next": "Επόμενο", - "pill_live_now": "Ζωντανά τώρα", - "pill_calculating": "Υπολογισμός", - "pill_results_ready": "Τα αποτελέσματα είναι έτοιμα", - "pill_complete": "Ολοκληρώθηκε" - }, - "snapshots_section": { - "title": "Στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", - "retry_button": "Επανάληψη" - }, - "snapshots_tab": { + "pill_active": "Ζωντανά", + "pill_complete": "Ολοκληρώθηκε", + "enter_now": "Συμμετάσχετε τώρα", + "entered": "Έχετε συμμετάσχει", + "participant_count": "#{{count}}", + "opt_in_cta": "Εγγραφή", + "opt_in_sheet_title": "Συμμετάσχετε στην καμπάνια", + "opt_in_sheet_description_pre_link": "Κάνοντας κλικ στο ‘Εγγραφή’, αποδέχεστε τις Ανταμοιβές του MetaMask", + "opt_in_sheet_link_text": "Συμπληρωματικοί Όροι Χρήσης και Δήλωση Απορρήτου", + "opt_in_sheet_description_post_link": "Θα παρακολουθούμε τη δραστηριότητά σας στο blockchain ώστε να σας ανταμείβουμε αυτόματα.", + "geo_restriction_banner_title": "Δεν είναι διαθέσιμη στην περιοχή σας", + "geo_restriction_banner_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας λόγω τοπικών κανονισμών." + }, + "campaign_mechanics": { + "title": "Κανόνες" + }, + "campaign_details": { + "start_date": "Ξεκινά: {{date}}", + "end_date": "Λήγει: {{date}}", + "opt_in": "Εγγραφή", + "opting_in": "Εγγραφή...", + "opted_in": "Έχετε εγγραφεί σε αυτή την καμπάνια", + "opt_in_error": "Η εγγραφή απέτυχε. Προσπαθήστε ξανά.", + "join_campaign": "Συμμετάσχετε στην καμπάνια", + "checking_opt_in_status": "Έλεγχος κατάστασης εγγραφής", + "swap": "Ανταλλαγή", + "how_it_works": "Πώς λειτουργεί" + }, + "campaigns_preview": { + "title": "Καμπάνιες", + "coming_soon": "Προσεχώς", + "notify_me": "Ειδοποιήστε με" + }, + "earn_rewards": { + "title": "Κερδίστε ανταμοιβές", + "musd_title": "Έως 3% μπόνους σε stables", + "musd_subtitle": "Υπολογίστε το μπόνους σας σε mUSD", + "card_title": "Έως 3% επιστροφή μετρητών", + "card_subtitle": "Αποκτήστε τώρα την MetaMask Card", + "card_subtitle_cardholder": "Δείτε τα προνόμια της MetaMask Card" + }, + "campaigns_view": { + "title": "Καμπάνιες", "active_title": "Ενεργό", "upcoming_title": "Επερχόμενο", "previous_title": "Προηγούμενο", - "empty_state": "Δεν υπάρχουν διαθέσιμα στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", + "empty_state": "Δεν υπάρχουν διαθέσιμες καμπάνιες", + "error_title": "Δεν ήταν δυνατή η φόρτωση των καμπανιών", + "error_description": "Δεν μπορέσαμε να φορτώσουμε τις καμπάνιες. Προσπαθήστε ξανά.", "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." } @@ -7953,13 +8112,12 @@ "continue": "Συνεχίστε" }, "connecting": { - "title": "Συνδέστε τη συσκευή σας {{device}}", + "title": "Σύνδεση με το {{device}}...", "searching": "Αναζήτηση για το {{device}}...", - "tips_header": "Για να συνεχίσετε, βεβαιωθείτε ότι:", + "tips_header": "Βεβαιωθείτε:", "tip_unlock": "Η συσκευή σας {{device}} είναι ξεκλείδωτη", "tip_open_app": "Η εφαρμογή Ethereum είναι ανοιχτή", "tip_enable_bluetooth": "Το Bluetooth είναι ενεργοποιημένο", - "tip_dnd_off": "Η λειτουργία Μην Ενοχλείτε είναι απενεργοποιημένη", "tip_bluetooth_permission": "Η άδεια τοποθεσίας και Bluetooth έχει δοθεί", "tip_bluetooth_permission_v12": "Η άδεια για κοντινές συσκευές έχει δοθεί", "tip_stay_close": "Η συσκευή σας πρέπει να βρίσκεται κοντά στο τηλέφωνό σας" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Απαιτείται άδεια πρόσβασης σε κοντινές συσκευές", "bluetooth_off": "Ενεργοποιήστε το Bluetooth για να συνδεθεί με τη συσκευή σας", "bluetooth_scan_failed": "Δεν ήταν δυνατή η σάρωση συσκευών. Προσπαθήστε ξανά", - "bluetooth_connection_failed": "Ενεργοποιήστε το Bluetooth στη συσκευή σας για να συνεχίσετε", + "bluetooth_connection_failed": "Η σύνδεση με τη συσκευή σας απέτυχε. Προσπαθήστε ξανά", "not_supported": "Αυτή η λειτουργία δεν υποστηρίζεται", "unknown_error": "Βεβαιωθείτε ότι το {{device}} σας έχει ρυθμιστεί με τη Μυστική Φράση Ανάκτησης ή τη φράση πρόσβασης για αυτόν τον λογαριασμό" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Μετρητά", + "cash_empty_description": "Δεν έχετε ακόμη mUSD. Μετατρέψτε τα stablecoins σας σε mUSD από την ενότητα Μετρητά στην αρχική σελίδα.", + "cash_empty_description_network_filter": "Δεν έχετε mUSD σε αυτό το δίκτυο. Αλλάξτε δίκτυο για να δείτε τα mUSD σας.", "tokens": "Token", "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", + "whats_happening": "Τι συμβαίνει", + "whats_happening_categories": { + "geopolitical": "Γεωπολιτικά", + "macro": "Μακροοικονομικά", + "regulatory": "Κανονισμοί", + "technical": "Τεχνικά", + "social": "Κοινωνικά", + "other": "Άλλο" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Εισαγωγή NFT", diff --git a/locales/languages/es.json b/locales/languages/es.json index 0aed1f163aa..3e30408f7e2 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -20,6 +20,12 @@ "update": "Actualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Envío de activos a la dirección de quema" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Advertencia sobre el contrato de tokens", + "message": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia." }, "gas_sponsorship_reserve_balance": { "message": "El patrocinio de gas no está disponible para esta transacción. Deberás mantener al menos %{minBalance} %{nativeTokenSymbol} en tu cuenta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "No se pudo resolver el nombre", "invalid_address": "Dirección no válida", "contractAddressError": "Estás enviando tokens a la dirección del contrato del token. Esto podría resultar en la pérdida de estos tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Dirección del contrato inteligente", + "smart_contract_address_warning": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia.", "i_understand": "Comprendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "El límite de pérdidas debe estar por {{direction}} del precio {{priceType}}", "stop_loss_beyond_liquidation_error": "El límite de pérdidas debe estar por {{direction}} del precio de liquidación", "stop_loss_order_view_warning": "El límite de pérdidas está por {{direction}} del precio de liquidación", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "por encima", "below": "por debajo", "done": "Hecho", @@ -2086,14 +2094,15 @@ "a_closer_look": "Un vistazo más de cerca", "whats_being_said": "Qué se dice", "footer_disclaimer": "Resumen generado por IA solo con fines informativos", - "trade_button": "Operar", + "swap_button": "Canjear", + "buy_button": "Comprar", "sources_count": "+{{count}} fuentes", "sources_title": "Fuentes de noticias", "feedback_submitted": "Comentarios enviados", "helpful_prompt": "¿Te resultó útil?", "feedback": { "title": "Comentarios", - "description": "Ayuda a mejorar nuestra información de mercado generada por IA.", + "description": "Tu respuesta ayuda a mejorar nuestros resúmenes generados por IA.", "not_relevant": "No es relevante", "not_accurate": "No es precisa", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponible", "claim_amount_text": "Reclamar ${{amount}}", "claim_winnings_text": "Reclama tus ganancias", - "claiming_text": "Claiming...", + "claiming_text": "Reclamando...", "unrealized_pnl_label": "P&L no realizado", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "No se puede cargar", @@ -2287,7 +2296,7 @@ "try_again": "Inténtalo de nuevo" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reclamación ya en curso" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Tarifa pagada al cambio o al mercado", "total_incl_fees": "tarifas incl.", "close": "Cerrar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Los precios indicados se basan en que tu orden se ejecute en su totalidad. Los montos reales pueden variar si la orden solo se ejecuta parcialmente.", + "deposit_fee": "Tarifa de depósito", + "deposit_fee_description": "Tarifa que se cobra por depositar fondos en tu saldo de predicción" }, "error": { "title": "No se puede conectar a las predicciones", @@ -3059,6 +3068,7 @@ "networks_no_results": "No se encontraron redes", "network_name_label": "Nombre de la red", "network_name_placeholder": "Nombre de la red (opcional)", + "required": "Requerido", "network_rpc_url_label": "URL de RPC", "network_rpc_name_label": "Nombre de RPC", "network_rpc_placeholder": "Nueva red RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esta función le alerta sobre actividades maliciosas al revisar activamente las solicitudes de transacciones y firmas.", "security_alerts": "Alertas de seguridad", "security_alerts_desc": "Esta función le alerta sobre actividad maliciosa al revisar localmente sus solicitudes de transacción y firma. Haga siempre su propia diligencia debida antes de aprobar cualquier solicitud. No hay garantía de que esta función detecte toda la actividad maliciosa. Al activar esta función, acepta los términos de uso del proveedor.", + "smart_account_dapp_requests_heading": "Solicitudes de cuentas inteligentes desde dapps", + "smart_account_dapp_requests_desc": "Permite que las dapps soliciten funciones de cuentas inteligentes para cuentas estándar. Esto no afectará a las cuentas que ya son cuentas inteligentes.", "smart_transactions_opt_in_heading": "Transacciones inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Active las transacciones inteligentes para realizar transacciones más confiables y seguras en las redes compatibles.", "smart_transactions_learn_more": "Conozca más", @@ -3566,6 +3578,53 @@ "activity": "Actividad de {{symbol}}", "disclaimer": "Los datos de mercado provienen de fuentes externas como CoinGecko. Su uso es meramente informativo. MetaMask no se responsabiliza de su exactitud." }, + "security_trust": { + "title": "Seguridad y confianza", + "malicious": "Malicioso", + "risky": "Riesgoso", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} es un token malicioso. Evita interactuar o realizar operaciones con él.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} opera activamente y goza de amplio reconocimiento. La verificación no implica el respaldo de MetaMask.", + "risky_token_title": "Token riesgoso", + "risky_token_description": "Se detectaron señales de precaución para {{symbol}}. Investiga bien antes de operar con este token.", + "malicious_token_sheet_description": "Se detectaron señales de riesgo grave para {{symbol}}. Te recomendamos no operar con este token.", + "got_it": "Entendido", + "proceed": "Continuar", + "cancel": "Cancelar", + "data_unavailable": "Datos de seguridad no disponibles", + "subtitle_known": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_no_issues": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_suspicious": "Se detectaron señales de precaución. Revisa cuidadosamente los problemas señalados antes de operar con este activo.", + "subtitle_malicious": "Se detectaron señales de riesgo grave. Te recomendamos evitar este activo.", + "subtitle_unavailable": "No se pudo cargar el análisis de seguridad de este token.", + "token_distribution": "Distribución de tokens", + "total_supply": "Suministro total", + "top_10_holders": "Los 10 principales titulares", + "other": "Otro", + "no_hidden_fees_detected": "No se detectaron tarifas ocultas", + "buy_sell_tax": "Impuesto de compra/venta", + "buy_tax": "Impuesto de compra", + "sell_tax": "Impuesto de venta", + "transfer": "Transferir", + "token_info": "Información del token", + "created": "Creado", + "token_age": "Antigüedad del token", + "network": "Red", + "type": "Tipo", + "official_links": "Enlaces oficiales", + "website": "Sitio web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Verificado", + "no_issues": "Sin problemas", + "suspicious": "Sospechoso", + "malicious_label": "Malicioso", + "more": "más", + "evaluation_disclaimer": "Esta revisión de seguridad es solo para fines de evaluación y no constituye un respaldo ni una recomendación para operar." + }, "account_details": { "title": "Detalles de la cuenta", "share_account": "Compartir", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonificación reclamable", "claim_bonus": "Reclamar bono", "claim_bonus_subtitle": "El bono se pagará en {{networkName}}.", + "percentage_bonus_on_linea": "Bono del {{percentage}} % en Linea", + "claim": "Reclamar", + "sounds_good": "Me parece bien", + "claimable_bonus_tooltip_with_percentage": "Bono anualizado del {{percentage}} % que has ganado por mantener mUSD. Puedes reclamar tu bono diariamente en Linea.", "empty_state_cta": { "heading": "Presta {{tokenSymbol}} y gana", "body": "Presta tu {{tokenSymbol}} con {{protocol}} y gana", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Tus monedas estables" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganar", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "No tienes suficiente saldo de recursos para realizar esta acción." }, - "trx_unstaking_in_progress": "Unstaking de {{amount}} n curso. El unstaking tarda 14 días.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaking de {{amount}} TRX en curso", + "description": "El unstaking tarda 14 días" + }, + "unstaked_banner": { + "title": "Unstaking de {{amount}} TRX finalizado", + "description": "Ya puedes retirar tus TRX sin staking", + "button": "Retirar", + "error": "Retiro fallido" + } }, "stake_eth": "Hacer staking de ETH", "unstake_eth": "Dejar de hacer staking de ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprobar la solicitud", "perps_deposit": "Agregar fondos", "predict_deposit": "Añadir fondos de Predicciones", - "predict_withdraw": "Retirar" + "predict_withdraw": "Retirar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este sitio necesita permiso para gastar sus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} de transacción", "transaction": "Transacción", "available_balance": "Saldo disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Agregar fondos", "deposit_edit_amount_predict_withdraw": "Retirar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Las billeteras físicas aún no son compatibles. Usa una billetera en caliente para continuar.", "hardware_wallet_not_supported_solana": "Las billeteras físicas aún no son compatibles con Solana. Usa una billetera en caliente para continuar.", "price_impact_info_title": "Impacto sobre el precio", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Así es como tu operación modifica el precio de mercado de un token. Depende del volumen de la operación, la liquidez disponible y las tarifas del proveedor. MetaMask no controla el impacto en el precio.", "price_impact_info_gasless_description": "El impacto en el precio refleja cómo tu orden de canje afecta el precio de mercado del activo. Si no tienes suficientes fondos para gas, parte de tu token de origen se asigna automáticamente para cubrir las tarifas, lo que aumenta el impacto en el precio. MetaMask no influye ni controla el impacto en el precio.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Debido al volumen de tu operación y a la liquidez disponible, obtendrás aproximadamente {{priceImpact}} menos que el precio de mercado. Esto ya está incluido en tu cotización.", "price_impact_high": "Alto impacto en el precio", "price_impact_execution_description": "Perderás aproximadamente {{priceImpact}} del valor de tu token en este canje. Intenta reducir el monto o elegir una ruta más líquida.", "proceed": "Continuar", @@ -6627,8 +6751,8 @@ "total_cost": "Costo total", "got_it": "Entendido", "price_impact_warning_title": "Alto impacto en el precio", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto muy alto en el precio", + "price_impact_error_description": "Perderás aproximadamente {{priceImpact}} del precio de mercado de tu token en este canje. Prueba con una operación más pequeña o una ruta con mayor liquidez para mejorar tu tasa." }, "quote_expired_modal": { "title": "Hay nuevas cotizaciones disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Actualiza a Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Tarjeta Virtual", "price": "Gratis", "feature_1": "Tarjeta virtual para Apple Pay y Google Pay", "feature_2": "Paga con criptomonedas (USDC, USDT, WETH y más)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Tarjeta Metal", "price": "$199/año", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Todo lo que ofrece la versión virtual, más:", + "feature_1": "Tarjeta metálica grabada premium", + "feature_2": "3 % de cashback en los primeros $10.000/año", "feature_3": "Sin tarifas por transacciones internacionales" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gana hasta $300 en cashback al año", + "upgrade_to_metal_label": "O actualiza a Metal para obtener 3 veces más recompensas" }, "review_order": { "title": "Revisa tu orden", @@ -7104,7 +7228,7 @@ "ssn_description": "Requerido por el emisor de la tarjeta. No se realizará ninguna verificación de crédito.", "invalid_ssn": "SSN no válido", "invalid_date_of_birth": "Fecha de nacimiento no válida. Debes tener al menos 18 años", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "El nombre y apellido deben coincidir con tu identidad verificada" }, "physical_address": { "title": "Agrega tu dirección", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Estás cerca de tu límite de gasto", "description": "Actualiza para evitar rechazos", - "confirm_button_label": "Establecer nuevo límite" + "confirm_button_label": "Establecer nuevo límite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Debes habilitar tu tarjeta", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Límite de gasto actualizado correctamente", "update_error": "No se pudo actualizar el límite de gasto", - "solana_not_supported": "Habilita los tokens de Solana en card.metamask.io", "select_token": "Selecciona un token", "loading": "Cargando tokens disponibles...", "load_error": "No se pueden cargar los tokens. Inténtalo de nuevo.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "No habilitado", "update_success": "Prioridad de gasto actualizada correctamente", - "update_error": "No se pudo actualizar la prioridad de gasto", - "solana_not_supported_button_title": "Otros tokens en Solana", - "solana_not_supported_button_description": "Habilitar en card.metamask.io" + "update_error": "No se pudo actualizar la prioridad de gasto" }, "card_authentication": { "title": "Inicia sesión en tu cuenta de tarjeta", @@ -7443,6 +7565,11 @@ "title": "Error al registrarte", "description": "Comprueba tu conexión y vuelve a intentarlo." }, + "version_guard": { + "title": "Se requiere una actualización", + "description": "Se requiere una versión más reciente de MetaMask para usar Recompensas. Actualiza para continuar.", + "update_button": "Actualizar MetaMask" + }, "season_error": { "error_fetching_title": "No se pudo cargar la temporada", "error_fetching_description": "Comprueba tu conexión y vuelve a intentarlo.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Referidos", "tab_overview_title": "Resumen general", - "tab_snapshots_title": "Instantáneas", "tab_activity_title": "Actividad", "referral_stats_earned_from_referrals": "Ganancias por referidos", "referral_stats_referrals": "Recomendados", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "No ganaste recompensas esta temporada, pero siempre habrá una próxima vez.", "verifying_rewards": "Nos aseguramos de que todo sea correcto antes de que reclames tus recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Puntos ganados" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Potenciadores activos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora del bono en mUSD", + "description": "Descubre cuánto podrías ganar al convertir tus monedas estables a mUSD.", + "amount_label": "Monto convertido", + "estimated_bonus": "Bono anualizado estimado: hasta 3 %", + "initial_amount": "Monto inicial", + "daily_bonus": "Bono diario reclamable", + "annualized_bonus": "Bono anualizado", + "disclaimer": "Esto es solo una estimación. El bono está sujeto a cambios.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Canjear por mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "No se pudo cargar" }, - "snapshot": { + "campaign": { "starts_date": "Empieza el {{date}}", "ends_date": "Termina el {{date}}", - "results_coming_soon": "Resultados próximamente", - "tokens_on_the_way": "Los tokens están llegando", + "ended_date": "Ended {{date}}", "pill_up_next": "A continuación", - "pill_live_now": "En vivo ahora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados listos", - "pill_complete": "Completado" - }, - "snapshots_section": { - "title": "Instantáneas", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", - "retry_button": "Reintentar" - }, - "snapshots_tab": { + "pill_active": "En vivo", + "pill_complete": "Completado", + "enter_now": "Participa ahora", + "entered": "Ya participas", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Únete a la campaña", + "opt_in_sheet_description_pre_link": "Al hacer clic en \"Participar\", aceptas las Recompensas de MetaMask", + "opt_in_sheet_link_text": "Términos de uso complementarios y aviso de privacidad", + "opt_in_sheet_description_post_link": "Realizaremos un seguimiento de la actividad en la cadena para recompensarte automáticamente.", + "geo_restriction_banner_title": "No está disponible en tu región", + "geo_restriction_banner_description": "Esta campaña no está disponible en tu región debido a regulaciones locales." + }, + "campaign_mechanics": { + "title": "Mecánica" + }, + "campaign_details": { + "start_date": "Inicia: {{date}}", + "end_date": "Finaliza: {{date}}", + "opt_in": "Participar", + "opting_in": "Activando participación...", + "opted_in": "Ya estás participando en esta campaña", + "opt_in_error": "Error al activar la participación. Inténtalo de nuevo.", + "join_campaign": "Únete a la campaña", + "checking_opt_in_status": "Comprobando estado de participación", + "swap": "Canjear", + "how_it_works": "Cómo funciona" + }, + "campaigns_preview": { + "title": "Campañas", + "coming_soon": "Próximamente", + "notify_me": "Notificarme" + }, + "earn_rewards": { + "title": "Gana recompensas", + "musd_title": "Bono de hasta el 3 % en monedas estables", + "musd_subtitle": "Calcula tu bono en mUSD", + "card_title": "Hasta 3% de cashback", + "card_subtitle": "Obtén tu tarjeta MetaMask ahora", + "card_subtitle_cardholder": "Accede a los beneficios de tu tarjeta MetaMask" + }, + "campaigns_view": { + "title": "Campañas", "active_title": "Activa", "upcoming_title": "Próxima", "previous_title": "Previa", - "empty_state": "No hay instantáneas disponibles", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", + "empty_state": "No hay campañas disponibles", + "error_title": "No se pueden cargar las campañas", + "error_description": "No pudimos cargar las campañas. Inténtalo de nuevo.", "retry_button": "Reintentar", "refreshing": "Actualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecta tu {{device}}", + "title": "Conectando tu {{device}}...", "searching": "Buscando {{device}}...", - "tips_header": "Para continuar, asegúrate de que:", + "tips_header": "Asegúrate de que:", "tip_unlock": "Tu {{device}} esté desbloqueado", "tip_open_app": "La aplicación de Ethereum esté abierta", "tip_enable_bluetooth": "El Bluetooth esté activado", - "tip_dnd_off": "El modo \"No molestar\" esté desactivado", "tip_bluetooth_permission": "Se hayan concedido permisos de ubicación y Bluetooth", "tip_bluetooth_permission_v12": "Se haya concedido permiso para dispositivos cercanos", "tip_stay_close": "Tu dispositivo se mantenga cerca de tu teléfono" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Se requiere permiso para dispositivos cercanos", "bluetooth_off": "Activa el Bluetooth para conectar tu dispositivo", "bluetooth_scan_failed": "Error al buscar dispositivos. Inténtalo de nuevo", - "bluetooth_connection_failed": "Activa el Bluetooth en tu dispositivo para continuar", + "bluetooth_connection_failed": "Falló la conexión con tu dispositivo. Inténtalo de nuevo", "not_supported": "No se admite esta operación", "unknown_error": "Asegúrate de que tu {{device}} esté configurado con la frase secreta de recuperación o la frase de contraseña de esta cuenta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Efectivo", + "cash_empty_description": "Aún no tienes mUSD. Convierte monedas estables a mUSD desde la sección Efectivo en la página de inicio.", + "cash_empty_description_network_filter": "No hay mUSD en esta red. Cambia de red para ver tus mUSD.", "tokens": "Tokens", "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", + "whats_happening": "Qué está pasando", + "whats_happening_categories": { + "geopolitical": "Geopolítico", + "macro": "Macro", + "regulatory": "Regulatorio", + "technical": "Técnico", + "social": "Social", + "other": "Otro" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importar NFT", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index edebc02df75..4771a1895c8 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -20,6 +20,12 @@ "update": "Mettre à jour" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerte", @@ -120,8 +126,8 @@ "title": "Envoi d’actifs vers une adresse de destruction" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Avertissement concernant le contrat de jetons", + "message": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert." }, "gas_sponsorship_reserve_balance": { "message": "Le parrainage de gaz n’est pas disponible pour cette transaction. Vous devrez conserver au moins %{minBalance} %{nativeTokenSymbol} sur votre compte.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Impossible de résoudre le nom", "invalid_address": "Adresse non valide", "contractAddressError": "Vous envoyez des jetons à l’adresse du contrat du jeton. Cela peut entraîner la perte de ces jetons.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Adresse du contrat intelligent", + "smart_contract_address_warning": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert.", "i_understand": "Je comprends", "cancel": "Annuler" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Le stop loss doit être {{direction}} au prix {{priceType}}", "stop_loss_beyond_liquidation_error": "Le stop loss doit être {{direction}} au prix de liquidation", "stop_loss_order_view_warning": "Le stop loss est {{direction}} au prix de liquidation", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "supérieur", "below": "inférieur", "done": "Terminé", @@ -2086,14 +2094,15 @@ "a_closer_look": "Regarder de plus près", "whats_being_said": "Ce qu’on en dit", "footer_disclaimer": "Résumé généré par l’IA fourni à titre informatif uniquement", - "trade_button": "Trader", + "swap_button": "Échanger", + "buy_button": "Acheter", "sources_count": "+{{count}} sources", "sources_title": "Sources d’information", "feedback_submitted": "Commentaire soumis", "helpful_prompt": "Cela vous a-t-il été utile ?", "feedback": { "title": "Commentaires", - "description": "Aidez-nous à améliorer nos analyses de marché générées par l’IA.", + "description": "Votre réponse nous aidera à améliorer nos résumés générés par l’IA.", "not_relevant": "Pas pertinent", "not_accurate": "Inexact", "hard_to_understand": "Difficile à comprendre", @@ -2206,7 +2215,7 @@ "available_balance": "Solde disponible", "claim_amount_text": "Réclamer {{amount}} $", "claim_winnings_text": "Réclamer vos gains", - "claiming_text": "Claiming...", + "claiming_text": "En cours de réclamation…", "unrealized_pnl_label": "Profits et pertes non réalisés", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Impossible de charger", @@ -2287,7 +2296,7 @@ "try_again": "Réessayez" }, "in_progress": { - "title": "Claim already in progress" + "title": "Réclamation déjà en cours" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Frais payés à la bourse ou au marché", "total_incl_fees": "frais inclus", "close": "Fermer", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Les prix indiqués sont basés sur une exécution entière de l’ordre. Les montants réels peuvent varier si l’ordre n’est que partiellement exécuté.", + "deposit_fee": "Frais de dépôt", + "deposit_fee_description": "Frais facturés pour déposer des fonds sur votre solde de prédiction" }, "error": { "title": "Impossible de se connecter aux marchés de prédiction", @@ -3059,6 +3068,7 @@ "networks_no_results": "Aucun réseau trouvé", "network_name_label": "Nom du réseau", "network_name_placeholder": "Nom du réseau (facultatif)", + "required": "Requis", "network_rpc_url_label": "URL de l’appel de procédure à distance", "network_rpc_name_label": "Nom du RPC", "network_rpc_placeholder": "Nouveau réseau RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant activement les demandes de transaction et de signature.", "security_alerts": "Alertes de sécurité", "security_alerts_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant localement vos demandes de transaction et de signature. Vous devez faire preuve de diligence raisonnable avant d’approuver toute demande. Rien ne garantit que toutes les activités malveillantes seront détectées par cette fonctionnalité. En l’activant, vous acceptez les conditions d’utilisation du fournisseur.", + "smart_account_dapp_requests_heading": "Demandes de comptes intelligents émanant de dapps", + "smart_account_dapp_requests_desc": "Autoriser les dapps à demander des fonctionnalités de compte intelligent pour les comptes standards. Cela n’affectera pas les comptes qui sont déjà des comptes intelligents.", "smart_transactions_opt_in_heading": "Transactions intelligentes", "smart_transactions_opt_in_desc_supported_networks": "Activez les transactions intelligentes pour profiter de transactions plus fiables et plus sûres sur les réseaux pris en charge.", "smart_transactions_learn_more": "En savoir plus", @@ -3566,6 +3578,53 @@ "activity": "Activité du {{symbol}}", "disclaimer": "Les données du marché sont fournies par des sources tierces telles que CoinGecko. Ces données sont fournies à titre d’information uniquement. MetaMask n’est pas responsable de leur exactitude." }, + "security_trust": { + "title": "Sécurité et confiance", + "malicious": "Malveillant", + "risky": "À risque", + "malicious_token_title": "Jeton malveillant", + "malicious_token_description": "{{symbol}} est un jeton malveillant. Évitez toute interaction avec ce jeton ou de le négocier.", + "verified_token_title": "Jeton vérifié", + "verified_token_description": "{{symbol}} fait l’objet d’un trading actif et est largement reconnu. Cette vérification ne constitue pas une recommandation de la part de MetaMask.", + "risky_token_title": "Jeton à risque", + "risky_token_description": "Signaux d’alerte détectés concernant le jeton {{symbol}}. Renseignez-vous soigneusement avant de négocier ce jeton.", + "malicious_token_sheet_description": "De sérieux signaux d’alerte ont été détectés concernant le jeton {{symbol}}. Nous vous recommandons de ne pas négocier ce jeton.", + "got_it": "J’ai compris", + "proceed": "Continuer", + "cancel": "Annuler", + "data_unavailable": "Données de sécurité non disponibles", + "subtitle_known": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_no_issues": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_suspicious": "Signaux d’alerte détectés. Examinez attentivement les problèmes signalés avant de négocier cet actif.", + "subtitle_malicious": "De sérieux signaux d’alerte ont été détectés. Nous vous recommandons d’éviter cet actif.", + "subtitle_unavailable": "L’analyse de sécurité n’a pas pu être chargée pour ce jeton.", + "token_distribution": "Répartition des jetons", + "total_supply": "Offre totale", + "top_10_holders": "Les 10 principaux détenteurs", + "other": "Autre", + "no_hidden_fees_detected": "Aucuns frais cachés détectés", + "buy_sell_tax": "Taxe à l’achat/à la vente", + "buy_tax": "Taxe à l’achat", + "sell_tax": "Taxe à la vente", + "transfer": "Transférer", + "token_info": "Informations sur le jeton", + "created": "Créé", + "token_age": "Âge du jeton", + "network": "Réseau", + "type": "Type", + "official_links": "Liens officiels", + "website": "Site web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "S.O.", + "verified": "Vérifié", + "no_issues": "Aucun problème", + "suspicious": "Suspect", + "malicious_label": "Malveillant", + "more": "plus", + "evaluation_disclaimer": "Cette analyse de sécurité est fournie à titre indicatif uniquement et ne constitue ni une recommandation ni une incitation à négocier ce jeton." + }, "account_details": { "title": "Détails du compte", "share_account": "Partager", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus réclamable", "claim_bonus": "Réclamer le bonus", "claim_bonus_subtitle": "Le bonus sera versé sur {{networkName}}.", + "percentage_bonus_on_linea": "Bonus de {{percentage}} % sur Linea", + "claim": "Réclamer", + "sounds_good": "Ça a l’air intéressant", + "claimable_bonus_tooltip_with_percentage": "Bonus annualisé de {{percentage}} % que vous avez gagné en détenant des mUSD. Vous pouvez réclamer votre bonus quotidiennement sur Linea.", "empty_state_cta": { "heading": "Prêtez des {{tokenSymbol}} et gagnez", "body": "Prêtez vos {{tokenSymbol}} avec {{protocol}} et touchez un revenu", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Vos stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Gagner", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Vous ne disposez pas d’un solde suffisant pour effectuer cette action." }, - "trx_unstaking_in_progress": "Déstaking de {{amount}} TRX en cours. Le déstaking prend 14 jours.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Déstaking de {{amount}} TRX en cours", + "description": "Le déstaking prendra 14 jours" + }, + "unstaked_banner": { + "title": "Déstaking de {{amount}} TRX terminé", + "description": "Vous pouvez désormais retirer vos TRX déstakés", + "button": "Retirer", + "error": "Le retrait a échoué" + } }, "stake_eth": "Staker des ETH", "unstake_eth": "Déstaker des ETH", @@ -6376,7 +6498,8 @@ "approve": "Approuver la demande", "perps_deposit": "Ajouter des fonds", "predict_deposit": "Ajouter des fonds de prédiction", - "predict_withdraw": "Retirer" + "predict_withdraw": "Retirer", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Ce site demande l’autorisation de dépenser vos jetons.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaction {{index}}", "transaction": "Protection des", "available_balance": "Solde disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuer", "deposit_edit_amount_done": "Ajouter des fonds", "deposit_edit_amount_predict_withdraw": "Retirer", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Les portefeuilles matériels ne sont pas encore pris en charge. Utilisez un portefeuille connecté pour continuer.", "hardware_wallet_not_supported_solana": "Les portefeuilles matériels ne sont pas encore pris en charge pour Solana. Utilisez un portefeuille connecté pour continuer.", "price_impact_info_title": "Impact sur les prix", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Voici comment votre transaction modifie le prix de marché d’un jeton. Cela dépend de la taille de la transaction, de la liquidité disponible et des frais facturés par le fournisseur. MetaMask n’exerce aucun contrôle sur cet impact.", "price_impact_info_gasless_description": "L’impact sur le prix reflète la manière dont votre ordre de swap affecte le prix de l’actif sur le marché. Si vous ne disposez pas de fonds suffisants pour payer les frais de gaz, une partie de votre jeton source est automatiquement allouée pour couvrir ces frais, ce qui augmente l’impact sur le prix. MetaMask n’influence ni ne contrôle l’impact sur le prix.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "En raison de la taille de votre transaction et de la liquidité disponible, vous obtiendrez environ {{priceImpact}} de moins que le prix de marché. Cette différence de prix est déjà reflétée dans votre devis.", "price_impact_high": "Impact élevé sur le prix", "price_impact_execution_description": "Vous perdrez environ {{priceImpact}} de la valeur de vos jetons lors de cet échange. Essayez de réduire le montant ou de choisir une voie plus liquide.", "proceed": "Continuer", @@ -6627,8 +6751,8 @@ "total_cost": "Coût total", "got_it": "J’ai compris", "price_impact_warning_title": "Impact élevé sur le prix", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impact très élevé sur le prix", + "price_impact_error_description": "En effectuant cet échange, vous perdrez environ {{priceImpact}} du prix de marché de votre jeton. Essayez un montant plus petit ou une voie plus liquide pour améliorer votre taux de change." }, "quote_expired_modal": { "title": "De nouvelles cotations sont disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Passer à Metal", "continue_button": "Continuer", "virtual_card": { - "name": "Virtual Card", + "name": "Carte virtuelle", "price": "Gratuit", "feature_1": "Carte virtuelle pour Apple Pay et Google Pay", "feature_2": "Payer avec des cryptomonnaies (USDC, USDT, WETH, etc.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Carte Metal", "price": "199 $/an", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tout ce qu’offre la carte virtuelle, avec en plus :", + "feature_1": "Une carte métallique gravée haut de gamme", + "feature_2": "3 % de cashback sur les premiers 10 000 $ que vous dépensez par an", "feature_3": "Pas de frais de transaction à l’étranger" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gagnez jusqu’à 300 $ de cashback par an", + "upgrade_to_metal_label": "Ou passez à la carte Metal pour tripler vos récompenses" }, "review_order": { "title": "Vérifiez votre commande", @@ -7104,7 +7228,7 @@ "ssn_description": "Requis par l’émetteur de la carte. Aucune vérification de solvabilité ne sera effectuée.", "invalid_ssn": "Numéro de sécurité sociale non valide", "invalid_date_of_birth": "Date de naissance non valide. Vous devez être âgé d’au moins 18 ans", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Veuillez saisir votre nom et votre prénom tels qu’ils figurent sur la pièce d’identité que vous avez fournie pour la vérification de votre identité" }, "physical_address": { "title": "Ajoutez votre adresse", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Vous avez presque atteint votre limite de dépenses", "description": "Veuillez modifier votre limite de dépenses pour éviter tout refus de paiement", - "confirm_button_label": "Fixer une nouvelle limite" + "confirm_button_label": "Fixer une nouvelle limite", + "dismiss_button_label": "Ignorer" }, "need_delegation": { "title": "Vous devez activer votre carte", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorer", "update_success": "Limite de dépenses mise à jour avec succès", "update_error": "Échec de la mise à jour de la limite de dépenses", - "solana_not_supported": "Activez les jetons Solana sur card.metamask.io", "select_token": "Sélectionnez un jeton", "loading": "Chargement des jetons disponibles…", "load_error": "Impossible de charger les jetons. Veuillez réessayer.", @@ -7343,9 +7467,7 @@ "limited": "Limité", "not_enabled": "Non activé", "update_success": "Priorité de dépense mise à jour avec succès", - "update_error": "Échec de la mise à jour de la priorité de dépense", - "solana_not_supported_button_title": "Autres jetons sur Solana", - "solana_not_supported_button_description": "Activer sur card.metamask.io" + "update_error": "Échec de la mise à jour de la priorité de dépense" }, "card_authentication": { "title": "Connectez-vous à votre compte Card", @@ -7443,6 +7565,11 @@ "title": "Échec de l’inscription", "description": "Vérifiez votre connexion et réessayez." }, + "version_guard": { + "title": "Mise à jour requise", + "description": "Une version plus récente de MetaMask est nécessaire pour utiliser « Récompenses ». Veuillez effectuer la mise à jour pour continuer.", + "update_button": "Mettre à jour MetaMask" + }, "season_error": { "error_fetching_title": "Impossible de charger les informations sur la période des récompenses", "error_fetching_description": "Vérifiez votre connexion et réessayez.", @@ -7525,7 +7652,6 @@ "main_title": "Récompenses", "referral_title": "Parrainages", "tab_overview_title": "Aperçu", - "tab_snapshots_title": "Snapshots", "tab_activity_title": "Activité", "referral_stats_earned_from_referrals": "Gagnés grâce aux parrainages", "referral_stats_referrals": "Parrainages", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Vous n’avez pas gagné de récompenses cette saison, mais il y aura toujours une prochaine fois.", "verifying_rewards": "Nous nous assurons que tout est correct avant que vous ne réclamiez vos récompenses." }, + "previous_season_view": { + "title": "Saison précédente" + }, "season_status": { "points_earned": "Points gagnés" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Boosts actifs", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculateur du bonus en mUSD", + "description": "Découvrez combien vous pourriez gagner en convertissant vos stablecoins en mUSD.", + "amount_label": "Montant converti", + "estimated_bonus": "Bonus annualisé estimé : jusqu’à 3 %", + "initial_amount": "Montant initial", + "daily_bonus": "Bonus quotidien pouvant être réclamé", + "annualized_bonus": "Bonus annualisé", + "disclaimer": "Il ne s’agit que d’une estimation. Le montant du bonus peut varier.", "buy_button": "Acheter des mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Échanger en mUSD" }, "upcoming_rewards": { "title": "Récompenses verrouillées", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Impossible de charger" }, - "snapshot": { + "campaign": { "starts_date": "Débute le {{date}}", "ends_date": "Se termine le {{date}}", - "results_coming_soon": "Résultats bientôt disponibles", - "tokens_on_the_way": "Jetons en cours d’envoi", + "ended_date": "Ended {{date}}", "pill_up_next": "À venir", - "pill_live_now": "Disponible dès maintenant", - "pill_calculating": "Calcul en cours", - "pill_results_ready": "Résultats disponibles", - "pill_complete": "Terminé" - }, - "snapshots_section": { - "title": "Snapshots", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", - "retry_button": "Réessayer" - }, - "snapshots_tab": { + "pill_active": "Active", + "pill_complete": "Terminé", + "enter_now": "S’inscrire maintenant", + "entered": "Déjà inscrit", + "participant_count": "n° {{count}}", + "opt_in_cta": "S’inscrire", + "opt_in_sheet_title": "Rejoindre la campagne", + "opt_in_sheet_description_pre_link": "En cliquant sur « S’inscrire », vous acceptez les conditions générales du programme de fidélité « Récompenses MetaMask »", + "opt_in_sheet_link_text": "Conditions d’utilisation supplémentaires et avis de confidentialité de MetaMask Rewards", + "opt_in_sheet_description_post_link": "Nous suivrons votre activité sur la chaîne pour vous récompenser automatiquement.", + "geo_restriction_banner_title": "Non disponible dans votre région", + "geo_restriction_banner_description": "Cette campagne n’est pas disponible dans votre région en raison de la réglementation locale." + }, + "campaign_mechanics": { + "title": "Déroulement" + }, + "campaign_details": { + "start_date": "Débute le : {{date}}", + "end_date": "Se termine le : {{date}}", + "opt_in": "S’inscrire", + "opting_in": "Inscription en cours…", + "opted_in": "Vous êtes inscrit à cette campagne", + "opt_in_error": "Échec de l’inscription. Veuillez réessayer.", + "join_campaign": "Rejoindre la campagne", + "checking_opt_in_status": "Vérification du statut d’inscription", + "swap": "Échanger", + "how_it_works": "Comment ça marche " + }, + "campaigns_preview": { + "title": "Campagnes", + "coming_soon": "Bientôt disponible", + "notify_me": "M’avertir" + }, + "earn_rewards": { + "title": "Gagner des récompenses", + "musd_title": "Jusqu’à 3 % de bonus sur les stablecoins", + "musd_subtitle": "Calculer votre bonus en mUSD", + "card_title": "Jusqu’à 3 % de cashback", + "card_subtitle": "Obtenez votre carte MetaMask Card dès maintenant", + "card_subtitle_cardholder": "Accédez aux avantages de votre carte MetaMask Card" + }, + "campaigns_view": { + "title": "Campagnes", "active_title": "Actif", "upcoming_title": "À venir", "previous_title": "Précédent", - "empty_state": "Aucun snapshot disponible", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", + "empty_state": "Aucune campagne disponible", + "error_title": "Impossible de charger les campagnes", + "error_description": "Nous n’avons pas pu charger les campagnes. Veuillez réessayer.", "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" } @@ -7953,13 +8112,12 @@ "continue": "Continuer" }, "connecting": { - "title": "Connectez votre {{device}}", + "title": "Connexion de votre {{device}}…", "searching": "Recherche de {{device}}…", - "tips_header": "Pour continuer, assurez-vous que :", + "tips_header": "Assurez-vous que :", "tip_unlock": "Votre {{device}} est déverrouillé", "tip_open_app": "L’application Ethereum est ouverte", "tip_enable_bluetooth": "Le Bluetooth est activé", - "tip_dnd_off": "Le mode « Ne pas déranger » est désactivé", "tip_bluetooth_permission": "Les autorisations de localisation et de connexion Bluetooth sont accordées", "tip_bluetooth_permission_v12": "L’autorisation d’accès aux appareils à proximité est accordée", "tip_stay_close": "Votre appareil reste à proximité de votre téléphone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "L’autorisation d’accès aux appareils à proximité est requise", "bluetooth_off": "Veuillez activer le Bluetooth pour établir une connexion avec votre appareil", "bluetooth_scan_failed": "Échec de la recherche d’appareils. Veuillez réessayer", - "bluetooth_connection_failed": "Activez le Bluetooth sur votre appareil pour continuer", + "bluetooth_connection_failed": "La connexion à votre appareil a échoué. Veuillez réessayer", "not_supported": "Cette opération n’est pas prise en charge", "unknown_error": "Assurez-vous que votre {{device}} est configuré avec la phrase de récupération secrète ou la phrase secrète de ce compte" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Liquidités", + "cash_empty_description": "Vous n’avez pas encore de mUSD. Convertissez vos stablecoins en mUSD depuis la section « Liquidités » de la page d’accueil.", + "cash_empty_description_network_filter": "Pas de mUSD sur ce réseau. Changez de réseau pour consulter votre solde de mUSD.", "tokens": "Jetons", "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", + "whats_happening": "Actualités", + "whats_happening_categories": { + "geopolitical": "Géopolitique", + "macro": "Macroéconomie", + "regulatory": "Réglementation", + "technical": "Technique", + "social": "Réseaux sociaux", + "other": "Autre" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importer des NFT", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 363fd6273cf..c99f36605fa 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -20,6 +20,12 @@ "update": "अपडेट करें" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "एलर्ट", @@ -120,8 +126,8 @@ "title": "एसेट्स को बर्न एड्रेस पर भेजना" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "टोकन कॉन्ट्रैक्ट वार्निंग", + "message": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।" }, "gas_sponsorship_reserve_balance": { "message": "इस ट्रांसेक्शन के लिए गैस स्पॉन्सरशिप उपलब्ध नहीं है। आपको अपने अकाउंट में कम से कम %{minBalance} %{nativeTokenSymbol} रखना होगा।", @@ -694,8 +700,8 @@ "could_not_resolve_name": "नाम हल नहीं किया जा सका", "invalid_address": "एड्रेस ग़लत है", "contractAddressError": "आप टोकन को टोकन के कॉन्ट्रैक्ट एड्रेस पर भेज रहे हैं। इससे इन टोकन को खोने की संभावना है।", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "स्मार्ट कॉन्ट्रैक्ट एड्रेस", + "smart_contract_address_warning": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।", "i_understand": "मैं समझता हूं", "cancel": "कैंसिल करें" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "स्टॉप लॉस {{direction}} {{priceType}} प्राइस का होना चाहिए", "stop_loss_beyond_liquidation_error": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का होना चाहिए", "stop_loss_order_view_warning": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का है", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "ऊपर", "below": "नीचे", "done": "पूरा हुआ", @@ -2086,14 +2094,15 @@ "a_closer_look": "एक करीबी निगाह", "whats_being_said": "क्या कहा जा रहा है", "footer_disclaimer": "AI सारांश केवल जानकारी के लिए है", - "trade_button": "ट्रेड करें", + "swap_button": "स्वैप करें", + "buy_button": "खरीदें", "sources_count": "+{{count}} सोर्स", "sources_title": "समाचार सूत्र", "feedback_submitted": "फीडबैक सबमिट किया गया", "helpful_prompt": "क्या यह सहायक था?", "feedback": { "title": "फीडबैक", - "description": "हमारे AI-जनित मार्केट इनसाइट्स को बेहतर बनाने में सहायता करें।", + "description": "आपका जवाब हमारी AI समरी को बेहतर बनाने में मदद करता है।", "not_relevant": "संबंधित नहीं", "not_accurate": "सही नहीं", "hard_to_understand": "समझने में मुश्किल", @@ -2162,7 +2171,7 @@ "sell_position": "सैल पोज़िशन", "cash_out": "कैश आउट करें", "cash_out_info": "फ़ंड आपके उपलब्ध बैलेंस में जोड़े जाएंगे", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} पर {{outcome}}", "at_price_per_share": "{{size}} शेयरों को {{price}} पर बेचा जा रहा है", "cashout_info": "{{initialPrice}} पर {{outcome}} पर {{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} पर {{amount}} • {{initialPrice}} पर {{outcome}}", @@ -2206,7 +2215,7 @@ "available_balance": "उपलब्ध बैलेंस", "claim_amount_text": "${{amount}} क्लेम करें", "claim_winnings_text": "जीत का ईनाम क्लेम करें", - "claiming_text": "Claiming...", + "claiming_text": "क्लेम किया जा रहा है...", "unrealized_pnl_label": "अनरियलाइज्ड P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "लोड करने में असमर्थ", @@ -2287,7 +2296,7 @@ "try_again": "फिर से प्रयास करें" }, "in_progress": { - "title": "Claim already in progress" + "title": "क्लेम प्रोग्रेस में है" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "एक्सचेंज या मार्केट को दिया गया शुल्क", "total_incl_fees": "शुल्क सहित", "close": "बंद करें", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "दिखाई गई कीमतें यह मानकर चलती हैं कि आपका ऑर्डर पूरी तरह से भरा हुआ है। अगर ऑर्डर थोड़ा भरा हुआ है, तो असल रकम अलग हो सकती है।", + "deposit_fee": "डिपॉज़िट फीस", + "deposit_fee_description": "आपके प्रेडिक्शन बैलेंस में फंड डिपॉज़िट करने के लिए फीस ली जाती है" }, "error": { "title": "प्रीडिक्शंस से कनेक्ट नहीं हो पाया", @@ -3059,6 +3068,7 @@ "networks_no_results": "कोई नेटवर्क नहीं मिला", "network_name_label": "नेटवर्क का नाम", "network_name_placeholder": "नेटवर्क का नाम (वैकल्पिक)", + "required": "जरुरी", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC का नाम", "network_rpc_placeholder": "नया RPC नेटवर्क", @@ -3298,6 +3308,8 @@ "blockaid_desc": "यह फीचर सक्रिय रूप से ट्रांसेक्शन और सिग्नेचर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है।", "security_alerts": "सुरक्षा चेतावनियाँ", "security_alerts_desc": "यह सुविधा स्थानीय रूप से आपके ट्रांसेक्शन और हस्ताक्षर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है। किसी भी अनुरोध को मंजूरी देने से पहले हमेशा पूरी जांच-पड़ताल ज़रूर करें। इस बात की कोई गारंटी नहीं है कि यह सुविधा सभी बुरी नीयत वाली गतिविधि का पता लगा लेगी। इस सुविधा को सक्षम करके आप प्रदाता की उपयोग की शर्तों से सहमत होते हैं।", + "smart_account_dapp_requests_heading": "dapps से स्मार्ट अकाउंट रिक्वेस्ट", + "smart_account_dapp_requests_desc": "dapps को स्टैंडर्ड अकाउंट के लिए स्मार्ट अकाउंट फ़ीचर रिक्वेस्ट करने दें। इससे उन अकाउंट पर कोई असर नहीं पड़ेगा जो पहले से ही स्मार्ट अकाउंट हैं।", "smart_transactions_opt_in_heading": "स्मार्ट ट्रांसेक्शन", "smart_transactions_opt_in_desc_supported_networks": "समर्थित नेटवर्क पर अधिक विश्वसनीय और सुरक्षित ट्रांसेक्शन के लिए स्मार्ट ट्रांसेक्शन चालू करें।", "smart_transactions_learn_more": "ज़्यादा जानें", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} गतिविधि", "disclaimer": "मार्केट डेटा CoinGecko जैसे थर्ड-पार्टी सोर्स से मिलता है। डेटा सिर्फ़ जानकारी के लिए है। इसकी सटीकता के लिए MetaMask ज़िम्मेदार नहीं है।" }, + "security_trust": { + "title": "सुरक्षा और भरोसा", + "malicious": "बुरी नीयत वाला", + "risky": "जोखिम से भरा", + "malicious_token_title": "बुरी नीयत वाला टोकन", + "malicious_token_description": "{{symbol}} एक बुरी नीयत वाला टोकन है। इसके साथ इंटरैक्ट करने या इसे ट्रेड करने से बचें।", + "verified_token_title": "सत्यापित टोकन", + "verified_token_description": "{{symbol}} एक्टिवली ट्रेड किया जाता है और यह काफ़ी जाना-माना है। वेरिफ़िकेशन को MetaMask मेटामास्क द्वारा समर्थन नहीं है।", + "risky_token_title": "जोखिम भरा टोकन", + "risky_token_description": "{{symbol}} पर चेतावनी के संकेत मिले हैं। इस टोकन में ट्रेड करने से पहले ध्यान से रिसर्च करें।", + "malicious_token_sheet_description": "{{symbol}} पर गंभीर जोखिम के संकेत मिले हैं। हम इस टोकन में ट्रेड न करने की सलाह देते हैं।", + "got_it": "समझ गए", + "proceed": "आगे बढ़ें", + "cancel": "कैंसिल करें", + "data_unavailable": "सुरक्षा डेटा उपलब्ध नहीं है", + "subtitle_known": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_no_issues": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_suspicious": "चेतावनी के संकेत मिले हैं। इस एसेट में ट्रेड करने से पहले फ़्लैग किए गए मुद्दों को ध्यान से देखें।", + "subtitle_malicious": "गंभीर जोखिम के संकेत मिले हैं। हम इस एसेट से बचने की सलाह देते हैं।", + "subtitle_unavailable": "इस टोकन के लिए सिक्योरिटी एनालिसिस लोड नहीं किया जा सका।", + "token_distribution": "टोकन डिस्ट्रीब्यूशन", + "total_supply": "कुल आपूर्ति", + "top_10_holders": "टॉप 10 होल्डर", + "other": "अन्य", + "no_hidden_fees_detected": "कोई छिपी हुई फ़ीस नहीं दिखी", + "buy_sell_tax": "टैक्स खरीदें/बेचें", + "buy_tax": "टैक्स खरीदें", + "sell_tax": "टैक्स बेचें", + "transfer": "स्थानांतरण", + "token_info": "टोकन जानकारी", + "created": "बनाया गया", + "token_age": "टोकन की उम्र", + "network": "नेटवर्क", + "type": "यह पुष्टि करने के लिए टाइप करें", + "official_links": "ऑफिशियल लिंक", + "website": "वेबसाइट", + "twitter_x": "ट्विटर", + "telegram": "टेलीग्राम", + "etherscan": "Etherscan", + "na": "लागू नहीं", + "verified": "वेरीफाई किया गया", + "no_issues": "कोई समस्या नहीं", + "suspicious": "संदिग्ध", + "malicious_label": "बुरी नीयत वाला", + "more": "अधिक", + "evaluation_disclaimer": "यह सिक्योरिटी रिव्यू सिर्फ़ इवैल्यूएशन के लिए है और यह ट्रेड के लिए एंडोर्समेंट या रिकमेंडेशन नहीं है।" + }, "account_details": { "title": "अकाउंट का विवरण", "share_account": "साझा करें", @@ -5934,6 +5993,10 @@ "claimable_bonus": "क्लेम करने योग्य बोनस", "claim_bonus": "बोनस क्लेम करें", "claim_bonus_subtitle": "बोनस {{networkName}} पर दिया जाएगा।", + "percentage_bonus_on_linea": "Linea पर {{percentage}}% बोनस", + "claim": "क्लेम करें", + "sounds_good": "सही लगता है", + "claimable_bonus_tooltip_with_percentage": "mUSD होल्ड करने पर आपको मिला {{percentage}}% सालाना बोनस। आपका बोनस Linea पर रोज़ाना क्लेम किया जा सकता है।", "empty_state_cta": { "heading": "{{tokenSymbol}} उधार दें और कमाएं", "body": "{{protocol}} के साथ अपना {{tokenSymbol}} उधार दें और सालाना", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "आपके स्टेबलकॉइन" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "कमाएं", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "आपके पास यह काम करने के लिए पर्याप्त रिसोर्स बैलेंस नहीं है।" }, - "trx_unstaking_in_progress": "{{amount}} TRX का अनस्टेकिंग जारी है। अनस्टेकिंग में 14 दिन लगते हैं।", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग प्रोग्रेस में है", + "description": "अनस्टेकिंग में 14 दिन लगेंगे" + }, + "unstaked_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग पूरी हुई", + "description": "आपका अनस्टेक्ड TRX अब विदड्रॉ किया जा सकता है", + "button": "निकालें", + "error": "विदड्रॉवल नहीं हो पाया" + } }, "stake_eth": "ETH स्टेक करें", "unstake_eth": "ETH अनस्टेक करें", @@ -6376,7 +6498,8 @@ "approve": "अनुरोध एप्रूव करें", "perps_deposit": "फंड जोड़ें", "predict_deposit": "प्रिडिक्शन फ़ंड जोड़ें", - "predict_withdraw": "निकालें" + "predict_withdraw": "निकालें", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "ट्रांसेक्शन {{index}}", "transaction": "ट्रांसेक्शन", "available_balance": "उपलब्ध बैलेंस: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "जारी रखें", "deposit_edit_amount_done": "फंड जोड़ें", "deposit_edit_amount_predict_withdraw": "निकालें", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "hardware_wallet_not_supported_solana": "Solana के लिए हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "price_impact_info_title": "कीमत का प्रभाव", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "इस तरह आपका ट्रेड किसी टोकन की मार्केट प्राइस बदलता है। यह ट्रेड साइज़, उपलब्ध लिक्विडिटी और प्रोवाइडर फीस पर निर्भर करता है। MetaMask प्राइस इम्पैक्ट को कंट्रोल नहीं करता है।", "price_impact_info_gasless_description": "प्राइस इम्पैक्ट यह दर्शाता है कि आपका स्वैप ऑर्डर एसेट की मार्केट प्राइस को कैसे प्रभावित करता है। अगर आपके पास गैस के लिए पर्याप्त फंड नहीं हैं, तो आपके स्रोत टोकन का एक हिस्सा ऑटोमैटिकली फीस को कवर करने के लिए इस्तेमाल किया जाएगा, जिससे प्राइस इम्पैक्ट बढ़ जाता है। MetaMask प्राइस इम्पैक्ट को प्रभावित या नियंत्रित नहीं करता।", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "आपके ट्रेड साइज़ और उपलब्ध लिक्विडिटी की वजह से, आपको मार्केट प्राइस से लगभग {{priceImpact}} कम मिलेगा। यह आपके कोट में पहले से ही शामिल है।", "price_impact_high": "हाई प्राइस इम्पैक्ट", "price_impact_execution_description": "इस स्वैप में आप अपने टोकन के मूल्य का लगभग {{priceImpact}} खो देंगे। राशि कम करने की कोशिश करें या अधिक लिक्विड रूट चुनें।", "proceed": "आगे बढ़ें", @@ -6627,8 +6751,8 @@ "total_cost": "कुल लागत", "got_it": "समझ गए", "price_impact_warning_title": "हाई प्राइस इम्पैक्ट", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "वेरी हाई प्राइस इम्पैक्ट", + "price_impact_error_description": "इस स्वैप पर आपको अपने टोकन की मार्केट प्राइस का लगभग {{priceImpact}} का नुकसान होगा। अपना रेट बेहतर करने के लिए छोटा ट्रेड या ज़्यादा लिक्विड तरीका आज़माएँ।" }, "quote_expired_modal": { "title": "नये कोटेशन उपलब्ध हैं", @@ -6940,7 +7064,7 @@ "upgrade_title": "मेटल में अपग्रेड करें", "continue_button": "जारी रखें", "virtual_card": { - "name": "Virtual Card", + "name": "वर्चुअल कार्ड", "price": "मुफ्त", "feature_1": "Apple Pay और Google Pay के लिए वर्चुअल कार्ड", "feature_2": "क्रिप्टो से भुगतान करें (USDC, USDT, WETH और अन्य)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "मेटल कार्ड", "price": "$199/साल", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "सब कुछ वर्चुअल, साथ में:", + "feature_1": "प्रीमियम एनग्रेव्ड मेटल कार्ड", + "feature_2": "पहले $10,000/साल पर 3% कैशबैक", "feature_3": "कोई विदेशी ट्रांसेक्शन फीस नहीं" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "सालाना $300 तक कैशबैक कमाएं", + "upgrade_to_metal_label": "या 3x रिवॉर्ड के लिए मेटल में अपग्रेड करें" }, "review_order": { "title": "अपना ऑर्डर समीक्षा करें", @@ -7104,7 +7228,7 @@ "ssn_description": "कार्ड जारी करने वाले के लिए ज़रूरी है। कोई क्रेडिट चेक नहीं किया जाएगा।", "invalid_ssn": "SSN ग़लत है", "invalid_date_of_birth": "जन्मतिथि ग़लत है। आपकी आयु कम से कम 18 वर्ष होनी चाहिए", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "पहला और आखिरी नाम आपकी वेरिफाइड पहचान से मेल खाना चाहिए" }, "physical_address": { "title": "अपना एड्रेस जोड़ें", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "आप अपनी खर्च सीमा के करीब पहुँच चुके हैं", "description": "अस्वीकृति से बचने के लिए अपडेट करें", - "confirm_button_label": "नई सीमा निर्धारित करें" + "confirm_button_label": "नई सीमा निर्धारित करें", + "dismiss_button_label": "खारिज करें" }, "need_delegation": { "title": "आपको अपना कार्ड चालू करना होगा", @@ -7301,7 +7426,6 @@ "dismiss": "खारिज करें", "update_success": "खर्च सीमा सफलतापूर्वक अपडेट की गई", "update_error": "खर्च सीमा अपडेट करना नहीं हो पाया", - "solana_not_supported": "card.metamask.io पर Solana टोकन चालू करें", "select_token": "टोकन चुनें", "loading": "उपलब्ध टोकन लोड हो रहे हैं…", "load_error": "टोकन लोड नहीं हो पाए। कृपया फिर से प्रयास करें।", @@ -7343,9 +7467,7 @@ "limited": "सीमित", "not_enabled": "चालू नहीं किया गया", "update_success": "खर्च प्राथमिकता सफलतापूर्वक अपडेट की गई", - "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया", - "solana_not_supported_button_title": "Solana पर अन्य टोकन", - "solana_not_supported_button_description": "card.metamask.io पर चालू करें" + "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया" }, "card_authentication": { "title": "अपने कार्ड अकाउंट में लॉगिन करें", @@ -7443,6 +7565,11 @@ "title": "ऑप्ट-इन नहीं हो पाया", "description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।" }, + "version_guard": { + "title": "अपडेट ज़रूरी है", + "description": "रिवॉर्ड्स इस्तेमाल करने के लिए MetaMask का नया वर्शन ज़रूरी है। जारी रखने के लिए कृपया अपडेट करें।", + "update_button": "MetaMask को अपडेट करें" + }, "season_error": { "error_fetching_title": "सीज़न लोड नहीं हो पाया", "error_fetching_description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।", @@ -7525,7 +7652,6 @@ "main_title": "पुरस्कार", "referral_title": "रेफरल", "tab_overview_title": "ओवरव्यू", - "tab_snapshots_title": "स्नैपशॉट्स", "tab_activity_title": "गतिविधि", "referral_stats_earned_from_referrals": "रेफरल से अर्जित", "referral_stats_referrals": "रेफरल", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "इस सीज़न में भले ही आपको रिवॉर्ड नहीं मिले, लेकिन अगली बार मिल भी सकते हैं।", "verifying_rewards": "इससे पहले कि आप रिवॉर्ड क्लेम करें, हम पुष्टि कर रहे हैं कि सब कुछ सही है।" }, + "previous_season_view": { + "title": "पिछला सत्र" + }, "season_status": { "points_earned": "पॉइंट्स कमाए" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "सक्रिय बूस्ट्स", "season_1": "सीज़न 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD बोनस कैलकुलेटर", + "description": "देखें कि आप अपने स्टेबलकॉइन को mUSD में बदलकर कितना कमा सकते हैं।", + "amount_label": "कन्वर्ट किया गया अमाउंट", + "estimated_bonus": "अनुमानित सालाना बोनस: 3% तक", + "initial_amount": "शुरुआती अमाउंट", + "daily_bonus": "रोज़ाना क्लेम किया जाने वाला बोनस", + "annualized_bonus": "सालाना बोनस", + "disclaimer": "यह सिर्फ़ एक अनुमान है। बोनस बदल सकता है।", "buy_button": "mUSD खरीदें", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD पर स्वैप करें" }, "upcoming_rewards": { "title": "लॉक किए हुए रिवॉर्ड्स", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "लोड नहीं हो सका" }, - "snapshot": { + "campaign": { "starts_date": "{{date}} को शुरू होता है", "ends_date": "{{date}} को समाप्त होता है", - "results_coming_soon": "रिज़ल्ट जल्द ही आ रहे हैं", - "tokens_on_the_way": "टोकन आने वाले हैं", + "ended_date": "Ended {{date}}", "pill_up_next": "आगे आने वाला है", - "pill_live_now": "अब लाइव है", - "pill_calculating": "गणना की जा रही है", - "pill_results_ready": "रिज़ल्ट तैयार हैं", - "pill_complete": "पूरा" - }, - "snapshots_section": { - "title": "स्नैपशॉट्स", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", - "retry_button": "फिर से प्रयास करें" - }, - "snapshots_tab": { + "pill_active": "लाइव", + "pill_complete": "पूरा", + "enter_now": "अभी एंटर करें", + "entered": "एंटर किया", + "participant_count": "#{{count}}", + "opt_in_cta": "ऑप्ट इन करें", + "opt_in_sheet_title": "कैंपेन जॉइन करें", + "opt_in_sheet_description_pre_link": "'ऑप्ट इन' पर क्लिक करके आप MetaMask रिवॉर्ड्स से सहमत होते हैं", + "opt_in_sheet_link_text": "के पूरक उपयोग की शर्तों और गोपनीयता नोटिस से सहमत होते हैं", + "opt_in_sheet_description_post_link": "हम आपको ऑटोमैटिकली रिवॉर्ड देने के लिए ऑनचेन एक्टिविटी को ट्रैक करेंगे।", + "geo_restriction_banner_title": "आपके इलाके में उपलब्ध नहीं है", + "geo_restriction_banner_description": "लोकल नियमों के कारण यह कैंपेन आपके इलाके में उपलब्ध नहीं है।" + }, + "campaign_mechanics": { + "title": "मैकेनिक्स" + }, + "campaign_details": { + "start_date": "इस दिन शुरू होता है: {{date}}", + "end_date": "इस दिन समाप्त होता है: {{date}}", + "opt_in": "ऑप्ट इन करें", + "opting_in": "ऑप्ट इन किया जा रहा है...", + "opted_in": "आपने इस कैंपेन में ऑप्ट इन किया है", + "opt_in_error": "ऑप्ट इन करना नहीं हो पाया। कृपया फिर से प्रयास करें।", + "join_campaign": "कैंपेन जॉइन करें", + "checking_opt_in_status": "ऑप्ट इन स्टेटस चेक किया जा रहा है", + "swap": "स्वैप करें", + "how_it_works": "ये कैसे काम करता है" + }, + "campaigns_preview": { + "title": "कैंपेन", + "coming_soon": "जल्द आ रहा है", + "notify_me": "मुझे सूचित करें" + }, + "earn_rewards": { + "title": "रिवॉर्ड्स कमाएं", + "musd_title": "स्टेबल्स पर 3% तक बोनस", + "musd_subtitle": "अपना mUSD बोनस कैलकुलेट करें", + "card_title": "3% तक कैश बैक", + "card_subtitle": "अपना MetaMask कार्ड अभी पाएं", + "card_subtitle_cardholder": "अपने MetaMask कार्ड के फ़ायदे पाएं" + }, + "campaigns_view": { + "title": "कैंपेन", "active_title": "एक्टिव", "upcoming_title": "आगे आने वाला है", "previous_title": "पिछला", - "empty_state": "कोई स्नैपशॉट उपलब्ध नहीं है", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", + "empty_state": "कोई कैंपेन उपलब्ध नहीं है", + "error_title": "कैंपेन लोड नहीं हो पा रहे हैं", + "error_description": "हम कैंपेन लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." } @@ -7953,13 +8112,12 @@ "continue": "जारी रखें" }, "connecting": { - "title": "अपना {{device}} कनेक्ट करें", + "title": "आपका {{device}} कनेक्ट हो रहा है...", "searching": "{{device}} खोज रहे हैं…", - "tips_header": "आगे बढ़ने के लिए, सुनिश्चित करें:", + "tips_header": "यह ज़रूर करें:", "tip_unlock": "आपका {{device}} अनलॉक है", "tip_open_app": "Ethereum ऐप खुला है", "tip_enable_bluetooth": "ब्लूटूथ चालू है", - "tip_dnd_off": "डू नॉट डिस्टर्ब बंद है", "tip_bluetooth_permission": "लोकेशन और ब्लूटूथ की अनुमति दी गई है", "tip_bluetooth_permission_v12": "नज़दीकी डिवाइस की अनुमति दी गई है", "tip_stay_close": "आपका डिवाइस आपके फोन के पास रहता है" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "नज़दीकी डिवाइस की अनुमति आवश्यक है", "bluetooth_off": "कृपया अपने डिवाइस से कनेक्ट करने के लिए ब्लूटूथ चालू करें", "bluetooth_scan_failed": "डिवाइस स्कैन नहीं हो पाया। कृपया दोबारा प्रयास करें", - "bluetooth_connection_failed": "जारी रखने के लिए अपने डिवाइस पर ब्लूटूथ चालू करें", + "bluetooth_connection_failed": "आपके डिवाइस से कनेक्शन नहीं हो पाया। कृपया फिर से कोशिश करें", "not_supported": "यह ऑपरेशन सपोर्टेड नहीं है", "unknown_error": "सुनिश्चित करें कि आपका {{device}} इस अकाउंट के लिए सीक्रेट रिकवरी फ्रेज़ या पासफ़्रेज़ के साथ सेटअप किया गया है" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "कैश", + "cash_empty_description": "आपके पास अभी तक कोई mUSD नहीं है। होमपेज पर कैश सेक्शन से स्टेबलकॉइन को mUSD में कन्वर्ट करें।", + "cash_empty_description_network_filter": "इस नेटवर्क पर कोई mUSD नहीं है। अपना mUSD देखने के लिए नेटवर्क बदलें।", "tokens": "टोकन", "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", + "whats_happening": "क्या हो रहा है", + "whats_happening_categories": { + "geopolitical": "जियोपॉलिटिकल", + "macro": "मैक्रो", + "regulatory": "रेगुलेटरी", + "technical": "टेक्निकल", + "social": "सोशल", + "other": "अन्य" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs इंपोर्ट करें", diff --git a/locales/languages/id.json b/locales/languages/id.json index 7aae4c37b0b..0acfe4afd19 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -20,6 +20,12 @@ "update": "Perbarui" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Peringatan", @@ -120,8 +126,8 @@ "title": "Mengirim aset ke alamat burn" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Peringatan kontrak token", + "message": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer." }, "gas_sponsorship_reserve_balance": { "message": "Sponsor gas tidak tersedia untuk transaksi ini. Anda perlu mempertahankan saldo minimal %{minBalance} %{nativeTokenSymbol} di akun Anda.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Tidak dapat menyelesaikan nama", "invalid_address": "Alamat tidak valid", "contractAddressError": "Anda mengirimkan token ke alamat kontrak token. Hal ini dapat mengakibatkan hilangnya token tersebut.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Alamat kontrak cerdas", + "smart_contract_address_warning": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer.", "i_understand": "Saya mengerti", "cancel": "Batal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop loss harus dilakukan pada harga {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Stop loss harus dilakukan pada harga likuidasi {{direction}}", "stop_loss_order_view_warning": "Stop loss merupakan harga likuidasi {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "di atas", "below": "di bawah", "done": "Selesai", @@ -2086,14 +2094,15 @@ "a_closer_look": "Melihat lebih dekat", "whats_being_said": "Yang sedang dibicarakan", "footer_disclaimer": "Ringkasan AI hanya untuk informasi", - "trade_button": "Berdagang", + "swap_button": "Swap", + "buy_button": "Beli", "sources_count": "+{{count}} sumber", "sources_title": "Sumber berita", "feedback_submitted": "Umpan balik telah dikirim", "helpful_prompt": "Apakah ini membantu?", "feedback": { "title": "Umpan balik", - "description": "Bantu tingkatkan wawasan pasar yang dihasilkan AI.", + "description": "Jawaban Anda membantu meningkatkan ringkasan AI kami.", "not_relevant": "Tidak relevan", "not_accurate": "Tidak akurat", "hard_to_understand": "Sulit dipahami", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo tersedia", "claim_amount_text": "Klaim ${{amount}}", "claim_winnings_text": "Klaim kemenangan", - "claiming_text": "Claiming...", + "claiming_text": "Mengklaim...", "unrealized_pnl_label": "P&L Belum Terealisasi", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Tidak dapat memuat", @@ -2287,7 +2296,7 @@ "try_again": "Coba lagi" }, "in_progress": { - "title": "Claim already in progress" + "title": "Klaim sedang diproses" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Biaya yang dibayarkan ke bursa atau pasar", "total_incl_fees": "termasuk biaya", "close": "Tutup", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Harga yang tertera diasumsikan order Anda telah terpenuhi sepenuhnya. Jumlah aktual dapat bervariasi jika order hanya terpenuhi sebagian.", + "deposit_fee": "Biaya deposit", + "deposit_fee_description": "Biaya yang dikenakan untuk mendeposit dana ke saldo prediksi Anda" }, "error": { "title": "Tidak dapat terhubung ke prediksi", @@ -3059,6 +3068,7 @@ "networks_no_results": "Jaringan tidak ditemukan", "network_name_label": "Nama jaringan", "network_name_placeholder": "Nama jaringan (opsional)", + "required": "Diperlukan", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Nama RPC", "network_rpc_placeholder": "Jaringan RPC baru", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Fitur ini memperingatkan Anda tentang aktivitas jahat dengan meninjau permintaan transaksi dan tanda tangan secara aktif.", "security_alerts": "Peringatan keamanan", "security_alerts_desc": "Fitur ini memperingatkan Anda tentang aktivitas berbahaya dengan meninjau permintaan transaksi dan tanda tangan secara lokal. Selalu lakukan uji tuntas sendiri sebelum menyetujui permintaan apa pun. Tidak ada jaminan bahwa fitur ini akan mendeteksi semua aktivitas berbahaya. Dengan mengaktifkan fitur ini, Anda menyetujui persyaratan penggunaan penyedia.", + "smart_account_dapp_requests_heading": "Permintaan akun cerdas dari dapp", + "smart_account_dapp_requests_desc": "Izinkan dapp meminta fitur akun cerdas untuk akun standar. Ini tidak akan memengaruhi akun yang sudah menjadi akun cerdas.", "smart_transactions_opt_in_heading": "Transaksi Pintar", "smart_transactions_opt_in_desc_supported_networks": "Aktifkan Transaksi Pintar untuk transaksi yang lebih andal dan aman pada jaringan yang didukung.", "smart_transactions_learn_more": "Pelajari selengkapnya", @@ -3566,6 +3578,53 @@ "activity": "Aktivitas {{symbol}}", "disclaimer": "Data pasar disediakan oleh sumber pihak ketiga seperti CoinGecko. Data hanya untuk tujuan informasi. MetaMask tidak bertanggung jawab atas akurasinya." }, + "security_trust": { + "title": "Keamanan dan kepercayaan", + "malicious": "Berbahaya", + "risky": "Berisiko", + "malicious_token_title": "Token berbahaya", + "malicious_token_description": "{{symbol}} adalah token berbahaya. Hindari berinteraksi dengan token ini atau memperdagangkannya.", + "verified_token_title": "Token terverifikasi", + "verified_token_description": "{{symbol}} diperdagangkan secara aktif dan dikenal luas. Verifikasi bukanlah bentuk dukungan dari MetaMask.", + "risky_token_title": "Token berisiko", + "risky_token_description": "Sinyal peringatan terdeteksi pada {{symbol}}. Lakukan riset dengan cermat sebelum memperdagangkan token ini.", + "malicious_token_sheet_description": "Sinyal risiko serius terdeteksi pada {{symbol}}. Sebaiknya jangan memperdagangkan token ini.", + "got_it": "Mengerti", + "proceed": "Lanjutkan", + "cancel": "Batalkan", + "data_unavailable": "Data keamanan tidak tersedia", + "subtitle_known": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_no_issues": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_suspicious": "Sinyal peringatan terdeteksi. Tinjau masalah yang ditandai dengan cermat sebelum memperdagangkan aset ini.", + "subtitle_malicious": "Sinyal risiko serius terdeteksi. Sebaiknya hindari aset ini.", + "subtitle_unavailable": "Analisis keamanan tidak dapat dimuat untuk token ini.", + "token_distribution": "Distribusi token", + "total_supply": "Total suplai", + "top_10_holders": "Top 10 pemilik", + "other": "Lainnya", + "no_hidden_fees_detected": "Tidak ditemukan biaya tersembunyi", + "buy_sell_tax": "Pajak Jual/Beli", + "buy_tax": "Pajak pembelian", + "sell_tax": "Pajak penjualan", + "transfer": "Transfer", + "token_info": "Informasi Token", + "created": "Dibuat", + "token_age": "Usia token", + "network": "Jaringan", + "type": "Jenis", + "official_links": "Tautan Resmi", + "website": "Situs web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "T/A", + "verified": "Terverifikasi", + "no_issues": "Tidak ada masalah", + "suspicious": "Mencurigakan", + "malicious_label": "Berbahaya", + "more": "lainnya", + "evaluation_disclaimer": "Tinjauan keamanan ini hanya untuk tujuan evaluasi dan bukan merupakan dukungan atau rekomendasi untuk melakukan perdagangan." + }, "account_details": { "title": "Detail akun", "share_account": "Bagikan", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus yang dapat diklaim", "claim_bonus": "Klaim bonus", "claim_bonus_subtitle": "Bonus akan dibayarkan melalui {{networkName}}.", + "percentage_bonus_on_linea": "Bonus {{percentage}}% di Linea", + "claim": "Klaim", + "sounds_good": "Kedengarannya bagus", + "claimable_bonus_tooltip_with_percentage": "Bonus tahunan sebesar {{percentage}}% yang Anda peroleh karena memiliki mUSD. Bonus dapat diklaim setiap hari di Linea.", "empty_state_cta": { "heading": "Pinjamkan {{tokenSymbol}} dan hasilkan", "body": "Pinjamkan {{tokenSymbol}} Anda dengan {{protocol}} dan dapatkan", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stablecoin Anda" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Dapatkan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Anda tidak memiliki cukup saldo sumber daya untuk melakukan tindakan ini." }, - "trx_unstaking_in_progress": "Proses pembatalan stake {{amount}} TRX sedang berlangsung. Proses ini membutuhkan waktu 14 hari.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Pembatalan stake {{amount}} TRX sedang berlangsung", + "description": "Proses pembatalan stake akan memakan waktu 14 hari" + }, + "unstaked_banner": { + "title": "Pembatalan stake {{amount}} TRX selesai", + "description": "TRX yang batal di-stake kini dapat ditarik", + "button": "Tarik", + "error": "Penarikan gagal" + } }, "stake_eth": "Stake ETH", "unstake_eth": "Batalkan stake ETH", @@ -6376,7 +6498,8 @@ "approve": "Setujui permintaan", "perps_deposit": "Tambahkan dana", "predict_deposit": "Tambahkan dana Prediction", - "predict_withdraw": "Tarik" + "predict_withdraw": "Tarik", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Situs ini meminta izin untuk menggunakan token Anda.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksi {{index}}", "transaction": "Transaksi", "available_balance": "Saldo tersedia: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Lanjutkan", "deposit_edit_amount_done": "Tambahkan dana", "deposit_edit_amount_predict_withdraw": "Tarik", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Dompet perangkat keras belum didukung. Gunakan hot wallet untuk melanjutkan.", "hardware_wallet_not_supported_solana": "Dompet perangkat keras belum didukung untuk Solana. Gunakan hot wallet untuk melanjutkan.", "price_impact_info_title": "Dampak harga", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Beginilah cara perdagangan Anda mengubah harga pasar suatu token. Hal ini bergantung pada ukuran perdagangan, likuiditas yang tersedia, dan biaya penyedia. MetaMask tidak mengontrol dampak harga tersebut.", "price_impact_info_gasless_description": "Dampak harga mencerminkan bagaimana perintah swap Anda memengaruhi harga pasar aset. Jika Anda tidak memiliki cukup dana untuk gas, sebagian token sumber akan dialokasikan secara otomatis untuk menutupi biaya, yang meningkatkan dampak harga. MetaMask tidak memengaruhi atau mengendalikan dampak harga.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Karena ukuran perdagangan Anda dan likuiditas yang tersedia, Anda akan mendapatkan sekitar {{priceImpact}} lebih rendah dari harga pasar. Hal ini sudah diperhitungkan dalam kuotasi Anda.", "price_impact_high": "Dampak harga tinggi", "price_impact_execution_description": "Anda akan kehilangan sekitar {{priceImpact}} dari nilai token Anda pada swap ini. Cobalah untuk mengurangi jumlahnya atau pilih rute yang lebih likuid.", "proceed": "Lanjutkan", @@ -6627,8 +6751,8 @@ "total_cost": "Biaya Total", "got_it": "Mengerti", "price_impact_warning_title": "Dampak harga tinggi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Dampak harga sangat tinggi", + "price_impact_error_description": "Anda akan kehilangan sekitar {{priceImpact}} dari harga pasar token Anda pada swap ini. Cobalah perdagangan yang lebih kecil atau rute yang lebih likuid untuk meningkatkan tarif Anda." }, "quote_expired_modal": { "title": "Kuotasi baru tersedia", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade ke Logam", "continue_button": "Lanjutkan", "virtual_card": { - "name": "Virtual Card", + "name": "Kartu Virtual", "price": "Gratis", "feature_1": "Kartu virtual untuk Apple Pay dan Google Pay", "feature_2": "Bayar dengan kripto (USDC, USDT, WETH, dan lainnya)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Kartu Logam", "price": "$199/tahun", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Semuanya serba virtual, ditambah:", + "feature_1": "Kartu logam berukir premium", + "feature_2": "Cashback 3% untuk $10.000 pertama/tahun", "feature_3": "Tidak ada biaya transaksi luar negeri" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Dapatkan cashback hingga $300 setiap tahunnya", + "upgrade_to_metal_label": "Atau tingkatkan ke Metal untuk mendapatkan reward 3x" }, "review_order": { "title": "Tinjau order", @@ -7104,7 +7228,7 @@ "ssn_description": "Diperlukan oleh penerbit kartu. Tidak akan dilakukan pengecekan kredit.", "invalid_ssn": "NJS tidak valid", "invalid_date_of_birth": "Tanggal lahir tidak valid. Anda harus berusia minimal 18 tahun", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nama depan dan nama belakang harus sesuai dengan identitas Anda yang telah diverifikasi" }, "physical_address": { "title": "Tambahkan alamat Anda", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Batas penggunaan hampir tercapai", "description": "Perbarui untuk menghindari penurunan", - "confirm_button_label": "Atur batas baru" + "confirm_button_label": "Atur batas baru", + "dismiss_button_label": "Lewatkan" }, "need_delegation": { "title": "Anda perlu mengaktifkan kartu", @@ -7301,7 +7426,6 @@ "dismiss": "Lewatkan", "update_success": "Batas penggunaan berhasil diperbarui", "update_error": "Gagal memperbarui batas penggunaan", - "solana_not_supported": "Aktifkan token Solana di card.metamask.io", "select_token": "Pilih token", "loading": "Memuat token yang tersedia...", "load_error": "Tidak dapat memuat token. coba lagi.", @@ -7343,9 +7467,7 @@ "limited": "Terbatas", "not_enabled": "Tidak diaktifkan", "update_success": "Prioritas penggunaan berhasil diperbarui", - "update_error": "Gagal memperbarui prioritas penggunaan", - "solana_not_supported_button_title": "Token lain di Solana", - "solana_not_supported_button_description": "Aktifkan di card.metamask.io" + "update_error": "Gagal memperbarui prioritas penggunaan" }, "card_authentication": { "title": "Masuk ke akun kartu Anda", @@ -7443,6 +7565,11 @@ "title": "Gagal berpartisipasi", "description": "Periksa koneksi Anda dan coba lagi." }, + "version_guard": { + "title": "Pembaruan diperlukan", + "description": "Versi MetaMask yang lebih baru diperlukan untuk menggunakan Reward. Perbarui untuk melanjutkan.", + "update_button": "Perbarui MetaMask" + }, "season_error": { "error_fetching_title": "Musim tidak dapat dimuat", "error_fetching_description": "Periksa koneksi Anda dan coba lagi.", @@ -7525,7 +7652,6 @@ "main_title": "Reward", "referral_title": "Rujukan", "tab_overview_title": "Ikhtisar", - "tab_snapshots_title": "Snapshot", "tab_activity_title": "Aktivitas", "referral_stats_earned_from_referrals": "Diperoleh dari rujukan", "referral_stats_referrals": "Rujukan", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Anda tidak mendapatkan reward musim ini, tetapi selalu ada kesempatan lain.", "verifying_rewards": "Kami memastikan semuanya benar sebelum Anda mengklaim reward." }, + "previous_season_view": { + "title": "Musim Sebelumnya" + }, "season_status": { "points_earned": "Poin yang diperoleh" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Peningkatan aktif", "season_1": "Musim 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "kalkulator bonus mUSD", + "description": "Lihat berapa banyak yang bisa diperoleh dengan mengonversi stablecoin Anda menjadi mUSD.", + "amount_label": "Jumlah yang dikonversi", + "estimated_bonus": "Estimasi bonus tahunan: hingga 3%", + "initial_amount": "Jumlah awal", + "daily_bonus": "Bonus yang dapat diklaim setiap hari", + "annualized_bonus": "Bonus tahunan", + "disclaimer": "Ini hanya estimasi. Bonus dapat berubah sewaktu-waktu.", "buy_button": "Beli mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Swap ke mUSD" }, "upcoming_rewards": { "title": "Reward terkunci", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Tidak dapat memuat" }, - "snapshot": { + "campaign": { "starts_date": "Mulai {{date}}", "ends_date": "Berakhir {{date}}", - "results_coming_soon": "Hasil akan segera diumumkan", - "tokens_on_the_way": "Token sedang dalam perjalanan", + "ended_date": "Ended {{date}}", "pill_up_next": "Selanjutnya", - "pill_live_now": "Sedang live", - "pill_calculating": "Menghitung", - "pill_results_ready": "Hasil Sudah Siap", - "pill_complete": "Selesaikan" - }, - "snapshots_section": { - "title": "Snapshot", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", - "retry_button": "Coba lagi" - }, - "snapshots_tab": { + "pill_active": "Langsung", + "pill_complete": "Selesaikan", + "enter_now": "Masuk sekarang", + "entered": "Masuk", + "participant_count": "#{{count}}", + "opt_in_cta": "Ikut serta", + "opt_in_sheet_title": "Gabung dalam kampanye ini", + "opt_in_sheet_description_pre_link": "Dengan mengklik 'Ikut serta', Anda menyetujui program Reward MetaMask", + "opt_in_sheet_link_text": "Ketentuan Penggunaan dan Pemberitahuan Privasi Tambahan", + "opt_in_sheet_description_post_link": "Kami akan melacak aktivitas onchain untuk memberikan reward secara otomatis.", + "geo_restriction_banner_title": "Tidak tersedia di wilayah Anda", + "geo_restriction_banner_description": "Kampanye ini tidak tersedia di wilayah Anda karena peraturan setempat." + }, + "campaign_mechanics": { + "title": "Mekanika" + }, + "campaign_details": { + "start_date": "Mulai: {{date}}", + "end_date": "Selesai: {{date}}", + "opt_in": "Ikut serta", + "opting_in": "Sedang ikut serta...", + "opted_in": "Anda telah memilih untuk ikut serta dalam kampanye ini", + "opt_in_error": "Gagal ikut serta. Coba lagi.", + "join_campaign": "Gabung dalam kampanye ini", + "checking_opt_in_status": "Memeriksa status keikutsertaan", + "swap": "Swap", + "how_it_works": "Cara kerjanya" + }, + "campaigns_preview": { + "title": "Kampanye", + "coming_soon": "Segera hadir", + "notify_me": "Beri tahu saya" + }, + "earn_rewards": { + "title": "Dapatkan reward", + "musd_title": "Bonus stabil hingga 3%", + "musd_subtitle": "Hitung bonus mUSD Anda", + "card_title": "Cashback hingga 3%", + "card_subtitle": "Dapatkan Kartu MetaMask sekarang", + "card_subtitle_cardholder": "Manfaatkan keuntungan Kartu MetaMask" + }, + "campaigns_view": { + "title": "Kampanye", "active_title": "Aktif", "upcoming_title": "Mendatang", "previous_title": "Sebelumnya", - "empty_state": "Snapshot tidak tersedia", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", + "empty_state": "Kampanye tidak tersedia", + "error_title": "Tidak dapat memuat kampanye", + "error_description": "Kami tidak dapat memuat kampanye. Coba lagi.", "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." } @@ -7953,13 +8112,12 @@ "continue": "Lanjutkan" }, "connecting": { - "title": "Hubungkan {{device}} Anda", + "title": "Menghubungkan {{device}} Anda...", "searching": "Mencari {{device}}...", - "tips_header": "Untuk melanjutkan, pastikan:", + "tips_header": "Pastikan:", "tip_unlock": "{{device}} Anda tidak terkunci", "tip_open_app": "Aplikasi Ethereum sudah dibuka", "tip_enable_bluetooth": "Bluetooth diaktifkan", - "tip_dnd_off": "Jangan Ganggu dinonaktifkan", "tip_bluetooth_permission": "Izin lokasi dan Bluetooth telah diberikan", "tip_bluetooth_permission_v12": "Izin perangkat terdekat telah diberikan", "tip_stay_close": "Perangkat Anda tetap berada di dekat ponsel Anda" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Izin perangkat terdekat diperlukan", "bluetooth_off": "Aktifkan Bluetooth untuk terhubung ke perangkat Anda", "bluetooth_scan_failed": "Gagal memindai perangkat. Coba lagi", - "bluetooth_connection_failed": "Aktifkan Bluetooth di perangkat Anda untuk melanjutkan", + "bluetooth_connection_failed": "Koneksi ke perangkat Anda gagal. Coba lagi", "not_supported": "Operasi ini tidak didukung", "unknown_error": "Pastikan {{device}} Anda telah diatur dengan Frasa Pemulihan Rahasia atau passphrase untuk akun ini" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Uang tunai", + "cash_empty_description": "Anda belum memiliki mUSD. Konversikan stablecoin ke mUSD dari bagian Uang Tunai di halaman beranda.", + "cash_empty_description_network_filter": "Tidak ada mUSD di jaringan ini. Ganti jaringan untuk melihat mUSD milik Anda.", "tokens": "Token", "perpetuals": "Abadi", "predictions": "Prediksi", + "whats_happening": "Apa yang sedang terjadi", + "whats_happening_categories": { + "geopolitical": "Geopolitik", + "macro": "Makro", + "regulatory": "Peraturan", + "technical": "Teknis", + "social": "Sosial", + "other": "Lainnya" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Impor NFT", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 32d9a9028f7..fdb8b7268e2 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "アラート", @@ -120,8 +126,8 @@ "title": "バーンアドレスに資産を送ろうとしています" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "トークンコントラクトに関する警告", + "message": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。" }, "gas_sponsorship_reserve_balance": { "message": "この取引でガススポンサーシップはご利用いただけません。アカウントに%{minBalance} %{nativeTokenSymbol}以上の残高が必要です。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "名前の解決ができませんでした", "invalid_address": "無効なアドレス", "contractAddressError": "トークンのコントラクトアドレスにトークンを送金しようとしています。これにより、当該トークンが失われる可能性があります。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "スマートコントラクトアドレス", + "smart_contract_address_warning": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。", "i_understand": "理解しています", "cancel": "キャンセル" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "ストップロスは{{priceType}}価格よりも{{direction}}に設定する必要があります", "stop_loss_beyond_liquidation_error": "ストップロスは清算価格よりも{{direction}}に設定する必要があります", "stop_loss_order_view_warning": "ストップロスが清算価格よりも{{direction}}に設定されています", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "上", "below": "下", "done": "完了", @@ -2086,14 +2094,15 @@ "a_closer_look": "詳細", "whats_being_said": "市場の声", "footer_disclaimer": "AIによる要約は参考用です", - "trade_button": "取引", + "swap_button": "スワップ", + "buy_button": "購入", "sources_count": "他{{count}}件のソース", "sources_title": "ニュースソース", "feedback_submitted": "フィードバックが送信されました", "helpful_prompt": "この情報は役に立ちましたか?", "feedback": { "title": "フィードバック", - "description": "AIが生成した市場分析情報の改善にご協力ください。", + "description": "皆様からの回答は、当社のAIサマリーの改善に役立ちます。", "not_relevant": "関連性が低い", "not_accurate": "正確でない", "hard_to_understand": "わかりにくい", @@ -2162,7 +2171,7 @@ "sell_position": "ポジションを売却", "cash_out": "キャッシュアウト", "cash_out_info": "資金は利用可能残高に追加されます", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}で{{outcome}}", "at_price_per_share": "{{size}}株を{{price}}で売却中", "cashout_info": "{{outcome}}を{{initialPrice}}で{{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} • {{outcome}}を{{initialPrice}}で{{amount}}", @@ -2206,7 +2215,7 @@ "available_balance": "利用可能残高", "claim_amount_text": "請求額 ${{amount}}", "claim_winnings_text": "報酬を請求", - "claiming_text": "Claiming...", + "claiming_text": "請求中...", "unrealized_pnl_label": "含み損益", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "読み込めません", @@ -2287,7 +2296,7 @@ "try_again": "再試行してください" }, "in_progress": { - "title": "Claim already in progress" + "title": "請求処理はすでに進行中です" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "取引所または市場に支払われる手数料", "total_incl_fees": "手数料込", "close": "閉じる", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "表示価格は、ご注文が完全に約定した場合の金額です。ご注文の一部のみが約定した場合は、実際の金額が異なることがあります。", + "deposit_fee": "デポジット手数料", + "deposit_fee_description": "予測残高に入金する際に発生する手数料" }, "error": { "title": "予想に接続できません", @@ -3059,6 +3068,7 @@ "networks_no_results": "ネットワークが見つかりません", "network_name_label": "ネットワーク名", "network_name_placeholder": "ネットワーク名 (オプション)", + "required": "必須", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC名", "network_rpc_placeholder": "新しいRPCネットワーク", @@ -3298,6 +3308,8 @@ "blockaid_desc": "この機能は、トランザクションと署名要求を能動的に確認し、悪質なアクティビティに関するアラートを発します。", "security_alerts": "セキュリティアラート", "security_alerts_desc": "この機能は、トランザクションと署名要求をローカルで確認することで、悪質な行為に関するアラートを発します。要求を承認する前に、必ず独自のデューデリジェンスを行ってください。この機能がすべての悪質な行為を検出するという保証はありません。この機能を有効にすることで、プロバイダーの利用規約に同意したものとみなされます。", + "smart_account_dapp_requests_heading": "DAppからのスマートアカウントリクエスト", + "smart_account_dapp_requests_desc": "DAppによる、スタンダードアカウント用のスマートアカウント機能のリクエストを許可します。これにより、すでにスマートアカウントのアカウントに影響はありません。", "smart_transactions_opt_in_heading": "スマートトランザクション", "smart_transactions_opt_in_desc_supported_networks": "スマートトランザクションをオンにして、サポートされているネットワーク上でのトランザクションの信頼性と安全性を高めましょう。", "smart_transactions_learn_more": "詳細", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}のアクティビティ", "disclaimer": "市場データは、CoinGeckoなどのサードパーティソースから提供されます。データは情報目的のみです。MetaMaskはこのデータの正確性に責任を持ちません。" }, + "security_trust": { + "title": "セキュリティと信頼", + "malicious": "悪意のある", + "risky": "リスクのある", + "malicious_token_title": "悪質なトークン", + "malicious_token_description": "{{symbol}}は悪意のあるトークンです。やり取りや取引は避けてください。", + "verified_token_title": "確認済みのトークン", + "verified_token_description": "{{symbol}}はアクティブに取引され、広く認識されています。検証はMetaMaskによる承認ではありません。", + "risky_token_title": "リスクのあるトークン", + "risky_token_description": "{{symbol}}で警告のシグナルが検出されました。このトークンを取引する前に、慎重に調査してください。", + "malicious_token_sheet_description": "{{symbol}}で深刻なリスクシグナルが検出されました。このトークンは取引しないことをお勧めします。", + "got_it": "了解", + "proceed": "先に進む", + "cancel": "キャンセル", + "data_unavailable": "セキュリティデータが利用できません", + "subtitle_known": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_no_issues": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_suspicious": "警告のシグナルが検出されました。この資産を取引する前に、フラグの付いた問題を慎重に確認してください。", + "subtitle_malicious": "深刻なリスクシグナルが検出されました。この資産は避けることをお勧めします。", + "subtitle_unavailable": "このトークンのセキュリティ分析情報を読み込めませんでした。", + "token_distribution": "トークンの流通", + "total_supply": "合計供給量", + "top_10_holders": "トップ10のトークン", + "other": "その他", + "no_hidden_fees_detected": "隠れた手数料は検出されませんでした", + "buy_sell_tax": "購入・売却税", + "buy_tax": "購入税", + "sell_tax": "売却税", + "transfer": "送金", + "token_info": "トークン情報", + "created": "作成されました", + "token_age": "トークンの年齢", + "network": "ネットワーク", + "type": "タイプ", + "official_links": "公式リンク", + "website": "Webサイト", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "該当なし", + "verified": "検証済み", + "no_issues": "問題なし", + "suspicious": "不審", + "malicious_label": "悪意のある", + "more": "さらに表示", + "evaluation_disclaimer": "このセキュリティレビューは評価目的のみのものであり、取引の承認または推奨とみなされるものではありません。" + }, "account_details": { "title": "アカウント情報", "share_account": "共有", @@ -5934,6 +5993,10 @@ "claimable_bonus": "獲得できるボーナス", "claim_bonus": "ボーナスを請求する", "claim_bonus_subtitle": "ボーナスは{{networkName}}上で支払われます。", + "percentage_bonus_on_linea": "Lineaでの{{percentage}}%ボーナス", + "claim": "請求", + "sounds_good": "いいですね", + "claimable_bonus_tooltip_with_percentage": "mUSDを保有することで獲得した年換算{{percentage}}%のボーナスです。ボーナスはLineaで毎日請求できます。", "empty_state_cta": { "heading": "{{tokenSymbol}}を貸して収益化", "body": "{{protocol}}で{{tokenSymbol}}を貸し付けて、", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "保有中のステーブルコイン" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "獲得", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "この操作を行うのに十分なリソース残高がありません。" }, - "trx_unstaking_in_progress": "{{amount}}TRXのステーキング解除を実行中です。ステーキング解除には14日かかります。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRXのステーキングを解除しています", + "description": "ステーキングの解除には14日間かかります" + }, + "unstaked_banner": { + "title": "{{amount}} TRXのステーキングが解除されました", + "description": "ステーキングを解除したTRXが出金可能になりました", + "button": "出金", + "error": "出金失敗" + } }, "stake_eth": "ETHをステーキング", "unstake_eth": "ETHのステーキングを解除", @@ -6376,7 +6498,8 @@ "approve": "要求の承認", "perps_deposit": "資金を追加", "predict_deposit": "予測資金を追加", - "predict_withdraw": "出金" + "predict_withdraw": "出金", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "このサイトがトークンの使用許可を求めています。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "トランザクション {{index}}", "transaction": "トランザクション", "available_balance": "利用可能残高: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "続行", "deposit_edit_amount_done": "資金を追加", "deposit_edit_amount_predict_withdraw": "出金", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "まだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "hardware_wallet_not_supported_solana": "Solanaはまだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "price_impact_info_title": "プライスインパクト", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "これは、取引によってトークンの市場価格がどのように変動するかを示しています。取引量、利用可能な流動性、プロバイダー手数料によって異なります。MetaMaskは価格への影響を制御することはできません。", "price_impact_info_gasless_description": "プライスインパクトは、スワップ注文がその資産の市場価格にどのように影響するかを反映します。ガス代の支払いに十分な資金を保有していない場合、交換前のトークンの一部が自動的に手数料の支払いに充当され、プライスインパクトが増大します。MetaMaskがプライスインパクトに影響を与えたりコントロールしたりすることはありません。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "取引規模と利用可能な流動性により、市場価格よりも約{{priceImpact}}安くなります。これはすでに提示価格に反映されています。", "price_impact_high": "高プライスインパクト", "price_impact_execution_description": "このスワップにより、トークンの価値の約{{priceImpact}}が失われます。金額を下げるか、より流動性の高いルートを選択してください。", "proceed": "先に進む", @@ -6627,8 +6751,8 @@ "total_cost": "総コスト", "got_it": "了解", "price_impact_warning_title": "高プライスインパクト", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "非常に高いプライスインパクト", + "price_impact_error_description": "このスワップでは、トークンの市場価格の約{{priceImpact}}を失います。より良いレートを得るには、取引量を減らすか、流動性の高いルートをお試しください。" }, "quote_expired_modal": { "title": "新しい価格が利用可能です", @@ -6940,7 +7064,7 @@ "upgrade_title": "メタルにアップグレード", "continue_button": "続行", "virtual_card": { - "name": "Virtual Card", + "name": "バーチャルカード", "price": "無料", "feature_1": "Apple PayとGoogle Payで使えるバーチャルカード", "feature_2": "仮想通貨でお支払い (USDC、USDT、WETHなど)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "メタルカード", "price": "年間199ドル", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "すべてバーチャルで、さらに:", + "feature_1": "プレミアム刻印入りメタルカード", + "feature_2": "年間最初の$10,000に対して3%のキャッシュバック", "feature_3": "海外トランザクション手数料なし" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "年間最大$300のキャッシュバック獲得", + "upgrade_to_metal_label": "または、メタルカードにアップグレードして3倍の報酬を獲得" }, "review_order": { "title": "ご注文内容の確認", @@ -7104,7 +7228,7 @@ "ssn_description": "カード発行者により求められています。信用調査は行われません。", "invalid_ssn": "無効な社会保障番号です", "invalid_date_of_birth": "生年月日が無効です。18歳以上である必要があります。", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "氏名は本人確認済みの情報と一致している必要があります" }, "physical_address": { "title": "住所を追加", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "利用限度額に近づいています", "description": "支払い拒否を防ぐために更新してください", - "confirm_button_label": "新しい限度額を設定" + "confirm_button_label": "新しい限度額を設定", + "dismiss_button_label": "閉じる" }, "need_delegation": { "title": "カードを有効にする必要があります", @@ -7301,7 +7426,6 @@ "dismiss": "閉じる", "update_success": "使用上限が更新されました", "update_error": "使用上限の更新に失敗しました", - "solana_not_supported": "card.metamask.ioでSolanaトークンを有効にする", "select_token": "トークンを選択", "loading": "ご利用可能なトークンを読み込み中...", "load_error": "トークンを読み込めません。もう一度お試しください。", @@ -7343,9 +7467,7 @@ "limited": "制限付き", "not_enabled": "有効になっていません", "update_success": "使用優先順位が更新されました", - "update_error": "使用優先順位の更新に失敗しました", - "solana_not_supported_button_title": "Solanaの他のトークン", - "solana_not_supported_button_description": "card.metamask.ioで有効にします" + "update_error": "使用優先順位の更新に失敗しました" }, "card_authentication": { "title": "カードアカウントへのログイン", @@ -7443,6 +7565,11 @@ "title": "オプトインに失敗しました", "description": "接続を確認して、もう一度お試しください。" }, + "version_guard": { + "title": "アップデートが必要です", + "description": "Rewardsの利用には、MetaMaskの新しいバージョンが必要です。続けるにはアップデートしてください。", + "update_button": "MetaMaskをアップデート" + }, "season_error": { "error_fetching_title": "シーズンを読み込めませんでした", "error_fetching_description": "接続を確認して、もう一度お試しください。", @@ -7525,7 +7652,6 @@ "main_title": "報酬", "referral_title": "紹介", "tab_overview_title": "概要", - "tab_snapshots_title": "スナップショット", "tab_activity_title": "アクティビティ", "referral_stats_earned_from_referrals": "紹介して報酬を獲得", "referral_stats_referrals": "紹介", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "このセッションではリワードを獲得できませんでしたが、また次があります。", "verifying_rewards": "リワードを獲得する前に、情報がすべて正しいことを確認しています。" }, + "previous_season_view": { + "title": "以前のセッション" + }, "season_status": { "points_earned": "ポイント獲得" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "有効なブースト", "season_1": "シーズン1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSDボーナス計算ツール", + "description": "ステーブルコインをmUSDに交換することで、どれだけの利益が得られるか確認しましょう。", + "amount_label": "変換金額", + "estimated_bonus": "推定年率ボーナス:最大3%", + "initial_amount": "初期金額", + "daily_bonus": "1日あたりに獲得可能なボーナス", + "annualized_bonus": "年率ボーナス", + "disclaimer": "これはあくまで目安です。ボーナスは変更される場合があります。", "buy_button": "mUSDを購入", - "swap_button": "Swap to mUSD" + "swap_button": "mUSDにスワップ" }, "upcoming_rewards": { "title": "ロックされているリワード", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "読み込ませんでした" }, - "snapshot": { + "campaign": { "starts_date": "{{date}}開始", "ends_date": "{{date}}終了", - "results_coming_soon": "間もなく結果が出ます", - "tokens_on_the_way": "トークンを送金中です", + "ended_date": "Ended {{date}}", "pill_up_next": "次", - "pill_live_now": "現在進行中", - "pill_calculating": "計算中", - "pill_results_ready": "結果が出ました", - "pill_complete": "完了" - }, - "snapshots_section": { - "title": "スナップショット", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", - "retry_button": "再試行" - }, - "snapshots_tab": { + "pill_active": "ライブ", + "pill_complete": "完了", + "enter_now": "今すぐ応募", + "entered": "応募しました", + "participant_count": "#{{count}}", + "opt_in_cta": "オプトイン", + "opt_in_sheet_title": "キャンペーンに参加しましょう", + "opt_in_sheet_description_pre_link": "「オプトイン」をクリックすることで、MetaMask Rewardsに同意したものとみなされます", + "opt_in_sheet_link_text": "補足利用規約およびプライバシー通知", + "opt_in_sheet_description_post_link": "オンチェーンアクティビティを自動的に追跡し、リワードを付与します。", + "geo_restriction_banner_title": "お客様の地域では利用できません", + "geo_restriction_banner_description": "現地の規制により、お住いの地域ではこのキャンペーンにご参加いただけません。" + }, + "campaign_mechanics": { + "title": "仕組み" + }, + "campaign_details": { + "start_date": "開始: {{date}}", + "end_date": "終了: {{date}}", + "opt_in": "オプトイン", + "opting_in": "オプトインしています...", + "opted_in": "このキャンペーンにオプトインしました", + "opt_in_error": "オプトインに失敗しました。もう一度お試しください。", + "join_campaign": "キャンペーンに参加", + "checking_opt_in_status": "オプトインステータスを確認しています", + "swap": "スワップ", + "how_it_works": "報酬獲得の仕組み" + }, + "campaigns_preview": { + "title": "キャンペーン", + "coming_soon": "近日追加予定", + "notify_me": "通知を受ける" + }, + "earn_rewards": { + "title": "報酬を獲得しましょう", + "musd_title": "ステーブルで最大3%のボーナス", + "musd_subtitle": "mUSDボーナスの計算", + "card_title": "最大3%のキャッシュバック", + "card_subtitle": "今すぐMetaMaskカードを取得", + "card_subtitle_cardholder": "MetaMaskカードの特典を利用" + }, + "campaigns_view": { + "title": "キャンペーン", "active_title": "アクティブ", "upcoming_title": "今後", "previous_title": "以前", - "empty_state": "利用可能なスナップショットがありません", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", + "empty_state": "参加できるキャンペーンがありません", + "error_title": "キャンペーンを読み込めません", + "error_description": "キャンペーンを読み込めませんでした。もう一度お試しください。", "retry_button": "再試行", "refreshing": "更新中..." } @@ -7953,13 +8112,12 @@ "continue": "続行" }, "connecting": { - "title": "{{device}}の接続", + "title": "{{device}}を接続しています...", "searching": "{{device}}を検索中...", - "tips_header": "続行するには、以下の点をご確認ください。", + "tips_header": "次の点を確認してください:", "tip_unlock": "{{device}}のロックが解除されている", "tip_open_app": "イーサリアムアプリが開いている", "tip_enable_bluetooth": "Bluetoothがオンになっている", - "tip_dnd_off": "サイレントモードがオフになっている", "tip_bluetooth_permission": "位置情報とBluetoothへのアクセス許可が付与されている", "tip_bluetooth_permission_v12": "付近のデバイスへのアクセス許可が付与されている", "tip_stay_close": "デバイスがスマートフォンの近くにある" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "付近のデバイスへのアクセス許可が必要です", "bluetooth_off": "デバイスに接続するには、Bluetoothをオンにしてください", "bluetooth_scan_failed": "デバイスを検索できませんでした。もう一度お試しください。", - "bluetooth_connection_failed": "続行するには、デバイスでBluetoothを有効にしてください", + "bluetooth_connection_failed": "デバイスへの接続に失敗しました。もう一度お試しください", "not_supported": "この操作はサポートされていません。", "unknown_error": "{{device}}がこのアカウント用のシークレットリカバリーフレーズまたはパスフレーズを使ってセットアップされていることを確認してください。" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "現金", + "cash_empty_description": "まだmUSDをお持ちではありません。ホームページの「現金」セクションからステーブルコインをmUSDに変換してください。", + "cash_empty_description_network_filter": "このネットワークにはmUSDがありません。ネットワークを切り替えてお持ちのmUSDをご確認ください。", "tokens": "トークン", "perpetuals": "パーペチュアル", "predictions": "予測", + "whats_happening": "現在起きていること", + "whats_happening_categories": { + "geopolitical": "地政学", + "macro": "マクロ", + "regulatory": "規制", + "technical": "技術", + "social": "社会", + "other": "その他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "NFTをインポート", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 6d484d0a381..cd541c1ed55 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -20,6 +20,12 @@ "update": "업데이트" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "경고", @@ -120,8 +126,8 @@ "title": "소각 주소로 자산 전송" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "토큰 계약 경고", + "message": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요." }, "gas_sponsorship_reserve_balance": { "message": "이 트랜잭션에는 가스 후원이 제공되지 않습니다. 계정에 최소 %{minBalance}의 %{nativeTokenSymbol} 토큰을 보유해야 합니다.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "이름을 확인할 수 없습니다", "invalid_address": "잘못된 주소", "contractAddressError": "토큰의 계약 주소로 토큰을 보내고 있습니다. 이로 인해 해당 토큰이 손실될 수 있습니다.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "스마트 계약 주소", + "smart_contract_address_warning": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요.", "i_understand": "견적은 다음 기간 전에 만료됨을", "cancel": "취소" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "손절은 {{direction}} {{priceType}} 가격이어야 합니다", "stop_loss_beyond_liquidation_error": "손절은 {{direction}} 청산 가격이어야 합니다", "stop_loss_order_view_warning": "손절이 {{direction}} 청산 가격입니다", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": " 이상", "below": " 미만", "done": "완료", @@ -2086,14 +2094,15 @@ "a_closer_look": "자세히 보기", "whats_being_said": "시장 반응", "footer_disclaimer": "참고용 AI 요약", - "trade_button": "거래하기", + "swap_button": "스와프", + "buy_button": "매수", "sources_count": "출처 {{count}}개 이상", "sources_title": "뉴스 출처", "feedback_submitted": "피드백 제출됨", "helpful_prompt": "도움이 되셨나요?", "feedback": { "title": "피드백", - "description": "AI 기반 시장 인사이트를 개선할 수 있도록 도와주세요.", + "description": "귀하의 답변은 AI 요약 개선에 도움이 됩니다.", "not_relevant": "관련 없음", "not_accurate": "정확하지 않음", "hard_to_understand": "이해하기 어려움", @@ -2162,7 +2171,7 @@ "sell_position": "포지션 매도", "cash_out": "출금", "cash_out_info": "자금은 사용 가능한 잔액에 추가됩니다", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}에 {{outcome}}", "at_price_per_share": "{{price}}에 {{size}}주 매도", "cashout_info": "{{outcome}}에 {{amount}}(단가: {{initialPrice}})", "cashout_info_multiple": "{{outcomeGroupTitle}} - {{outcome}}에 {{amount}}(단가:{{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "사용 가능한 잔액", "claim_amount_text": "${{amount}} 수령", "claim_winnings_text": "수익금 수령", - "claiming_text": "Claiming...", + "claiming_text": "청구 중...", "unrealized_pnl_label": "미실현 손익", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "불러올 수 없습니다", @@ -2287,7 +2296,7 @@ "try_again": "다시 시도" }, "in_progress": { - "title": "Claim already in progress" + "title": "청구가 이미 진행 중입니다" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "거래소 또는 시장에 지불한 수수료", "total_incl_fees": "수수료 포함", "close": "닫기", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "표시된 가격은 주문이 전량 체결된다고 가정한 값입니다. 주문이 일부만 체결되면 실제 수량은 달라질 수 있습니다.", + "deposit_fee": "예치 수수료", + "deposit_fee_description": "예측 잔액에 자금을 예치할 때 부과되는 수수료" }, "error": { "title": "예측에 연결할 수 없습니다", @@ -3059,6 +3068,7 @@ "networks_no_results": "네트워크를 찾을 수 없습니다", "network_name_label": "네트워크 이름", "network_name_placeholder": "네트워크 이름(옵션)", + "required": "필수", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC 이름", "network_rpc_placeholder": "신규 RPC 네트워크", @@ -3298,6 +3308,8 @@ "blockaid_desc": "이 기능은 트랜잭션과 서명 요청을 적극적으로 검토하여 악의적인 활동을 경고합니다.", "security_alerts": "보안 경고", "security_alerts_desc": "이 기능은 거래 및 서명 요청을 로컬에서 검토하여 악의적인 활동이 있는 경우 경고합니다. 요청을 승인하기 전에 항상 직접 검토하세요. 이 기능이 모든 악성 활동 탐지를 보장하지는 않습니다. 이 기능을 활성화하면 제공 업체의 이용 약관에 동의하는 것이 됩니다.", + "smart_account_dapp_requests_heading": "디앱의 스마트 계정 요청", + "smart_account_dapp_requests_desc": "디앱이 일반 계정에 스마트 계정 기능을 요청하도록 허용합니다. 이미 스마트 계정인 계정에는 영향을 미치지 않습니다.", "smart_transactions_opt_in_heading": "스마트 트랜잭션", "smart_transactions_opt_in_desc_supported_networks": "지원되는 네트워크에서 더 안정적이고 안전하게 트랜잭션을 진행하려면 스마트 트랜잭션을 활성화하세요.", "smart_transactions_learn_more": "더 보기", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 활동", "disclaimer": "시장 데이터는 CoinGecko와 같은 제3자 제공업체에서 제공합니다. 해당 데이터는 정보 제공 목적이며, MetaMask는 정확성을 보장하지 않습니다." }, + "security_trust": { + "title": "보안 및 신뢰", + "malicious": "악성", + "risky": "위험", + "malicious_token_title": "악성 토큰", + "malicious_token_description": "{{symbol}}은(는) 악성 토큰입니다. 이 토큰과 상호작용하거나 거래하지 마세요.", + "verified_token_title": "검증된 토큰", + "verified_token_description": "{{symbol}}은(는) 활발하게 거래되고 있으며 널리 알려져 있습니다. 검증은 MetaMask의 보증을 의미하지 않습니다.", + "risky_token_title": "위험한 토큰", + "risky_token_description": "{{symbol}}에 대해 주의가 필요한 신호가 감지되었습니다. 이 토큰을 거래하기 전에 충분히 조사하세요.", + "malicious_token_sheet_description": "{{symbol}}에 대해 심각한 위험 신호가 감지되었습니다. 이 토큰은 거래하지 않는 것이 좋습니다.", + "got_it": "컨펌", + "proceed": "진행", + "cancel": "취소", + "data_unavailable": "보안 데이터 없음", + "subtitle_known": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_no_issues": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_suspicious": "주의가 필요한 신호가 감지되었습니다. 이 자산을 거래하기 전에 표시된 문제를 주의 깊게 검토하세요.", + "subtitle_malicious": "심각한 위험 신호가 감지되었습니다. 이 자산은 피하는 것이 좋습니다.", + "subtitle_unavailable": "이 토큰의 보안 분석을 불러올 수 없습니다.", + "token_distribution": "토큰 분포", + "total_supply": "총 공급", + "top_10_holders": "상위 보유자 10인", + "other": "기타", + "no_hidden_fees_detected": "숨은 수수료 감지되지 않음", + "buy_sell_tax": "매수/매도 세금", + "buy_tax": "매수세", + "sell_tax": "매도세", + "transfer": "송금", + "token_info": "토큰 정보", + "created": "생성됨", + "token_age": "토큰 생성 후 경과 시간", + "network": "네트워크", + "type": "유형", + "official_links": "공식 링크", + "website": "웹사이트", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "이용 불가", + "verified": "인증 완료", + "no_issues": "문제 없음", + "suspicious": "의심됨", + "malicious_label": "악성", + "more": "더 보기", + "evaluation_disclaimer": "이 보안 검토는 평가용일 뿐이므로 거래에 대한 보증이나 권유로 받아들여서는 안 됩니다." + }, "account_details": { "title": "계정 세부 정보", "share_account": "공유", @@ -5934,6 +5993,10 @@ "claimable_bonus": "청구 가능한 보너스", "claim_bonus": "보너스 수령", "claim_bonus_subtitle": "보너스는 {{networkName}}에서 지급됩니다.", + "percentage_bonus_on_linea": "Linea에서 {{percentage}}% 보너스", + "claim": "청구", + "sounds_good": "좋아요", + "claimable_bonus_tooltip_with_percentage": "mUSD를 보유하여 {{percentage}}%의 연환산 보너스를 받았습니다. 보너스는 Linea에서 매일 청구할 수 있습니다.", "empty_state_cta": { "heading": "{{tokenSymbol}} 토큰을 빌려주고 수익을 올리세요", "body": "{{protocol}}에서 {{tokenSymbol}}을 예치하고 연간 이자를", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "사용자의 스테이블코인" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "수익 창출", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "이 작업을 수행하기에 리소스 잔액이 부족합니다." }, - "trx_unstaking_in_progress": "{{amount}} TRX 언스테이킹이 진행 중입니다. 언스테이킹에는 14일이 소요됩니다.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX 언스테이킹 진행 중", + "description": "언스테이킹에는 14일이 소요됩니다" + }, + "unstaked_banner": { + "title": "{{amount}} TRX 언스테이킹 완료", + "description": "언스테이킹한 TRX를 이제 출금할 수 있습니다", + "button": "출금", + "error": "출금 실패" + } }, "stake_eth": "ETH 스테이크", "unstake_eth": "ETH 언스테이크", @@ -6376,7 +6498,8 @@ "approve": "요청 승인", "perps_deposit": "자금 추가", "predict_deposit": "예측 자금 추가", - "predict_withdraw": "출금" + "predict_withdraw": "출금", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "이 사이트에서 토큰 사용 권한을 요청합니다.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "트랜잭션 {{index}}", "transaction": "트랜잭션", "available_balance": "사용 가능한 잔액: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "계속", "deposit_edit_amount_done": "자금 추가", "deposit_edit_amount_predict_withdraw": "출금", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "하드웨어 지갑은 아직 지원되지 않습니다. 핫월렛을 사용하여 계속하세요.", "hardware_wallet_not_supported_solana": "솔라나는 아직 하드웨어 지갑이 지원하지 않습니다. 계속하려면 핫월렛을 사용하세요.", "price_impact_info_title": "가격 영향", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "이는 거래가 토큰의 시장 가격에 어떤 영향을 미치는지 보여줍니다. 거래 규모, 이용 가능한 유동성, 공급자 수수료에 따라 달라집니다. MetaMask는 가격 영향에 관여하지 않습니다.", "price_impact_info_gasless_description": "가격 영향은 사용자의 스왑 주문이 자산의 시장 가격에 미치는 영향을 의미합니다. 가스비를 지불할 충분한 자금이 없는 경우, 스왑할 토큰 일부가 자동으로 수수료로 사용되므로 가격 영향이 커질 수 있습니다. MetaMask는 가격 영향에 관여하지 않으며 이를 통제하지도 않습니다.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "거래 규모와 이용 가능한 유동성으로 인해 시장 가격보다 약 {{priceImpact}}만큼 낮은 가격을 받게 됩니다. 이 내용은 이미 견적에 반영되어 있습니다.", "price_impact_high": "높은 가격 영향", "price_impact_execution_description": "이 스왑으로 토큰 가치의 약 {{priceImpact}}을(를) 잃게 됩니다. 금액을 낮추거나 유동성이 더 많은 경로를 선택해 보세요.", "proceed": "진행", @@ -6627,8 +6751,8 @@ "total_cost": "총비용", "got_it": "컨펌", "price_impact_warning_title": "높은 가격 영향", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "매우 높은 가격 영향", + "price_impact_error_description": "이번 스왑으로 토큰의 시장 가격 대비 약 {{priceImpact}}만큼 손실이 발생합니다. 더 작은 규모로 거래하거나 유동성이 더 높은 경로를 선택하면 더 나은 비율을 받을 수 있습니다." }, "quote_expired_modal": { "title": "새로운 견적이 있습니다", @@ -6940,7 +7064,7 @@ "upgrade_title": "메탈 카드로 업그레이드", "continue_button": "계속", "virtual_card": { - "name": "Virtual Card", + "name": "가상 카드", "price": "수수료", "feature_1": "Apple Pay 및 Google Pay용 가상 카드", "feature_2": "암호화폐로 결제 (USDC, USDT, WETH 등)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "메탈 카드", "price": "연 $199", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "가상 카드의 모든 혜택에 더해, 다음 특전도 제공됩니다.", + "feature_1": "프리미엄 각인 메탈 카드", + "feature_2": "연간 첫 $10,000 사용액에 대해 3% 캐시백", "feature_3": "해외 결제 수수료 없음" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "연간 최대 $300 캐시백 적립", + "upgrade_to_metal_label": "또는 Metal로 업그레이드하고 3배 보상 적립" }, "review_order": { "title": "주문 검토", @@ -7104,7 +7228,7 @@ "ssn_description": "카드 발급사의 요구 사항입니다. 신용 조회는 진행되지 않습니다.", "invalid_ssn": "잘못된 SSN입니다", "invalid_date_of_birth": "유효하지 않은 생년월일입니다. 18세 이상이어야 합니다", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "이름과 성은 인증된 신원 정보와 일치해야 합니다" }, "physical_address": { "title": "주소 추가", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "이용한도가 얼마 남지 않았습니다", "description": "거절을 피하려면 업데이트하세요", - "confirm_button_label": "새 한도 설정" + "confirm_button_label": "새 한도 설정", + "dismiss_button_label": "닫기" }, "need_delegation": { "title": "카드를 활성화해야 합니다", @@ -7301,7 +7426,6 @@ "dismiss": "닫기", "update_success": "지출 한도 변경됨", "update_error": "지출 한도 변경 실패", - "solana_not_supported": "card.metamask.io에서 솔라나 토큰 활성화", "select_token": "토큰 선택", "loading": "사용 가능한 토큰 불러오는 중...", "load_error": "토큰을 불러올 수 없습니다. 다시 시도해 주세요.", @@ -7343,9 +7467,7 @@ "limited": "제한됨", "not_enabled": "활성화되지 않음", "update_success": "지출 우선순위 변경됨", - "update_error": "지출 우선순위 변경 실패", - "solana_not_supported_button_title": "솔라나 네트워크의 다른 토큰", - "solana_not_supported_button_description": "card.metamask.io에서 활성화" + "update_error": "지출 우선순위 변경 실패" }, "card_authentication": { "title": "카드 계정에 로그인", @@ -7443,6 +7565,11 @@ "title": "참여 실패", "description": "연결 상태를 확인하고 다시 시도하세요." }, + "version_guard": { + "title": "업데이트 필요", + "description": "보상을 사용하려면 더 최신 버전의 MetaMask가 필요합니다. 계속하려면 업데이트하세요.", + "update_button": "MetaMask 업데이트" + }, "season_error": { "error_fetching_title": "시즌을 불러올 수 없습니다", "error_fetching_description": "연결 상태를 확인하고 다시 시도하세요.", @@ -7525,7 +7652,6 @@ "main_title": "보상", "referral_title": "추천", "tab_overview_title": "개요", - "tab_snapshots_title": "스냅샷", "tab_activity_title": "활동", "referral_stats_earned_from_referrals": "추천을 통해 적립", "referral_stats_referrals": "추천", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "이번 시즌에는 보상을 받지 못하셨습니다. 다음 기회를 기다려 주세요.", "verifying_rewards": "회원님이 보상을 수령하기 전에 모든 정보가 정확한지 확인하고 있습니다." }, + "previous_season_view": { + "title": "이전 시즌" + }, "season_status": { "points_earned": "포인트 획득함" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "적용 중인 부스트", "season_1": "시즌 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 보너스 계산기", + "description": "스테이블코인을 mUSD로 전환하면 얼마나 적립할 수 있는지 확인해 보세요.", + "amount_label": "전환 금액", + "estimated_bonus": "예상 연환산 보너스: 최대 3%", + "initial_amount": "초기 금액", + "daily_bonus": "매일 청구 가능 보너스", + "annualized_bonus": "연환산 보너스", + "disclaimer": "이는 추정치일 뿐입니다. 보너스는 변경될 수 있습니다.", "buy_button": "mUSD 구매", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD로 스왑" }, "upcoming_rewards": { "title": "잠긴 리워드", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "불러올 수 없음" }, - "snapshot": { + "campaign": { "starts_date": "시작일: {{date}}", "ends_date": "종료일: {{date}}", - "results_coming_soon": "결과 곧 공개", - "tokens_on_the_way": "토큰 지급 예정", + "ended_date": "Ended {{date}}", "pill_up_next": "다음 일정", - "pill_live_now": "지금 진행 중", - "pill_calculating": "계산 중", - "pill_results_ready": "결과 준비 완료", - "pill_complete": "완료" - }, - "snapshots_section": { - "title": "스냅샷", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", - "retry_button": "다시 시도" - }, - "snapshots_tab": { + "pill_active": "진행 중", + "pill_complete": "완료", + "enter_now": "지금 참가하기", + "entered": "참가 완료", + "participant_count": "#{{count}}", + "opt_in_cta": "참여하기", + "opt_in_sheet_title": "캠페인 참여", + "opt_in_sheet_description_pre_link": "'참여하기'를 클릭하면 MetaMask 보상 프로그램에 동의하는 것입니다", + "opt_in_sheet_link_text": "추가 이용 약관 및 개인정보 처리방침에 동의하는 것이 됩니다", + "opt_in_sheet_description_post_link": "MetaMask는 온체인 활동을 추적하여 보상을 자동으로 지급합니다.", + "geo_restriction_banner_title": "회원님의 지역에서 사용할 수 없습니다", + "geo_restriction_banner_description": "현지 규정으로 인해 이 캠페인은 거주 지역에서 사용할 수 없습니다." + }, + "campaign_mechanics": { + "title": "운영 방식" + }, + "campaign_details": { + "start_date": "시작일: {{date}}", + "end_date": "종료일: {{date}}", + "opt_in": "참여하기", + "opting_in": "참여 중...", + "opted_in": "이 캠페인에 참여했습니다", + "opt_in_error": "참여하지 못했습니다. 다시 시도하세요.", + "join_campaign": "캠페인 참여", + "checking_opt_in_status": "참여 상태 확인 중", + "swap": "스와프", + "how_it_works": "작동 방식" + }, + "campaigns_preview": { + "title": "캠페인", + "coming_soon": "곧 추가 예정", + "notify_me": "알림 받기" + }, + "earn_rewards": { + "title": "보상 받기", + "musd_title": "스테이블코인 최대 3% 보너스", + "musd_subtitle": "mUSD 보너스 계산하기", + "card_title": "최대 3% 캐시백", + "card_subtitle": "지금 MetaMask 카드를 받으세요", + "card_subtitle_cardholder": "MetaMask 카드 혜택을 이용하세요" + }, + "campaigns_view": { + "title": "캠페인", "active_title": "진행 중", "upcoming_title": "예정", "previous_title": "이전", - "empty_state": "사용 가능한 스냅샷 없음", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", + "empty_state": "참여 가능한 캠페인이 없습니다", + "error_title": "캠페인을 불러올 수 없습니다", + "error_description": "캠페인을 불러오지 못했습니다. 다시 시도하세요.", "retry_button": "다시 시도", "refreshing": "새로 고침 중..." } @@ -7953,13 +8112,12 @@ "continue": "계속" }, "connecting": { - "title": "{{device}} 연결", + "title": "{{device}} 연결 중...", "searching": "{{device}} 찾는 중...", - "tips_header": "계속하려면 다음 사항을 확인하세요.", + "tips_header": "다음을 확인하세요:", "tip_unlock": "{{device}} 잠금이 해제되어 있어야 합니다", "tip_open_app": "이더리움 앱이 열려 있어야 합니다", "tip_enable_bluetooth": "블루투스가 켜져 있어야 합니다", - "tip_dnd_off": "방해 금지 모드는 꺼져 있어야 합니다", "tip_bluetooth_permission": "위치 및 블루투스 권한이 허용되어 있어야 합니다", "tip_bluetooth_permission_v12": "근처 장치 권한이 허용되어 있어야 합니다", "tip_stay_close": "장치와 휴대전화가 서로 가까이 있어야 합니다" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "근처 장치 권한이 필요합니다", "bluetooth_off": "장치에 연결하려면 블루투스를 켜세요", "bluetooth_scan_failed": "장치를 스캔하지 못했습니다. 다시 시도하세요", - "bluetooth_connection_failed": "장치의 블루투스를 활성화한 후 계속하세요", + "bluetooth_connection_failed": "기기 연결에 실패했습니다. 다시 시도하세요", "not_supported": "지원되지 않는 작업입니다", "unknown_error": "이 계정에 대한 비밀복구구문 또는 패스프레이즈로 {{device}}이(가) 설정되어 있는지 확인하세요" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "현금", + "cash_empty_description": "아직 mUSD가 없습니다. 홈페이지의 현금 섹션에서 스테이블코인을 mUSD로 전환하세요.", + "cash_empty_description_network_filter": "이 네트워크에는 mUSD가 없습니다. mUSD를 확인하려면 네트워크를 전환하세요.", "tokens": "토큰", "perpetuals": "영구계약", "predictions": "예측", + "whats_happening": "주요 동향", + "whats_happening_categories": { + "geopolitical": "지정학", + "macro": "거시 경제", + "regulatory": "규제", + "technical": "기술", + "social": "사회", + "other": "기타" + }, "defi": "디파이", "nfts": "NFT", "import_nfts": "NFT 가져오기", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index a612b4b87de..3a2d1147550 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -20,6 +20,12 @@ "update": "Atualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Enviando ativos para endereço de queima" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Aviso sobre contrato de token", + "message": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência." }, "gas_sponsorship_reserve_balance": { "message": "O patrocínio de gas não está disponível para esta transação. Você precisará manter pelo menos %{minBalance} %{nativeTokenSymbol} em sua conta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Não foi possível resolver o nome", "invalid_address": "Endereço inválido", "contractAddressError": "Você está enviando tokens para o endereço do contrato do token. Isso pode levar à perda desses tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Endereço de contrato inteligente", + "smart_contract_address_warning": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência.", "i_understand": "Eu compreendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "O stop loss deve ser um preço {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "O stop loss deve ser um preço de liquidação {{direction}}", "stop_loss_order_view_warning": "O stop loss é um preço de liquidação {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "acima", "below": "abaixo", "done": "Pronto", @@ -2086,14 +2094,15 @@ "a_closer_look": "Uma análise mais detalhada", "whats_being_said": "O que as pessoas dizem", "footer_disclaimer": "Resumo de IA apenas para fins informativos", - "trade_button": "Negociar", + "swap_button": "Troca", + "buy_button": "Comprar", "sources_count": "+{{count}} fontes", "sources_title": "Fontes de notícias", "feedback_submitted": "Feedback enviado", "helpful_prompt": "Isso foi útil?", "feedback": { "title": "Comentário", - "description": "Ajude a aprimorar nossas análises de mercado geradas por IA.", + "description": "Sua resposta ajuda a melhorar nossos resumos de IA.", "not_relevant": "Não relevante", "not_accurate": "Inexato", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponível", "claim_amount_text": "Resgatar $ {{amount}}", "claim_winnings_text": "Resgatar ganhos", - "claiming_text": "Claiming...", + "claiming_text": "Reivindicando...", "unrealized_pnl_label": "P&L não realizados", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Não foi possível carregar", @@ -2287,7 +2296,7 @@ "try_again": "Tentar novamente" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reivindicação já em andamento" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Taxa paga à bolsa ou ao mercado", "total_incl_fees": "incluindo taxas", "close": "Fechar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Os preços apresentados pressupõem que sua ordem seja totalmente executada. Os valores reais podem variar se a ordem for executada apenas parcialmente.", + "deposit_fee": "Taxa de depósito", + "deposit_fee_description": "Taxa cobrada para depositar fundos em seu saldo de previsões" }, "error": { "title": "Não foi possível conectar-se às previsões", @@ -3059,6 +3068,7 @@ "networks_no_results": "Nenhuma rede encontrada", "network_name_label": "Nome da rede", "network_name_placeholder": "Nome da rede (opcional)", + "required": "Obrigatório", "network_rpc_url_label": "URL da RPC", "network_rpc_name_label": "Nome da RPC", "network_rpc_placeholder": "Nova rede RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esse recurso alerta você sobre atividades mal-intencionadas analisando ativamente as solicitações de transações e assinaturas.", "security_alerts": "Alertas de segurança", "security_alerts_desc": "Esse recurso alerta sobre atividades mal-intencionadas por meio da análise local de solicitações de transações e assinaturas. Sempre realize sua própria devida diligência antes de aprovar solicitações. Não há garantia de que esse recurso detectará toda e qualquer atividade mal-intencionada. Ao ativar esse recurso, você concorda com os termos de uso do provedor.", + "smart_account_dapp_requests_heading": "Solicitações de conta inteligente vindas de dapps", + "smart_account_dapp_requests_desc": "Permite que dapps (aplicativos descentralizados) solicitem recursos de conta inteligente para contas padrão. Isso não afetará contas que já são contas inteligentes.", "smart_transactions_opt_in_heading": "Transações inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Ative as transações inteligentes para fazer transações mais confiáveis e seguras nas redes suportadas.", "smart_transactions_learn_more": "Saiba mais", @@ -3566,6 +3578,53 @@ "activity": "Atividade do {{symbol}}", "disclaimer": "Os dados de mercado são fornecidos por fontes terceirizadas, como o CoinGecko. Os dados são apenas para fins informativos. A MetaMask não se responsabiliza por sua exatidão." }, + "security_trust": { + "title": "Segurança e confiança", + "malicious": "Malicioso", + "risky": "De risco", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} é um token malicioso. Evite interagir com ele ou negociá-lo.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} é ativamente negociado e amplamente reconhecido. A verificação não representa um endosso por parte da MetaMask.", + "risky_token_title": "Token de risco", + "risky_token_description": "Sinais de alerta detectados em {{symbol}}. Pesquise cuidadosamente antes de negociar este token.", + "malicious_token_sheet_description": "Sinais de risco graves foram detectados em {{symbol}}. Recomendamos não negociar este token.", + "got_it": "Entendi", + "proceed": "Prosseguir", + "cancel": "Cancelar", + "data_unavailable": "Dados de segurança não disponíveis", + "subtitle_known": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_no_issues": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_suspicious": "Sinais de alerta detectados. Analise cuidadosamente os problemas sinalizados antes de negociar este ativo.", + "subtitle_malicious": "Sinais de risco graves foram detectados. Recomendamos evitar este ativo.", + "subtitle_unavailable": "Não foi possível carregar a análise de segurança para este token.", + "token_distribution": "Distribuição de token", + "total_supply": "Fornecimento total", + "top_10_holders": "10 principais detentores", + "other": "Outro", + "no_hidden_fees_detected": "Nenhuma taxa oculta detectada", + "buy_sell_tax": "Imposto sobre compra/venda", + "buy_tax": "Imposto sobre compra", + "sell_tax": "Imposto sobre venda", + "transfer": "Transferir", + "token_info": "Informações do token", + "created": "Criado em", + "token_age": "Idade do token", + "network": "Rede", + "type": "Digite", + "official_links": "Links oficiais", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/D", + "verified": "Verificado", + "no_issues": "Sem problemas", + "suspicious": "Suspeito", + "malicious_label": "Malicioso", + "more": "mais", + "evaluation_disclaimer": "Esta análise de segurança tem caráter meramente avaliativo e não constitui um endosso ou recomendação de negociação." + }, "account_details": { "title": "Detalhes da conta", "share_account": "Compartilhar", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bônus resgatável", "claim_bonus": "Resgatar bônus", "claim_bonus_subtitle": "O bônus será pago em {{networkName}}.", + "percentage_bonus_on_linea": "Bônus de {{percentage}}% na Linea", + "claim": "Resgatar", + "sounds_good": "Parece bom", + "claimable_bonus_tooltip_with_percentage": "Você ganhou {{percentage}}% de bônus anualizado por manter mUSD. Seu bônus pode ser resgatado diariamente na Linea.", "empty_state_cta": { "heading": "Empreste {{tokenSymbol}} e ganhe", "body": "Empreste seus {{tokenSymbol}} com {{protocol}} e ganhe", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Suas stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganhe", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Você não possui saldo de recursos suficiente para realizar esta ação." }, - "trx_unstaking_in_progress": "Desfazer staking de {{amount}} TRX em andamento. O processo de desfazer staking leva 14 dias.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Retirada de staking de {{amount}} TRX em andamento", + "description": "O processo de retirada de staking levará 14 dias" + }, + "unstaked_banner": { + "title": "Retirada de staking de {{amount}} TRX concluída", + "description": "Seus TRX retirados de staking já podem ser sacados", + "button": "Sacar", + "error": "Falha ao sacar" + } }, "stake_eth": "Fazer staking de ETH", "unstake_eth": "Retirar ETH do staking", @@ -6376,7 +6498,8 @@ "approve": "Aprovar solicitação", "perps_deposit": "Adicionar fundos", "predict_deposit": "Adicionar fundos de previsão", - "predict_withdraw": "Sacar" + "predict_withdraw": "Sacar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este site quer permissão para gastar seus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transação {{index}}", "transaction": "Transações", "available_balance": "Saldo disponível: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Adicionar fundos", "deposit_edit_amount_predict_withdraw": "Sacar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ainda não oferecemos suporte a carteiras de hardware. Use uma hot wallet para continuar.", "hardware_wallet_not_supported_solana": "Carteiras de hardware ainda não são compatíveis com Solana. Use uma hot wallet para continuar.", "price_impact_info_title": "Impacto do preço", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "É assim que sua negociação altera o preço de mercado de um token. Ele depende do volume da negociação, da liquidez disponível e das taxas do fornecedor. A MetaMask não controla o impacto no preço.", "price_impact_info_gasless_description": "O impacto no preço reflete como sua ordem de troca afeta o preço de mercado do ativo. Se você não tiver fundos suficientes para o gás, parte do seu token de origem será automaticamente alocada para cobrir taxas, o que aumenta o impacto no preço. A MetaMask não influencia nem controla o impacto no preço.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Devido ao volume da sua negociação e à liquidez disponível, você receberá cerca de {{priceImpact}} a menos do que o preço de mercado. Isso já está incluído em sua cotação.", "price_impact_high": "Alto impacto no preço", "price_impact_execution_description": "Você perderá aproximadamente {{priceImpact}} do valor do seu token nesta troca. Tente reduzir o valor ou escolher uma rota com mais liquidez.", "proceed": "Prosseguir", @@ -6627,8 +6751,8 @@ "total_cost": "Custo total", "got_it": "Entendi", "price_impact_warning_title": "Alto impacto no preço", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto de preço muito elevado", + "price_impact_error_description": "Você perderá aproximadamente {{priceImpact}} do valor de mercado do seu token neste swap. Tente uma negociação de valor menor ou uma rota com mais liquidez para melhorar sua taxa." }, "quote_expired_modal": { "title": "Novas cotações estão disponíveis", @@ -6940,7 +7064,7 @@ "upgrade_title": "Faça upgrade para Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Cartão virtual", "price": "Gratuito", "feature_1": "Cartão virtual para Apple Pay e Google Pay", "feature_2": "Pague com criptomoedas (USDC, USDT, WETH e várias outras)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Cartão Metal", "price": "US$ 199/ano", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tudo em formato virtual, além de:", + "feature_1": "Cartão premium de metal entalhado", + "feature_2": "3% de cashback nos primeiros US$ 10.000/ano", "feature_3": "Sem taxas de transação internacional" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Ganhe até US$ 300 em cashback anualmente", + "upgrade_to_metal_label": "Ou faça upgrade para Metal e ganhe 3x mais recompensas" }, "review_order": { "title": "Confira sua ordem", @@ -7104,7 +7228,7 @@ "ssn_description": "Exigido pela emissora do cartão. Nenhuma verificação de crédito será realizada.", "invalid_ssn": "NSS inválido", "invalid_date_of_birth": "Data de nascimento inválida. Você deve ter pelo menos 18 anos", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nome e sobrenome devem corresponder à sua identidade verificada" }, "physical_address": { "title": "Insira seu endereço", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Você está próximo do seu limite de gastos", "description": "Atualize para evitar recusas", - "confirm_button_label": "Definir novo limite" + "confirm_button_label": "Definir novo limite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Você precisa habilitar seu cartão", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Limite de gastos atualizado com sucesso", "update_error": "Falha ao atualizar limite de gastos", - "solana_not_supported": "Habilite tokens Solana em card.metamask.io", "select_token": "Selecionar token", "loading": "Carregando tokens disponíveis...", "load_error": "Não foi possível carregar os tokens. Tente novamente.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Não ativado", "update_success": "Prioridade de gastos atualizada com sucesso", - "update_error": "Falha ao atualizar prioridade de gastos", - "solana_not_supported_button_title": "Outros tokens em Solana", - "solana_not_supported_button_description": "Ative em card.metamask.io" + "update_error": "Falha ao atualizar prioridade de gastos" }, "card_authentication": { "title": "Faça login na conta do seu cartão", @@ -7443,6 +7565,11 @@ "title": "A participação falhou", "description": "Verifique sua conexão e tente novamente." }, + "version_guard": { + "title": "Atualização necessária", + "description": "O uso do programa de Recompensas requer uma versão mais recente da MetaMask. Atualize para continuar.", + "update_button": "Atualizar MetaMask" + }, "season_error": { "error_fetching_title": "Não foi possível carregar a temporada", "error_fetching_description": "Verifique sua conexão e tente novamente.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Indicações", "tab_overview_title": "Visão geral", - "tab_snapshots_title": "Capturas de tela", "tab_activity_title": "Atividade", "referral_stats_earned_from_referrals": "Ganho por meio de indicações", "referral_stats_referrals": "Indicações", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Você não ganhou recompensas nesta temporada, mas sempre haverá uma próxima vez.", "verifying_rewards": "Estamos verificando se tudo está correto antes de você resgatar suas recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Pontos ganhos" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Incrementos ativos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora de bônus mUSD", + "description": "Veja quanto você pode ganhar convertendo suas stablecoins para mUSD.", + "amount_label": "Valor convertido", + "estimated_bonus": "Bônus anual estimado: até 3%", + "initial_amount": "Valor inicial", + "daily_bonus": "Bônus diário resgatável", + "annualized_bonus": "Bônus anualizado", + "disclaimer": "Este valor é apenas uma estimativa. O bônus está sujeito a alterações.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Converter para mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Não foi possível carregar" }, - "snapshot": { + "campaign": { "starts_date": "Começa em {{date}}", "ends_date": "Termina em {{date}}", - "results_coming_soon": "Resultados em breve", - "tokens_on_the_way": "Tokens a caminho", + "ended_date": "Ended {{date}}", "pill_up_next": "Em seguida", - "pill_live_now": "Ao vivo agora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados prontos", - "pill_complete": "Concluído" - }, - "snapshots_section": { - "title": "Capturas de tela", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", - "retry_button": "Tentar novamente" - }, - "snapshots_tab": { + "pill_active": "Em tempo real", + "pill_complete": "Concluído", + "enter_now": "Insira agora", + "entered": "Inserido", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Participe da campanha", + "opt_in_sheet_description_pre_link": "Ao clicar em \"Participar\", você concorda com o programa de Recompensas da MetaMask", + "opt_in_sheet_link_text": "Termos de uso suplementares e aviso de privacidade", + "opt_in_sheet_description_post_link": "Rastrearemos a atividade onchain para recompensar você automaticamente.", + "geo_restriction_banner_title": "Não disponível em sua região", + "geo_restriction_banner_description": "Esta campanha não está disponível na sua região devido a regulamentos locais." + }, + "campaign_mechanics": { + "title": "Mecânica" + }, + "campaign_details": { + "start_date": "Início: {{date}}", + "end_date": "Término: {{date}}", + "opt_in": "Participar", + "opting_in": "Optando por participar...", + "opted_in": "Você optou por participar desta campanha", + "opt_in_error": "Falha ao optar por participar. Tente novamente.", + "join_campaign": "Participe da campanha", + "checking_opt_in_status": "Verificando status de participação", + "swap": "Troca", + "how_it_works": "Como funciona" + }, + "campaigns_preview": { + "title": "Campanhas", + "coming_soon": "Em breve", + "notify_me": "Avisar-me" + }, + "earn_rewards": { + "title": "Ganhe recompensas", + "musd_title": "Até 3% de bônus sobre stablecoins", + "musd_subtitle": "Calcule seu bônus em mUSD", + "card_title": "Até 3% em cashback", + "card_subtitle": "Peça já o seu Cartão MetaMask", + "card_subtitle_cardholder": "Acesse os benefícios do seu Cartão MetaMask" + }, + "campaigns_view": { + "title": "Campanhas", "active_title": "Ativo", "upcoming_title": "Próximo", "previous_title": "Anterior", - "empty_state": "Nenhuma captura de tela disponível", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", + "empty_state": "Nenhuma campanha disponível", + "error_title": "Não foi possível carregar campanhas", + "error_description": "Não foi possível carregar as campanhas. Tente novamente.", "retry_button": "Tentar novamente", "refreshing": "Atualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecte seu {{device}}", + "title": "Conectando seu {{device}}...", "searching": "Procurando {{device}}...", - "tips_header": "Para continuar, certifique-se de que:", + "tips_header": "Certifique-se de que:", "tip_unlock": "Seu {{device}} está desbloqueado", "tip_open_app": "O aplicativo Ethereum está aberto", "tip_enable_bluetooth": "O Bluetooth está ativado", - "tip_dnd_off": "O modo \"Não perturbe\" está desativado", "tip_bluetooth_permission": "Permissão de localização e Bluetooth estão concedidas", "tip_bluetooth_permission_v12": "A permissão para dispositivos próximos foi concedida", "tip_stay_close": "Seu dispositivo permanece próximo ao seu telefone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "É necessário permissão para dispositivos próximos", "bluetooth_off": "Ative o Bluetooth para se conectar ao seu dispositivo", "bluetooth_scan_failed": "Falha ao procurar dispositivos. Tente novamente", - "bluetooth_connection_failed": "Ative o Bluetooth no seu dispositivo para continuar", + "bluetooth_connection_failed": "A conexão com seu dispositivo falhou. Tente novamente", "not_supported": "Esta operação não é suportada", "unknown_error": "Certifique-se de que seu {{device}} esteja configurado com a Frase de Recuperação Secreta ou com a senha para esta conta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Dinheiro em espécie", + "cash_empty_description": "Você ainda não tem mUSD. Converta stablecoins para mUSD na seção \"Dinheiro\" da página inicial.", + "cash_empty_description_network_filter": "Não há mUSD nesta rede. Mude de rede para ver seus mUSD.", "tokens": "Tokens", "perpetuals": "Perpétuos", "predictions": "Previsões", + "whats_happening": "O que está acontecendo", + "whats_happening_categories": { + "geopolitical": "Geopolítica", + "macro": "Macro", + "regulatory": "Regulatório", + "technical": "Técnico", + "social": "Redes sociais", + "other": "Outro" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "Importar NFTs", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index be640321f3f..5710fe027b2 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -20,6 +20,12 @@ "update": "Обновить" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Оповещение", @@ -120,8 +126,8 @@ "title": "Активы отправляются на адрес для сжигания" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Предупреждение о контракте токена", + "message": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод." }, "gas_sponsorship_reserve_balance": { "message": "Для этой транзакции недоступна спонсорская оплата газа. Вам необходимо постоянно иметь на счету не менее %{minBalance} %{nativeTokenSymbol}.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Не удалось разрешить имя", "invalid_address": "Недействительный адрес", "contractAddressError": "Вы отправляете токены на адрес контракта токена. Это может привести к потере этих токенов.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Адрес смарт-контракта", + "smart_contract_address_warning": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод.", "i_understand": "Я понимаю", "cancel": "Отмена" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Стоп-лосс должен быть по цене {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Стоп-лосс должен быть равен цене ликвидации {{direction}}", "stop_loss_order_view_warning": "Стоп-лосс — это цена ликвидации {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "выше", "below": "ниже", "done": "Готово", @@ -2086,14 +2094,15 @@ "a_closer_look": "Подробный обзор", "whats_being_said": "Что говорят", "footer_disclaimer": "ИИ-сводка только для информации", - "trade_button": "Торговать", + "swap_button": "Обменять", + "buy_button": "Купить", "sources_count": "+{{count}} источника(-ов)", "sources_title": "Источники новостей", "feedback_submitted": "Отзыв отправлен", "helpful_prompt": "Это было полезно?", "feedback": { "title": "Отзыв", - "description": "Помогите улучшить наши обзоры рынка, созданные с помощью ИИ.", + "description": "Ваш ответ помогает улучшить наши сводки, создаваемые ИИ.", "not_relevant": "Неактуально", "not_accurate": "Неточно", "hard_to_understand": "Сложно для понимания", @@ -2162,7 +2171,7 @@ "sell_position": "Позиция на продажу", "cash_out": "Вывести деньги", "cash_out_info": "Средства будут зачислены на ваш доступный баланс.", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} по {{price}}", "at_price_per_share": "Продажа {{size}} акции(-ий) по цене {{price}}", "cashout_info": "{{amount}} при {{outcome}} за {{initialPrice}}", "cashout_info_multiple": "{{amount}} при {{outcomeGroupTitle}} • {{outcome}} по цене {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Доступный баланс", "claim_amount_text": "Получить {{amount}} $", "claim_winnings_text": "Получить выигрыши", - "claiming_text": "Claiming...", + "claiming_text": "Получение...", "unrealized_pnl_label": "Нереализованные П/У", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Не удалось загрузить", @@ -2287,7 +2296,7 @@ "try_again": "Повторить попытку" }, "in_progress": { - "title": "Claim already in progress" + "title": "Запрос уже выполняется" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Комиссия, уплачиваемая бирже или рынку", "total_incl_fees": "вкл. комиссии", "close": "Закрыть", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Указанные цены действительны при условии полного выполнения вашего ордера. Фактические суммы могут отличаться, если ордер выполнен лишь частично.", + "deposit_fee": "Комиссия за депозит", + "deposit_fee_description": "Комиссия, взимаемая за внесение средств на ваш баланс прогнозов" }, "error": { "title": "Невозможно подключиться к прогнозам", @@ -3059,6 +3068,7 @@ "networks_no_results": "Сети не найдены", "network_name_label": "Имя сети", "network_name_placeholder": "Имя сети (необязательно)", + "required": "Требуется", "network_rpc_url_label": "URL-адрес RPC", "network_rpc_name_label": "Название RPC", "network_rpc_placeholder": "Новая сеть RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Эта функция предупреждает вас о вредоносной активности, активно проверяя транзакции и запросы на подпись.", "security_alerts": "Оповещения безопасности", "security_alerts_desc": "Эта функция предупреждает вас о вредоносной активности, проверяя запросы транзакций и подписей локально. Всегда проводите комплексную проверку перед утверждением каких-либо запросов. Нет никакой гарантии, что эта функция обнаружит всю вредоносную активность. Включая эту функцию, вы соглашаетесь с условиями использования поставщика.", + "smart_account_dapp_requests_heading": "Запросы на создание смарт-счетов от dapps", + "smart_account_dapp_requests_desc": "Разрешите dapps запрашивать функции смарт-счетов для стандартных счетов. Это не повлияет на счета, которые уже являются смарт-счетами.", "smart_transactions_opt_in_heading": "Умные транзакции", "smart_transactions_opt_in_desc_supported_networks": "Включите функцию «Умные транзакции» для более надежных и безопасных транзакций в поддерживаемых сетях.", "smart_transactions_learn_more": "Подробнее", @@ -3566,6 +3578,53 @@ "activity": "Активность {{symbol}}", "disclaimer": "Рыночные данные предоставлены сторонними источниками, такими как CoinGecko. Данные носят исключительно информационный характер. MetaMask не несет ответственности за их точность." }, + "security_trust": { + "title": "Безопасность и доверие", + "malicious": "Вредоносный", + "risky": "Рискованный", + "malicious_token_title": "Вредоносный токен", + "malicious_token_description": "{{symbol}} — это вредоносный токен. Избегайте взаимодействия с ним или торговли им.", + "verified_token_title": "Проверенный токен", + "verified_token_description": "{{symbol}} активно торгуется и широко известен. Подтверждение не является одобрением со стороны MetaMask.", + "risky_token_title": "Рискованный токен", + "risky_token_description": "В отношении {{symbol}} обнаружены предупреждающие сигналы. Перед началом торговли этим токеном внимательно изучите информацию.", + "malicious_token_sheet_description": "Обнаружены серьезные сигналы риска для {{symbol}}. Мы рекомендуем воздержаться от торговли этим токеном.", + "got_it": "Понятно", + "proceed": "Продолжить", + "cancel": "Отмена", + "data_unavailable": "Данные о безопасности недоступны", + "subtitle_known": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_no_issues": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_suspicious": "Обнаружены предупреждающие сигналы. Внимательно изучите отмеченные проблемы, прежде чем совершать сделки с этим активом.", + "subtitle_malicious": "Обнаружены серьезные сигналы риска. Рекомендуем воздержаться от покупки этого актива.", + "subtitle_unavailable": "Не удалось загрузить анализ безопасности для этого токена.", + "token_distribution": "Распределение токенов", + "total_supply": "Общий запас", + "top_10_holders": "Топ-10 держателей", + "other": "Другое", + "no_hidden_fees_detected": "Скрытых платежей не обнаружено", + "buy_sell_tax": "Налог на куплю-продажу", + "buy_tax": "Налог на покупку", + "sell_tax": "Налог на продажу", + "transfer": "Перевести", + "token_info": "Информация о токене", + "created": "Создан", + "token_age": "Возраст токена", + "network": "Сеть", + "type": "Тип", + "official_links": "Официальные ссылки", + "website": "Веб-сайт", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Н.Д.", + "verified": "Проверенный", + "no_issues": "Нет проблем", + "suspicious": "Подозрительный", + "malicious_label": "Вредоносный", + "more": "больше", + "evaluation_disclaimer": "Данный обзор безопасности носит исключительно ознакомительный характер и не является рекомендацией или одобрением для совершения сделок." + }, "account_details": { "title": "Сведения о счете", "share_account": "Поделиться", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Встребуемый бонус", "claim_bonus": "Получить бонус", "claim_bonus_subtitle": "Бонус будет выплачен в сети {{networkName}}.", + "percentage_bonus_on_linea": "Бонус {{percentage}}% на Linea", + "claim": "Получить", + "sounds_good": "Звучит отлично", + "claimable_bonus_tooltip_with_percentage": "Годовой бонус в размере {{percentage}}% от суммы вашего накопленного бонуса за владение mUSD. Ваш бонус можно получить ежедневно на Linea.", "empty_state_cta": { "heading": "Давайте взаймы {{tokenSymbol}} и зарабатывайте", "body": "Одолжите свой {{tokenSymbol}} с помощью {{protocol}} и зарабатывайте", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ваши стейблкоины" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Заработать", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "У вас недостаточно ресурсов для выполнения этого действия." }, - "trx_unstaking_in_progress": "Выполняется вывод {{amount}} TRX из стейкинга. Он займет 14 дней.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Выполняется отмена стейкинга {{amount}} TRX", + "description": "Для отмены стейкинга потребуется 14 дней" + }, + "unstaked_banner": { + "title": "Отмена стейкинга {{amount}} TRX завершена", + "description": "Теперь вы можете вывести свои TRX, стейкинг которых отменили", + "button": "Вывести средства", + "error": "Ошибка вывода средств" + } }, "stake_eth": "Выполнить стейкинг ETH", "unstake_eth": "Отменить стейкинг ETH", @@ -6376,7 +6498,8 @@ "approve": "Одобрить запрос", "perps_deposit": "Внести средства", "predict_deposit": "Внести средства для прогнозирования", - "predict_withdraw": "Вывести средства" + "predict_withdraw": "Вывести средства", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Этот сайт запрашивает разрешение на трату ваших токенов.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Транзакция {{index}}", "transaction": "Защита", "available_balance": "Доступный баланс: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Продолжить", "deposit_edit_amount_done": "Внести средства", "deposit_edit_amount_predict_withdraw": "Вывести средства", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Аппаратные кошельки пока не поддерживаются. Используйте горячий кошелек, чтобы продолжить.", "hardware_wallet_not_supported_solana": "Аппаратные кошельки пока не поддерживаются для Solana. Используйте горячий кошелек, чтобы продолжить.", "price_impact_info_title": "Влияние на цену", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Вот как ваша сделка влияет на рыночную цену токена. Это зависит от размера сделки, доступной ликвидности и комиссий поставщика. MetaMask не контролирует влияние на цену.", "price_impact_info_gasless_description": "Влияние на цену отражает, как ваш ордер на своп влияет на рыночную цену актива. Если у вас недостаточно средств для оплаты газа, часть вашего исходного токена автоматически выделяется на покрытие комиссий, что увеличивает влияние на цену. MetaMask не влияет на воздействие цену и не контролирует его.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Из-за размера вашей сделки и доступной ликвидности вы получите примерно на {{priceImpact}} меньше рыночной цены. Это уже учтено в вашей котировке.", "price_impact_high": "Сильное влияние на цену", "price_impact_execution_description": "В результате этого обмена вы потеряете примерно {{priceImpact}} от стоимости вашего токена. Попробуйте уменьшить сумму или выбрать более ликвидный вариант.", "proceed": "Продолжить", @@ -6627,8 +6751,8 @@ "total_cost": "Общая стоимость", "got_it": "Понятно", "price_impact_warning_title": "Сильное влияние на цену", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Очень высокое влияние на цену", + "price_impact_error_description": "В результате этой сделки вы потеряете примерно {{priceImpact}} от рыночной цены вашего токена. Попробуйте совершить сделку меньшего размера или использовать более ликвидный путь, чтобы улучшить свой курс." }, "quote_expired_modal": { "title": "Доступны новые котировки", @@ -6940,7 +7064,7 @@ "upgrade_title": "Повысить уровень до Металлической", "continue_button": "Продолжить", "virtual_card": { - "name": "Virtual Card", + "name": "Виртуальная карта", "price": "Бесплатно", "feature_1": "Виртуальная карта для Apple Pay и Google Pay", "feature_2": "Оплата криптовалютой (USDC, USDT, WETH и другие)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Металлическая карта", "price": "$199/год", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Все то же, что и для виртуальной карты, плюс:", + "feature_1": "Премиальная металлическая карта с гравировкой", + "feature_2": "Кешбэк 3% на первые 10 000 $ в год", "feature_3": "Без комиссий за зарубежные транзакции" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Зарабатывайте до 300 $ кешбэка ежегодно", + "upgrade_to_metal_label": "Или перейдите на план Metal и получайте трехкратные бонусы" }, "review_order": { "title": "Проверьте свой заказ", @@ -7104,7 +7228,7 @@ "ssn_description": "Требуется эмитентом карты. Проверка кредитной истории проводиться не будет.", "invalid_ssn": "Недопустимый SSN", "invalid_date_of_birth": "Неверная дата рождения. Ваш возраст должен быть не менее 18 лет", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Имя и фамилия должны совпадать с вашими подтвержденными данными" }, "physical_address": { "title": "Введите свой адрес", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Вы приближаетесь к своему лимиту расходов", "description": "Обновите, чтобы избежать отказов", - "confirm_button_label": "Установить новый лимит" + "confirm_button_label": "Установить новый лимит", + "dismiss_button_label": "Отклонить" }, "need_delegation": { "title": "Вам нужно включить вашу карту", @@ -7301,7 +7426,6 @@ "dismiss": "Отклонить", "update_success": "Лимит расходов успешно обновлен", "update_error": "Не удалось обновить лимит расходов", - "solana_not_supported": "Включить токены Solana на card.metamask.io", "select_token": "Выбрать токен", "loading": "Загрузка доступных токенов…", "load_error": "Не удалось загрузить токены. Попробуйте еще раз.", @@ -7343,9 +7467,7 @@ "limited": "Ограниченный", "not_enabled": "Не включен", "update_success": "Приоритет расходов успешно обновлен", - "update_error": "Не удалось обновить приоритет расходов", - "solana_not_supported_button_title": "Другие токены на Solana", - "solana_not_supported_button_description": "Включить на card.metamask.io" + "update_error": "Не удалось обновить приоритет расходов" }, "card_authentication": { "title": "Войдите в счет своей карты", @@ -7443,6 +7565,11 @@ "title": "Не удалось согласиться", "description": "Проверьте соединение и повторите попытку." }, + "version_guard": { + "title": "Требуется обновление", + "description": "Для использования функции «Бонусы» требуется более новая версия MetaMask. Обновите приложение, чтобы продолжить.", + "update_button": "Обновить MetaMask" + }, "season_error": { "error_fetching_title": "Не удалось загрузить сезон", "error_fetching_description": "Проверьте соединение и повторите попытку.", @@ -7525,7 +7652,6 @@ "main_title": "Награды", "referral_title": "Рефералы", "tab_overview_title": "Обзор", - "tab_snapshots_title": "Снимки", "tab_activity_title": "Деятельность", "referral_stats_earned_from_referrals": "Заработано на рефералах", "referral_stats_referrals": "Рефералы", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "В этом сезоне вы не получали бонусы, но всегда можно получить их в следующий раз.", "verifying_rewards": "Мы проверяем правильность всех данных, прежде чем вы сможете получить свои бонусы." }, + "previous_season_view": { + "title": "Предыдущий сезон" + }, "season_status": { "points_earned": "Заработанные баллы" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Активные повышающие коэффициенты", "season_1": "Сезон 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Калькулятор бонусов mUSD", + "description": "Посмотрите, сколько вы могли бы заработать, конвертировав ваши стейблкоины в mUSD.", + "amount_label": "Сконвертированная сумма", + "estimated_bonus": "Расчетный годовой бонус: до 3%", + "initial_amount": "Начальная сумма", + "daily_bonus": "Ежедневный бонус, доступный для получения", + "annualized_bonus": "Годовой бонус", + "disclaimer": "Это лишь приблизительная оценка. Размер бонуса может измениться.", "buy_button": "Купить mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Обменять на mUSD" }, "upcoming_rewards": { "title": "Заблокированные награды", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Ошибка загрузки" }, - "snapshot": { + "campaign": { "starts_date": "Начинается {{date}}", "ends_date": "Заканчивается {{date}}", - "results_coming_soon": "Скоро появятся результаты", - "tokens_on_the_way": "Токены в пути", + "ended_date": "Ended {{date}}", "pill_up_next": "Далее", - "pill_live_now": "Уже активно", - "pill_calculating": "Расчет", - "pill_results_ready": "Результаты готовы", - "pill_complete": "Завершено" - }, - "snapshots_section": { - "title": "Снимки", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", - "retry_button": "Повтор" - }, - "snapshots_tab": { + "pill_active": "Идет сейчас", + "pill_complete": "Завершено", + "enter_now": "Принять участие", + "entered": "Уже участвуете", + "participant_count": "#{{count}}", + "opt_in_cta": "Согласиться", + "opt_in_sheet_title": "Присоединиться к кампании", + "opt_in_sheet_description_pre_link": "Нажав на кнопку «Согласиться», вы соглашаетесь на участие в Бонусной программе MetaMask", + "opt_in_sheet_link_text": "Дополнительные условия использования и уведомление о конфиденциальности", + "opt_in_sheet_description_post_link": "Мы будем отслеживать активность в сети, чтобы автоматически начислять вам вознаграждения.", + "geo_restriction_banner_title": "Недоступно в вашем регионе", + "geo_restriction_banner_description": "Данная кампания недоступна в вашем регионе в связи с местными правилами." + }, + "campaign_mechanics": { + "title": "Механизм" + }, + "campaign_details": { + "start_date": "Начинается: {{date}}", + "end_date": "Заканчивается: {{date}}", + "opt_in": "Согласиться", + "opting_in": "Дача согласия...", + "opted_in": "Вы согласились на участие в этой кампании", + "opt_in_error": "Не удалось согласиться. Повторите попытку.", + "join_campaign": "Присоединиться к кампании", + "checking_opt_in_status": "Проверить статуса согласия на участие", + "swap": "Обменять", + "how_it_works": "Как это работает" + }, + "campaigns_preview": { + "title": "Кампании", + "coming_soon": "Скоро появятся", + "notify_me": "Уведомить меня" + }, + "earn_rewards": { + "title": "Заработать вознаграждения", + "musd_title": "Бонус до 3% на стейблкойны", + "musd_subtitle": "Рассчитайте свой бонус в mUSD", + "card_title": "До 3% кешбэка", + "card_subtitle": "Получите свою карту MetaMask прямо сейчас", + "card_subtitle_cardholder": "Воспользуйтесь преимуществами своей карты MetaMask" + }, + "campaigns_view": { + "title": "Кампании", "active_title": "Активно", "upcoming_title": "Далее", "previous_title": "Предыдущее", - "empty_state": "Нет доступных снимков", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", + "empty_state": "Нет доступных кампаний", + "error_title": "Не удалось загрузить кампании", + "error_description": "Нам не удалось загрузить кампании. Повторите попытку.", "retry_button": "Повтор", "refreshing": "Обновление..." } @@ -7953,13 +8112,12 @@ "continue": "Продолжить" }, "connecting": { - "title": "Подключите ваше {{device}}", + "title": "Подключение вашего {{device}}...", "searching": "Поиск {{device}}...", - "tips_header": "Чтобы продолжить, убедитесь, что:", + "tips_header": "Убедитесь:", "tip_unlock": "Ваше {{device}} разблокировано", "tip_open_app": "Приложение Ethereum открыто", "tip_enable_bluetooth": "Bluetooth включен", - "tip_dnd_off": "Режим «Не беспокоить» выключен", "tip_bluetooth_permission": "Разрешен доступ к геолокации и Bluetooth", "tip_bluetooth_permission_v12": "Разрешен доступ к устройствам поблизости", "tip_stay_close": "Ваше устройство находится рядом с телефоном" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Требуется разрешение на доступ к устройствам поблизости", "bluetooth_off": "Включите Bluetooth для подключения к вашему устройству", "bluetooth_scan_failed": "Не удалось выполнить поиск устройств. Повторите попытку", - "bluetooth_connection_failed": "Включите Bluetooth на вашем устройстве, чтобы продолжить", + "bluetooth_connection_failed": "Сбой подключения к вашему устройству. Повторите попытку", "not_supported": "Эта операция не поддерживается", "unknown_error": "Убедитесь, что ваш {{device}} настроен с помощью секретной фразой для восстановления или пароля для этого счета" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Наличные", + "cash_empty_description": "У вас пока нет mUSD. Конвертируйте стейблкоины в mUSD в разделе «Деньги» на главной странице.", + "cash_empty_description_network_filter": "В этой сети нет mUSD. Переключитесь на другую сеть, чтобы увидеть свои mUSD.", "tokens": "Токены", "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", + "whats_happening": "Что происходит", + "whats_happening_categories": { + "geopolitical": "Геополитика", + "macro": "Макро", + "regulatory": "Регулирование", + "technical": "Техника", + "social": "Социальные вопросы", + "other": "Другое" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Импорт NFT", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 6a2b36089ce..9c064573120 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -20,6 +20,12 @@ "update": "I-update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerto", @@ -120,8 +126,8 @@ "title": "Ipapadala ang mga asset sa burn address" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Babala sa kontrata ng token", + "message": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo." }, "gas_sponsorship_reserve_balance": { "message": "Hindi available ang pag-iisponsor ng gas para sa transaksyong ito. Kakailanganin mo ng hindi bababa sa %{minBalance} %{nativeTokenSymbol} sa account mo.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Hindi maresolba ang pangalan", "invalid_address": "Di-wastong address", "contractAddressError": "Nagpapadala ka ng mga token sa address ng kontrata ng token. Maaari itong magresulta sa pagkawala ng mga token na ito.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Address ng smart na kontrata", + "smart_contract_address_warning": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo.", "i_understand": "Nauunawaan ko", "cancel": "Kanselahin" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Ang stop loss ay dapat na {{direction}} {{priceType}} ang presyo", "stop_loss_beyond_liquidation_error": "Ang stop loss ay dapat na {{direction}} ang presyo ng liquidation", "stop_loss_order_view_warning": "Ang stop loss ay {{direction}} ang liquidation price", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "mas mataas", "below": "mas mababa", "done": "Tapos na", @@ -2086,14 +2094,15 @@ "a_closer_look": "Mas malalim na pagtingin", "whats_being_said": "Ano ang sinasabi", "footer_disclaimer": "Para sa impormasyon lang ang buod ng AI", - "trade_button": "Mag-trade", + "swap_button": "Mag-swap", + "buy_button": "Bumili", "sources_count": "+{{count}} (na) pinagmulan", "sources_title": "Mga mapagkukunan ng balita", "feedback_submitted": "Isinumite ang feedback", "helpful_prompt": "Nakatulong ba ito?", "feedback": { "title": "Feedback", - "description": "Tumulong na mapahusay ang mga pananaw sa market na gawa ng AI.", + "description": "Tinutulungan ng sagot mo na mapahusay ang mga buod ng aming AI.", "not_relevant": "Walang kaugnayan", "not_accurate": "Hindi tumpak", "hard_to_understand": "Mahirap maunawaan", @@ -2206,7 +2215,7 @@ "available_balance": "Available na balanse", "claim_amount_text": "I-claim ang ${{amount}}", "claim_winnings_text": "I-claim ang mga panalo", - "claiming_text": "Claiming...", + "claiming_text": "Kini-claim...", "unrealized_pnl_label": "Unrealized P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Hindi mai-load", @@ -2287,7 +2296,7 @@ "try_again": "Subukang muli" }, "in_progress": { - "title": "Claim already in progress" + "title": "Isinasagawa na ang pagki-claim" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Bayad sa palitan o market", "total_incl_fees": "kasama ang mga bayarin", "close": "Isara", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Ipinagpapalagay sa mga ipinakitang presyo na ganap na puno ang order mo. Maaaring iba-iba ang aktwal na mga halaga kapag bahagyang puno lamang ang order.", + "deposit_fee": "Bayad sa deposito", + "deposit_fee_description": "Bayarin na sinisingil para magdeposito ng mga pondo sa iyong balanse ng hula" }, "error": { "title": "Hindi maikonekta sa mga prediksyon", @@ -3059,6 +3068,7 @@ "networks_no_results": "Walang nahanap na network", "network_name_label": "Pangalan ng network", "network_name_placeholder": "Pangalan ng network (opsyonal)", + "required": "Kinakailangan", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "Pangalan ng RPC", "network_rpc_placeholder": "Bagong RPC network", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Ang tampok na ito ay nag-aalerto sa iyo ng masamang aktibidad sa pamamagitan ng aktibong pagsusuri sa mga transaksyon at paghiling ng pirma.", "security_alerts": "Mga alerto sa seguridad", "security_alerts_desc": "Inaalertuhan ka ng tampok na ito sa mga aktibidad na may masamang hangarin sa pamamagitan ng lokal na pagsusuri sa iyong mga transaksyon at kahilingan sa paglagda. Palaging gumawa ng sarili mong pag-iingat bago aprubahan ang anumang mga kahilingan. Walang garantiya na made-detect ng tampok na ito ang lahat ng aktibidad na may masamang hangarin. Sa pagpapagana sa tampok na ito, sumasang-ayon ka sa mga tuntunin ng paggamit ng provider.", + "smart_account_dapp_requests_heading": "Mga kahilingan na smart account mula sa dapps", + "smart_account_dapp_requests_desc": "Hayaan ang dapps na hilingin ang mga feature ng smart account para sa mga standard na account. Hindi nito maaapektuhan ang mga smart account na.", "smart_transactions_opt_in_heading": "Mga Smart Transaction", "smart_transactions_opt_in_desc_supported_networks": "I-on ang mga Smart na Transaksyon para sa mas maaasahan at ligtas na mga transaksyon sa mga suportadong network.", "smart_transactions_learn_more": "Matuto pa", @@ -3566,6 +3578,53 @@ "activity": "Aktibidad ng {{symbol}}", "disclaimer": "Ibinibigay ang market data ng mga pinagmumulang third-party gaya ng CoinGecko. Ang data ay para lamang sa mga layuning pang-impormasyon. Hindi mananagot ang MetaMask para sa katumpakan nito." }, + "security_trust": { + "title": "Seguridad at tiwala", + "malicious": "Mapaminsala", + "risky": "Mapanganib", + "malicious_token_title": "Mapanganib na token", + "malicious_token_description": "Ang {{symbol}} ay isang mapaminsalang token. Iwasang makipag-ugnayan o i-trade ito.", + "verified_token_title": "Na-verify na token", + "verified_token_description": "Ang {{symbol}} ay aktibong tini-trade at malawakang kinikilala. Ang verification ay hindi pag-eendorso ng MetaMask.", + "risky_token_title": "Mapanganib na token", + "risky_token_description": "Natuklasan ang mga senyales ng pag-iingat sa {{symbol}}. Maingat na magsaliksik bago i-trade ang token na ito.", + "malicious_token_sheet_description": "Natuklasan ang mga senyales ng malubhang panganib sa {{symbol}}. Inirerekomenda namin na huwag i-trade ang token na ito.", + "got_it": "Nakuha ko", + "proceed": "Magpatuloy", + "cancel": "Kanselahin", + "data_unavailable": "Hindi available ang data ng seguridad", + "subtitle_known": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_no_issues": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_suspicious": "Natuklasan ang mga senyales ng pag-iingat sa. Maingat na suriin ang mga nai-flag na isyu bago i-trade ang asset na ito.", + "subtitle_malicious": "Natuklasan ang mga senyales ng malubhang panganib. Inirerekomenda namin na iwasan ang asset na ito.", + "subtitle_unavailable": "Hindi mai-load ang pagsusuri sa seguridad para sa token na ito.", + "token_distribution": "Pamamahagi ng token", + "total_supply": "Kabuuang supply", + "top_10_holders": "Nangungunang 10 na holder", + "other": "Iba pa", + "no_hidden_fees_detected": "Walang natuklasang mga nakatagong bayarin", + "buy_sell_tax": "Buwis sa Pagbili/Pagbebenta", + "buy_tax": "Buwis sa pagbili", + "sell_tax": "Buwis sa pagbebenta", + "transfer": "Maglipat", + "token_info": "Impormasyon ng Token", + "created": "Ginawa", + "token_age": "Tagal ng token", + "network": "Network", + "type": "Uri", + "official_links": "Mga Opisyal na Link", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Na-verify", + "no_issues": "Walang mga isyu", + "suspicious": "Kahina-hinala", + "malicious_label": "Mapaminsala", + "more": "iba pa", + "evaluation_disclaimer": "Ang pagsusuri sa seguridad na ito ay para sa ebalwasyon lamang at hindi nangangahulugan ng pag-eendorso o rekomendasyon na mag-trade." + }, "account_details": { "title": "Mga detalye ng account", "share_account": "Ibahagi", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Naki-claim na bonus", "claim_bonus": "I-claim ang bonus", "claim_bonus_subtitle": "Ibibigay ang bonus sa {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% bonus sa Linea", + "claim": "I-claim", + "sounds_good": "Mukhang maganda", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% taunang bonus na kinita mo sa pagho-hold ng mUSD. Pwedeng i-claim ang bonus mo sa Linea araw-araw.", "empty_state_cta": { "heading": "Magpahiram ng {{tokenSymbol}} at kumita ng", "body": "Ipahiram ang iyong {{tokenSymbol}} sa {{protocol}} at kumita", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Mga stablecoin mo" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Mag-stake", "earn": "Kumita", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Wala kang sapat na balanse ng mapagkukunan para gawin ang aksyong ito." }, - "trx_unstaking_in_progress": "Kasalukuyang ina-unstake ang {{amount}} TRX. Umaabot nang 14 na araw ang pag-unstake.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Kasalukuyang nag-a-unstake ng {{amount}} TRX", + "description": "Tatagal ng 14 araw para sa pag-unstake" + }, + "unstaked_banner": { + "title": "Nakumpleto ang pag-unstake ng {{amount}} TRX", + "description": "Maaari ng ma-withdraw ang na-unstake mo na TRX", + "button": "Mag-withdraw", + "error": "Pumalya ang pag-withdraw" + } }, "stake_eth": "Mag-stake ng ETH", "unstake_eth": "Mag-unstake ng ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprubahan ang kahilingan", "perps_deposit": "Magdagdag ng pondo", "predict_deposit": "Magdagdag ng mga pondo para sa Prediksyon", - "predict_withdraw": "Mag-withdraw" + "predict_withdraw": "Mag-withdraw", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Kailangan ng site na ito ng pahintulot para gastusin ang mga token mo.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksyon {{index}}", "transaction": "Transaksyon", "available_balance": "Available na balanse: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Magpatuloy", "deposit_edit_amount_done": "Magdagdag ng pondo", "deposit_edit_amount_predict_withdraw": "Mag-withdraw", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hindi pa sinusuportahan ang mga wallet na hardware. Gumamit ng hot wallet para magpatuloy.", "hardware_wallet_not_supported_solana": "Hindi pa sinusuportahan ang mga wallet na hardware sa Solana. Gumamit ng hot wallet para magpatuloy.", "price_impact_info_title": "Epekto ng presyo", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Ganito binabago ng mga trade mo ang market price ng isang token. Depende ito sa laki ng trade, available na liquidity, at mga bayarin sa provider. Hindi kontrolado ng MetaMask ang epekto sa presyo.", "price_impact_info_gasless_description": "Ipinapakita ng epekto sa presyo kung paano naaapektuhan ng iyong swap order ang market price ng asset. Kung wala kang sapat na pondo para sa gas, awtomatikong ilalaan ang isang bahagi ng source token mo para sa mga bayarin, na magdaragdag sa epekto ng presyo. Walang impluwensya o kontrol ang MetaMask sa epekto sa presyo.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Dahil sa laki ng trade mo at available na liquidity, makakakuha ka ng mga {{priceImpact}} na mas mababa sa market price. Sinukat na ito sa quote mo.", "price_impact_high": "Matinding epekto sa presyo", "price_impact_execution_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng halaga ng token mo sa swap na ito. Subukang ibaba ang halaga o pumili ng mas liquid na ruta.", "proceed": "Magpatuloy", @@ -6627,8 +6751,8 @@ "total_cost": "Kabuuang Halaga", "got_it": "Nakuha ko", "price_impact_warning_title": "Matinding epekto sa presyo", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Napakatinding epekto sa presyo", + "price_impact_error_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng market price ng token mo sa swap na ito. Subukan ang mas maliit na trade o mas liquid na ruta para pahusayin ang rate mo." }, "quote_expired_modal": { "title": "Available ang mga bagong quote", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/taon", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Virtual ang lahat, at:", + "feature_1": "Premium na nakaukit na metal card", + "feature_2": "3% cashback sa unang $10,000 kada taon", "feature_3": "Walang bayad sa transaksyon ang tagaibang bansa" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Kumita ng hanggang $300 sa cashback taun-taon", + "upgrade_to_metal_label": "O mag-upgrade sa Metal para sa 3x na mga reward" }, "review_order": { "title": "Suriin ang order mo", @@ -7104,7 +7228,7 @@ "ssn_description": "Kinakailangan ng taga-isyu ng card. Hindi magsasagawa ng credit check.", "invalid_ssn": "Di-wastong SSN", "invalid_date_of_birth": "Maling petsa ng kapanganakan. Dapat na hindi bababa sa 18 taong gulang ang edad mo", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Ang pangalan at apelyido ay dapat tumugma sa na-verify na pagkakakilanlan" }, "physical_address": { "title": "Ilagay ang address mo", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Malapit mo nang maabot ang limitasyon mo sa paggastos", "description": "Mag-update para maiwasang matanggihan", - "confirm_button_label": "Magtakda ng bagong limitasyon" + "confirm_button_label": "Magtakda ng bagong limitasyon", + "dismiss_button_label": "I-dismiss" }, "need_delegation": { "title": "Kailangan mong i-enable ang card mo", @@ -7301,7 +7426,6 @@ "dismiss": "I-dismiss", "update_success": "Matagumpay na na-update ang limit ng paggastos", "update_error": "Hindi na-update ang limit ng paggastos", - "solana_not_supported": "Paganahin ang mga token ng Solana sa card.metamask.io", "select_token": "Pumili ng token", "loading": "Naglo-load ng mga available na token...", "load_error": "Hindi makapag-load ng mga token. Pakisubukan muli.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Hindi gumagana", "update_success": "Matagumpay na na-update ang prayoridad", - "update_error": "Hindi na-update ang prayoridad sa paggastos", - "solana_not_supported_button_title": "Iba pang mga token sa Solana", - "solana_not_supported_button_description": "Paganahin sa card.metamask.io" + "update_error": "Hindi na-update ang prayoridad sa paggastos" }, "card_authentication": { "title": "Mag-log in sa card account mo", @@ -7443,6 +7565,11 @@ "title": "Nabigong mag-opt-in", "description": "Suriin ang iyong koneksyon at subukang muli." }, + "version_guard": { + "title": "Kinakailangan ng update", + "description": "Kinakailangan ang mas bagong bersyon ng MetaMask para magamit ang mga Reward. Paki-update para makapagpatuloy.", + "update_button": "I-update ang MetaMask" + }, "season_error": { "error_fetching_title": "Hindi mai-load ang season", "error_fetching_description": "Suriin ang iyong koneksyon at subukang muli.", @@ -7525,7 +7652,6 @@ "main_title": "Mga Reward", "referral_title": "Mga Referral", "tab_overview_title": "Overview", - "tab_snapshots_title": "Mga snapshot", "tab_activity_title": "Aktibidad", "referral_stats_earned_from_referrals": "Nakuha mula sa mga referral", "referral_stats_referrals": "Mga Referral", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Hindi ka nakakuha ng mga reward sa season na ito, pero mayroon pa namang ibang pagkakataon.", "verifying_rewards": "Sinisigurado namin na tama lahat bago mo i-claim ang mga reward mo." }, + "previous_season_view": { + "title": "Nakaraang Season" + }, "season_status": { "points_earned": "Mga point na nakuha" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Mga active boost", "season_1": "Season 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "bonus calculator ng mUSD", + "description": "Tingnan kung magkano ang maaari mong kitain sa pamamagitan ng pag-convert sa mga stablecoin mo sa mUSD.", + "amount_label": "Halagang na-convert", + "estimated_bonus": "Tinatayang taunang bonus: hanggang 3%", + "initial_amount": "Paunang halaga", + "daily_bonus": "Maki-claim na bonus araw-araw", + "annualized_bonus": "Taunang bonus", + "disclaimer": "Pagtataya lamang ito. Maaaring magbago ang bonus.", "buy_button": "Bumili ng mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "I-swap sa mUSD" }, "upcoming_rewards": { "title": "Naka-lock na mga reward", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Hindi mai-load" }, - "snapshot": { + "campaign": { "starts_date": "Magsisimula sa {{date}}", "ends_date": "Matatapos sa {{date}}", - "results_coming_soon": "Paparating na ang mga resulta", - "tokens_on_the_way": "Malapit na ang mga token", + "ended_date": "Ended {{date}}", "pill_up_next": "Susunod", - "pill_live_now": "Live ngayon", - "pill_calculating": "Kinakalkula", - "pill_results_ready": "Handa na ang mga Resulta", - "pill_complete": "Kumpleto na" - }, - "snapshots_section": { - "title": "Mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", - "retry_button": "Subukang muli" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Kumpleto na", + "enter_now": "Ilagay ngayon", + "entered": "Nailagay", + "participant_count": "#{{count}}", + "opt_in_cta": "Mag-opt in", + "opt_in_sheet_title": "Sumali sa campaign", + "opt_in_sheet_description_pre_link": "Sa pamamagitan ng pag-click sa ''Mag-opt in', sumasang-ayon ka sa Mga Reward ng MetaMask", + "opt_in_sheet_link_text": "Karagdagang Mga Tuntunin ng Paggamit at Abiso sa Privacy", + "opt_in_sheet_description_post_link": "Susubaybayan namin ang aktibidad sa onchain para agad kang mabigyan ng reward.", + "geo_restriction_banner_title": "Hindi available sa rehiyon mo", + "geo_restriction_banner_description": "Hindi available ang campaign na ito sa iyong rehiyon dahil sa mga lokal na regulasyon." + }, + "campaign_mechanics": { + "title": "Mechanics" + }, + "campaign_details": { + "start_date": "Magsisimula sa: {{date}}", + "end_date": "Matatapos sa: {{date}}", + "opt_in": "Mag-opt in", + "opting_in": "Nag-o-opt in...", + "opted_in": "Nag-opt in ka sa campaign na ito", + "opt_in_error": "Pumalaya ang pag-opt in. Pakisubukan muli.", + "join_campaign": "Sumali sa campaign", + "checking_opt_in_status": "Sinusuri ang katayuan ng pag-opt in", + "swap": "Mag-swap", + "how_it_works": "Paano ito gumagana" + }, + "campaigns_preview": { + "title": "Mga campaign", + "coming_soon": "Paparating na", + "notify_me": "Abisuhan ako" + }, + "earn_rewards": { + "title": "Kumita ng mga reward", + "musd_title": "Hanggang 3% bonus sa mga stable", + "musd_subtitle": "Kalkulahin ang iyong mUSD bonus", + "card_title": "Hanggang 3% cash back", + "card_subtitle": "Kunin ang iyong MetaMask Card ngayon", + "card_subtitle_cardholder": "I-access ang mga benepisyo ng iyong MetaMask Card" + }, + "campaigns_view": { + "title": "Mga campaign", "active_title": "Aktibo", "upcoming_title": "Paparating", "previous_title": "Nakaraan", - "empty_state": "Walang available na mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", + "empty_state": "Walang mga campaign na available", + "error_title": "Hindi mai-load ang mga campaign", + "error_description": "Hindi namin mai-load ang mga campaign. Pakisubukan muli.", "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." } @@ -7953,13 +8112,12 @@ "continue": "Magpatuloy" }, "connecting": { - "title": "Ikonekta ang {{device}} mo", + "title": "Ikinokonekta ang iyong {{device}}...", "searching": "Hinahanap ang {{device}}...", - "tips_header": "Para magpatuloy, siguraduhing:", + "tips_header": "Tiyakin na:", "tip_unlock": "Naka-unlock ang {{device}} mo", "tip_open_app": "Bukas ang Ethereum app", "tip_enable_bluetooth": "Naka-on ang bluetooth", - "tip_dnd_off": "Naka-off ang Do Not Disturb", "tip_bluetooth_permission": "Nagbigay ng pahintulot sa Lokasyon at Bluetooth", "tip_bluetooth_permission_v12": "Nagbigay ng pahintulot sa mga device sa paligid", "tip_stay_close": "Malapit ang device mo sa telepono mo" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Kinakailangan ang pahintulot sa mga device sa paligid", "bluetooth_off": "I-on ang Bluetooth para kumonekta sa device mo", "bluetooth_scan_failed": "Hindi nakapag-scan ng mga device. Subukan ulit", - "bluetooth_connection_failed": "I-enable ang Bluetooh sa device mo para magpatuloy", + "bluetooth_connection_failed": "Pumalya ang koneksyon sa iyong device. Pakisubukan muli", "not_supported": "Hindi sinusuportahan ang operasyong ito", "unknown_error": "Siguraduhing may naka-set up na Lihim na Parirala sa Pagbawi o passphrase ang {{device}} mo para sa account na ito" }, @@ -8034,11 +8192,20 @@ "homepage": { "sections": { "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash_empty_description": "Wala ka pang kahit anong mUSD. I-convert ang mga stablecoin papuntang mUSD mula sa seksyon ng Cash sa homepage.", + "cash_empty_description_network_filter": "Walang mUSD sa network na ito. Lumipat ng network para makita ang mUSD mo.", "tokens": "Mga Token", "perpetuals": "Perpetuals", "predictions": "Mga hula", + "whats_happening": "Ano ang nangyayari", + "whats_happening_categories": { + "geopolitical": "Heopolitikal", + "macro": "Makro", + "regulatory": "Regulatoryo", + "technical": "Teknikal", + "social": "Sosyal", + "other": "Iba pa" + }, "defi": "DeFi", "nfts": "Mga NFT", "import_nfts": "Mag-import ng mga NFT", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index cc453b540d7..8295430842c 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -20,6 +20,12 @@ "update": "Güncelle" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Uyarı", @@ -120,8 +126,8 @@ "title": "Varlıklar yakım adresine gönderiliyor" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token sözleşmesi uyarısı", + "message": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin." }, "gas_sponsorship_reserve_balance": { "message": "Gaz sponsorluğu bu işlem için kullanılamıyor. Hesabınızda en az %{minBalance} %{nativeTokenSymbol} tutmanız gerekecek.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Adı çözümlenemedi", "invalid_address": "Geçersiz adres", "contractAddressError": "Token'in sözleşme adresine token gönderiyorsunuz. Bu durum, bu token'lerin kaybedilmesine neden olabilir.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Akıllı sözleşme adresi", + "smart_contract_address_warning": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin.", "i_understand": "Anlıyorum", "cancel": "İptal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Zararda durdur emri {{direction}} yönünde {{priceType}} fiyatında olmalıdır", "stop_loss_beyond_liquidation_error": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında olmalıdır", "stop_loss_order_view_warning": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "üzerinde", "below": "altında", "done": "Bitti", @@ -2086,14 +2094,15 @@ "a_closer_look": "Daha yakın bir görünüm", "whats_being_said": "Neler söyleniyor", "footer_disclaimer": "Yapay zeka özeti yalnızca bilgi amaçlıdır", - "trade_button": "İşlem Yap", + "swap_button": "Takas", + "buy_button": "Al", "sources_count": "+{{count}} kaynak", "sources_title": "Haber kaynakları", "feedback_submitted": "Geri bildirim gönderildi", "helpful_prompt": "Bu faydalı oldu mu?", "feedback": { "title": "Geri Bildirim", - "description": "Yapay zeka ile oluşturulmuş piyasa içgörülerimizi iyileştirmemize yardımcı olun.", + "description": "Cevabınız yapay zeka özetlerimizi iyileştirmemize yardımcı olur.", "not_relevant": "İlgili değil", "not_accurate": "Doğru değil", "hard_to_understand": "Anlaşılması zor", @@ -2162,7 +2171,7 @@ "sell_position": "Sat pozisyonu", "cash_out": "Paraya çevir", "cash_out_info": "Fonlar kullanılabilir bakiyenize eklenecek", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} için {{outcome}}", "at_price_per_share": "{{size}} hisse {{price}} fiyatından satılıyor", "cashout_info": "{{amount}} {{outcome}} için {{initialPrice}} fiyatıyla", "cashout_info_multiple": "{{amount}} {{outcomeGroupTitle}} • {{outcome}} için {{initialPrice}} fiyatıyla", @@ -2206,7 +2215,7 @@ "available_balance": "Kullanılabilir bakiye", "claim_amount_text": "{{amount}}$ al", "claim_winnings_text": "Kazançları al", - "claiming_text": "Claiming...", + "claiming_text": "Alınıyor...", "unrealized_pnl_label": "Gerçekleşmemiş K&Z", "unrealized_pnl_value": "({{percent}}) {{amount}}", "unrealized_pnl_error": "Yüklenemiyor", @@ -2287,7 +2296,7 @@ "try_again": "Tekrar dene" }, "in_progress": { - "title": "Claim already in progress" + "title": "Alım zaten sürüyor" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Borsaya veya piyasaya ödenen ücret", "total_incl_fees": "ücretler dahil", "close": "Kapat", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Gösterilen fiyatlar emrinizin tamamen gerçekleştiği varsayılarak hesaplanmıştır. Emrin sadece kısmen gerçekleşmesi durumunda gerçek tutarlar değişiklik gösterebilir.", + "deposit_fee": "Para yatırma ücreti", + "deposit_fee_description": "Tahmin bakiyenize para yatırma işlemi için alınan ücret" }, "error": { "title": "Tahminlere bağlanılamıyor", @@ -3059,6 +3068,7 @@ "networks_no_results": "Ağ bulunamadı", "network_name_label": "Ağ adı", "network_name_placeholder": "Ağ adı (isteğe bağlı)", + "required": "Gerekli", "network_rpc_url_label": "RPC URL adresi", "network_rpc_name_label": "RPC adı", "network_rpc_placeholder": "Yeni RPC ağı", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Bu özellik, işlem ve imza taleplerini aktif bir şekilde inceleyerek kötü amaçlı aktivite konusunda sizi uyarır.", "security_alerts": "Güvenlik uyarıları", "security_alerts_desc": "Bu özellik, işlem ve imza taleplerinizi yerel olarak incelerken gizliliğinizi koruyarak Ethereum Ana Ağındaki kötü amaçlı aktivitelere karşı sizi uyarır. Talepleri onaylamadan önce her zaman gereken özeni kendiniz gösterin. Bu özelliğin tüm kötü amaçlı faaliyetleri algılayacağına dair herhangi bir garanti bulunmamaktadır. Bu özelliği etkinleştirerek sağlayıcının kullanım koşullarını kabul etmiş olursunuz.", + "smart_account_dapp_requests_heading": "Dapp'lerden akıllı hesap talepleri", + "smart_account_dapp_requests_desc": "Dapp'lerin standart hesaplar için akıllı hesap özellikleri talep etmesine izin verin. Bu durum, halihazırda akıllı hesap olan hesapları etkilemez.", "smart_transactions_opt_in_heading": "Akıllı İşlemler", "smart_transactions_opt_in_desc_supported_networks": "Desteklenen ağlarda daha güvenilir ve güvenli işlemler için Akıllı İşlemleri açın.", "smart_transactions_learn_more": "Daha fazla bilgi edin", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} aktivitesi", "disclaimer": "Piyasa verileri CoinGecko gibi üçüncü taraf kaynaklar tarafından sağlanır. Veriler yalnızca bilgi amaçlıdır. Verilerin doğruluğundan MetaMask sorumlu değildir." }, + "security_trust": { + "title": "Güvenlik ve güven", + "malicious": "Kötü amaçlı", + "risky": "Riskli", + "malicious_token_title": "Kötü amaçlı tokenlar", + "malicious_token_description": "{{symbol}} kötü amaçlı bir token. Bununla etkileşimde bulunmaktan veya işlem yapmaktan kaçının.", + "verified_token_title": "Doğrulanmış tokenlar", + "verified_token_description": "{{symbol}} aktif olarak işlem görmekte olup yaygın olarak tanınmaktadır. Doğrulama, MetaMask tarafından onaylandığı anlamına gelmez.", + "risky_token_title": "Riskli token", + "risky_token_description": "{{symbol}} üzerinde uyarı sinyalleri tespit edildi. Bu token ile işlem yapmadan önce dikkatli bir şekilde araştırın.", + "malicious_token_sheet_description": "{{symbol}} üzerinde ciddi risk sinyalleri tespit edildi. Bu token ile işlem yapmamanızı tavsiye ederiz.", + "got_it": "Anladım", + "proceed": "Devam et", + "cancel": "İptal et", + "data_unavailable": "Güvenlik verileri mevcut değil", + "subtitle_known": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_no_issues": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_suspicious": "Uyarı sinyalleri tespit edildi. Bu varlıkla işlem yapmadan önce işaretli sorunları dikkatli bir şekilde inceleyin.", + "subtitle_malicious": "Ciddi risk sinyalleri algılandı. Bu varlıktan kaçınmanızı tavsiye ederiz.", + "subtitle_unavailable": "Bu token için güvenlik analizi yüklenemedi.", + "token_distribution": "Token dağılımı", + "total_supply": "Toplam arz", + "top_10_holders": "En büyük 10 hissedar", + "other": "Diğer", + "no_hidden_fees_detected": "Gizli ücret algılanmadı", + "buy_sell_tax": "Alış/Satış Vergisi", + "buy_tax": "Alış vergisi", + "sell_tax": "Satış vergisi", + "transfer": "Transfer Et", + "token_info": "Token Bilgileri", + "created": "Oluşturuldu", + "token_age": "Token yaşı", + "network": "Ağ", + "type": "Bu snap'i kaldırmak istediğinizi onaylamak için", + "official_links": "Resmi Bağlantılar", + "website": "Web Sitesi", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Yok", + "verified": "Doğrulandı", + "no_issues": "Sorun yok", + "suspicious": "Şüpheli", + "malicious_label": "Kötü amaçlı", + "more": "daha fazla", + "evaluation_disclaimer": "Bu güvenlik incelemesi yalnızca değerlendirme amaçlıdır ve onay veya işlem tavsiyesi teşkil etmez." + }, "account_details": { "title": "Hesap bilgileri", "share_account": "Paylaş", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Alınabilir bonus", "claim_bonus": "Bonusu al", "claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.", + "percentage_bonus_on_linea": "Linea üzerinde %{{percentage}} bonus", + "claim": "Al", + "sounds_good": "Kulağa hoş geliyor", + "claimable_bonus_tooltip_with_percentage": "mUSD tuttuğunuz için kazandığınız %{{percentage}} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.", "empty_state_cta": { "heading": "{{tokenSymbol}} borç verin ve kazanın", "body": "{{protocol}} ile {{tokenSymbol}} token'ınızı borç verin ve", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stabil kripto paralarınız" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Pay", "earn": "Kazan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bu eylemi gerçekleştirmek için yeterli kaynak bakiyeniz yok." }, - "trx_unstaking_in_progress": "{{amount}} TRX unstake işlemi devam ediyor. Unstake işlemi 14 gün sürer.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX unstake işlemi sürüyor", + "description": "Unstake işlemi 14 gün sürecek" + }, + "unstaked_banner": { + "title": "{{amount}} TRX unstake işlemi tamamlandı", + "description": "Unstake edilen TRX'iniz şu anda çekilebilir", + "button": "Çek", + "error": "Para çekme işlemi başarısız" + } }, "stake_eth": "ETH Stake Et", "unstake_eth": "ETH Unstake Et", @@ -6376,7 +6498,8 @@ "approve": "Talebi onayla", "perps_deposit": "Fon ekle", "predict_deposit": "Tahmin fonu ekle", - "predict_withdraw": "Çek" + "predict_withdraw": "Çek", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Bu site token'larınızı harcamak için izin istiyor.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} işlemi", "transaction": "İşlem", "available_balance": "Kullanılabilir bakiye: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Devam et", "deposit_edit_amount_done": "Fon ekle", "deposit_edit_amount_predict_withdraw": "Çek", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Donanım cüzdanları henüz desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "hardware_wallet_not_supported_solana": "Donanım cüzdanları henüz Solana için desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "price_impact_info_title": "Piyasa etkisi", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Bu, işleminizin bir tokenın piyasa fiyatını nasıl değiştirdiğini ifade eder. Bu durum; işlem boyutuna, mevcut likiditeye ve sağlayıcı ücretlerine bağlıdır. MetaMask fiyat etkisini kontrol etmez.", "price_impact_info_gasless_description": "Fiyat etkisi, takas emrinizin varlığın piyasa fiyatını nasıl etkilediğini yansıtır. Gaz için yeterli fon tutmazsanız kaynak token'inizin bir kısmı otomatik olarak ücretleri karşılamak üzere ayrılır, bu nedenle fiyat etkisi artar. MetaMask fiyat etkisini etkilemez veya kontrol etmez.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "İşleminizin büyüklüğü ve kullanılabilir likiditeden dolayı piyasa fiyatından yaklaşık {{priceImpact}} daha az alacaksınız. Bu zaten fiyat teklifinize dahil edilmiştir.", "price_impact_high": "Yüksek fiyat etkisi", "price_impact_execution_description": "Bu takas işleminde tokenınızın değerinden yaklaşık {{priceImpact}} kaybedeceksiniz. Miktarı düşürmeyi veya daha likit bir rota seçmeyi deneyin.", "proceed": "Devam et", @@ -6627,8 +6751,8 @@ "total_cost": "Toplam Maliyet", "got_it": "Anladım", "price_impact_warning_title": "Yüksek fiyat etkisi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Çok yüksek fiyat etkisi", + "price_impact_error_description": "Bu takas işleminde tokenınızın piyasa fiyatından yaklaşık {{priceImpact}} kaybedeceksiniz. Oranınızı iyileştirmek için daha küçük bir işlem veya daha likit bir rota deneyin." }, "quote_expired_modal": { "title": "Yeni teklifler mevcut", @@ -6940,7 +7064,7 @@ "upgrade_title": "Metale Yükselt", "continue_button": "Devam et", "virtual_card": { - "name": "Virtual Card", + "name": "Sanal Kart", "price": "Ücretsiz", "feature_1": "Apple Pay ve Google Pay için sanal kart", "feature_2": "Kripto ile öde (USDC, USDT, WETH ve daha fazlası)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Kart", "price": "199$/yıl", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Her şey sanal ortamdadır, ayrıca:", + "feature_1": "Premium gravürlü metal kart", + "feature_2": "İlk 10.000$/yıl için %3 para iadesi", "feature_3": "Yabancı işlem ücretleri yok" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Yılda 300$'a kadar para iadesi kazanın", + "upgrade_to_metal_label": "Veya 3 katı ödül için Metal karta yükseltin" }, "review_order": { "title": "Emrinizi inceleyin", @@ -7104,7 +7228,7 @@ "ssn_description": "Kart düzenleyicisi tarafından talep edilir. Kredi notu sorgulaması yapılmayacaktır.", "invalid_ssn": "Geçersiz Sosyal Güvenlik Numarası (SSN)", "invalid_date_of_birth": "Doğum tarihi geçersiz. En az 18 yaşında olmalısınız", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Adı ve soyadı, doğrulanmış kimliğinizle uyumlu olmalıdır" }, "physical_address": { "title": "Adresinizi ekleyin", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Harcama limitinize yakınsınız", "description": "Reddedilmeleri önlemek için güncelle", - "confirm_button_label": "Yeni limit ayarla" + "confirm_button_label": "Yeni limit ayarla", + "dismiss_button_label": "Yok say" }, "need_delegation": { "title": "Kartınızı etkinleştirmeniz gerekiyor", @@ -7301,7 +7426,6 @@ "dismiss": "Yok say", "update_success": "Harcama limiti başarılı bir şekilde güncellendi", "update_error": "Harcama limiti güncellenemedi", - "solana_not_supported": "card.metamask.io adresinde Solana token'larını etkinleştir", "select_token": "Token seç", "loading": "Kullanılabilir tokenler yükleniyor...", "load_error": "Tokenler yüklenemedi. Lütfen tekrar deneyin.", @@ -7343,9 +7467,7 @@ "limited": "Sınırlı", "not_enabled": "Etkinleştirilmedi", "update_success": "Harcama önceliği başarılı bir şekilde güncellendi", - "update_error": "Harcama önceliği güncellenemedi", - "solana_not_supported_button_title": "Solana üzerindeki diğer token'lar", - "solana_not_supported_button_description": "card.metamask.io adresinde etkinleştir" + "update_error": "Harcama önceliği güncellenemedi" }, "card_authentication": { "title": "Kart hesabınızda oturum açın", @@ -7443,6 +7565,11 @@ "title": "Katılım başarısız oldu", "description": "Bağlantınızı kontrol edin ve tekrar deneyin." }, + "version_guard": { + "title": "Güncelleme gerekli", + "description": "Ödüller programının kullanılabilmesi için MetaMask'in daha yeni bir sürümü gereklidir. Devam etmek için lütfen güncelleyin.", + "update_button": "MetaMask'i güncelle" + }, "season_error": { "error_fetching_title": "Sezon yüklenemedi", "error_fetching_description": "Bağlantınızı kontrol edin ve tekrar deneyin.", @@ -7525,7 +7652,6 @@ "main_title": "Ödüller", "referral_title": "Referanslar", "tab_overview_title": "Genel Bakış", - "tab_snapshots_title": "Anlık görüntüler", "tab_activity_title": "Aktivite", "referral_stats_earned_from_referrals": "Referanslardan kazanılan", "referral_stats_referrals": "Referanslar", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bu sezon ödül kazanmadınız ancak her zaman bir sonraki sezon vardır.", "verifying_rewards": "Ödüllerinizi almadan önce her şeyin doğru olduğunu teyit ediyoruz." }, + "previous_season_view": { + "title": "Önceki Sezon" + }, "season_status": { "points_earned": "Puan kazanıldı" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktif yükseltmeler", "season_1": "Sezon 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD bonus hesaplayıcı", + "description": "Stabil kripto paralarınızı mUSD'ye dönüştürerek ne kadar kazanabileceğinize bakın.", + "amount_label": "Dönüştürülen miktar", + "estimated_bonus": "Tahmini yıllıklandırılmış bonus: %3'e kadar", + "initial_amount": "Başlangıç miktarı", + "daily_bonus": "Günlük alınabilir bonus", + "annualized_bonus": "Yıllıklandırılmış bonus", + "disclaimer": "Bu yalnızca bir tahmindir. Bonus değişikliğe tabidir.", "buy_button": "mUSD al", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD'ye takas et" }, "upcoming_rewards": { "title": "Kilitli ödüller", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Yüklenemedi" }, - "snapshot": { + "campaign": { "starts_date": "Başlangıç tarihi {{date}}", "ends_date": "Bitiş tarihi {{date}}", - "results_coming_soon": "Sonuçlar çok yakında", - "tokens_on_the_way": "Tokenlar yolda", + "ended_date": "Ended {{date}}", "pill_up_next": "Sırada", - "pill_live_now": "Şimdi canlı", - "pill_calculating": "Hesaplanıyor", - "pill_results_ready": "Sonuçlar Hazır", - "pill_complete": "Tamamlandı" - }, - "snapshots_section": { - "title": "Anlık görüntüler", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", - "retry_button": "Tekrar Dene" - }, - "snapshots_tab": { + "pill_active": "Canlı", + "pill_complete": "Tamamlandı", + "enter_now": "Şimdi giriş yapın", + "entered": "Giriş yapıldı", + "participant_count": "#{{count}}", + "opt_in_cta": "Katıl", + "opt_in_sheet_title": "Kampanyaya katıl", + "opt_in_sheet_description_pre_link": "'Katıl' düğmesine tıklayarak şunları kabul edersiniz: MetaMask Ödüller", + "opt_in_sheet_link_text": "Ek Kullanım Şartları ve Gizlilik Bildirimi", + "opt_in_sheet_description_post_link": "Sizi otomatik olarak ödüllendirmek için zincir üzeri etkinliğinizi takip edeceğiz.", + "geo_restriction_banner_title": "Bölgenizde kullanılamıyor", + "geo_restriction_banner_description": "Bu kampanya yerel düzenlemelerden dolayı bölgenizde kullanılamıyor." + }, + "campaign_mechanics": { + "title": "İşleyiş" + }, + "campaign_details": { + "start_date": "Başlangıç: {{date}}", + "end_date": "Bitiş: {{date}}", + "opt_in": "Katıl", + "opting_in": "Katılım sağlanıyor...", + "opted_in": "Bu kampanyaya katıldınız", + "opt_in_error": "Katılım sağlanamadı. Lütfen tekrar deneyin.", + "join_campaign": "Kampanyaya katıl", + "checking_opt_in_status": "Katılım durumu kontrol ediliyor", + "swap": "Takas", + "how_it_works": "Nasıl çalışır?" + }, + "campaigns_preview": { + "title": "Kampanyalar", + "coming_soon": "Çok yakında", + "notify_me": "Bana bildir" + }, + "earn_rewards": { + "title": "Ödül kazan", + "musd_title": "Stabil kripto paralarda %3'e kadar bonus", + "musd_subtitle": "mUSD bonusunuzu hesaplayın", + "card_title": "%3'e kadar para iadesi", + "card_subtitle": "MetaMask Card'ınızı hemen alın", + "card_subtitle_cardholder": "MetaMask Card faydalarınıza erişim sağlayın" + }, + "campaigns_view": { + "title": "Kampanyalar", "active_title": "Aktif", "upcoming_title": "Yaklaşan", "previous_title": "Önceki", - "empty_state": "Anlık görüntüler kullanılamıyor", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", + "empty_state": "Kampanya mevcut değil", + "error_title": "Kampanyalar yüklenemiyor", + "error_description": "Kampanyaları yükleyemedik. Lütfen tekrar deneyin.", "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." } @@ -7953,13 +8112,12 @@ "continue": "Devam et" }, "connecting": { - "title": "{{device}} cihazını bağla", + "title": "{{device}} cihazınız bağlanıyor...", "searching": "{{device}} aranıyor...", - "tips_header": "Devam etmek için şunlardan emin olun:", + "tips_header": "Şunlardan emin olun:", "tip_unlock": "{{device}} kilidi açık olmalı", "tip_open_app": "Ethereum uygulaması açık olmalı", "tip_enable_bluetooth": "Bluetooth açık olmalı", - "tip_dnd_off": "Rahatsız Etme modu kapalı olmalı", "tip_bluetooth_permission": "Konum ve Bluetooth izni verilmiş olmalı", "tip_bluetooth_permission_v12": "Yakındaki cihazlar izni verilmiş olmalı", "tip_stay_close": "Cihazınız telefonunuza yakın durmalı" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Yakındaki cihazlar izni gereklidir", "bluetooth_off": "Cihazınıza bağlanmak için lütfen Bluetooth'u açın", "bluetooth_scan_failed": "Cihazlar taranamadı. Lütfen tekrar deneyin", - "bluetooth_connection_failed": "Devam etmek için cihazınızda Bluetooth'u etkinleştirin", + "bluetooth_connection_failed": "Cihazınıza bağlanılamadı. Lütfen tekrar deneyin", "not_supported": "Bu işlem desteklenmiyor", "unknown_error": "{{device}} cihazınızda bu hesap için Gizli Kurtarma İfadesi veya parola kurulumunun yapıldığından emin olun" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Nakit", + "cash_empty_description": "Henüz mUSD'niz yok. Ana sayfada Nakit kısmından stabil kripto paraları mUSD'ye dönüştürün.", + "cash_empty_description_network_filter": "Bu ağda mUSD yok. mUSD'nizi görmek için ağ değiştirin.", "tokens": "tokenlar", "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", + "whats_happening": "Neler oluyor", + "whats_happening_categories": { + "geopolitical": "Jeopolitik", + "macro": "Makro", + "regulatory": "Mevzuat", + "technical": "Teknik", + "social": "Sosyal", + "other": "Diğer" + }, "defi": "DeFi", "nfts": "NFT'ler", "import_nfts": "NFT'leri içe aktar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 0ba27f0840b..31240aed98b 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -20,6 +20,12 @@ "update": "Cập nhật" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Cảnh báo", @@ -120,8 +126,8 @@ "title": "Gửi tài sản đến địa chỉ đốt" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Cảnh báo hợp đồng token", + "message": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn." }, "gas_sponsorship_reserve_balance": { "message": "Tài trợ phí gas không khả dụng cho giao dịch này. Bạn cần giữ ít nhất %{minBalance} %{nativeTokenSymbol} trong tài khoản của mình.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Không thể giải mã tên", "invalid_address": "Địa chỉ không hợp lệ", "contractAddressError": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể dẫn đến việc mất token đó.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Địa chỉ hợp đồng thông minh", + "smart_contract_address_warning": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn.", "i_understand": "Tôi hiểu", "cancel": "Hủy" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Giá cắt lỗ phải {{direction}} giá {{priceType}}", "stop_loss_beyond_liquidation_error": "Giá cắt lỗ phải {{direction}} giá thanh lý", "stop_loss_order_view_warning": "Giá cắt lỗ {{direction}} giá thanh lý", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "cao hơn", "below": "thấp hơn", "done": "Hoàn tất", @@ -2086,14 +2094,15 @@ "a_closer_look": "Xem chi tiết hơn", "whats_being_said": "Những gì đang được nói", "footer_disclaimer": "Tóm tắt AI chỉ mang tính chất cung cấp thông tin", - "trade_button": "Giao dịch", + "swap_button": "Hoán đổi", + "buy_button": "Mua", "sources_count": "+{{count}} nguồn", "sources_title": "Nguồn tin tức", "feedback_submitted": "Đã gửi phản hồi", "helpful_prompt": "Thông tin này có hữu ích không?", "feedback": { "title": "Phản hồi", - "description": "Giúp cải thiện thông tin thị trường do AI tạo ra.", + "description": "Câu trả lời của bạn giúp cải thiện các bản tóm tắt bằng AI của chúng tôi.", "not_relevant": "Không liên quan", "not_accurate": "Không chính xác", "hard_to_understand": "Khó hiểu", @@ -2206,7 +2215,7 @@ "available_balance": "Số dư khả dụng", "claim_amount_text": "Nhận ${{amount}}", "claim_winnings_text": "Nhận tiền thắng", - "claiming_text": "Claiming...", + "claiming_text": "Đang nhận...", "unrealized_pnl_label": "Lãi/Lỗ chưa thực hiện", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Không thể tải", @@ -2287,7 +2296,7 @@ "try_again": "Thử lại" }, "in_progress": { - "title": "Claim already in progress" + "title": "Yêu cầu nhận đang được xử lý" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Phí trả cho sàn giao dịch hoặc thị trường", "total_incl_fees": "bao gồm phí", "close": "Đóng", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Giá hiển thị dựa trên giả định rằng lệnh của bạn đã khớp hoàn toàn. Số tiền thực tế có thể khác nếu lệnh chỉ khớp một phần.", + "deposit_fee": "Phí gửi tiền", + "deposit_fee_description": "Phí được tính để gửi tiền vào số dư dự đoán của bạn" }, "error": { "title": "Không thể kết nối với thị trường dự đoán", @@ -3059,6 +3068,7 @@ "networks_no_results": "Không tìm thấy mạng nào", "network_name_label": "Tên mạng", "network_name_placeholder": "Tên mạng (không bắt buộc)", + "required": "Bắt buộc", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Tên RPC", "network_rpc_placeholder": "Mạng RPC mới", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Tính năng này sẽ cảnh báo bạn khi có hoạt động độc hại bằng cách chủ động xem xét các yêu cầu giao dịch và chữ ký.", "security_alerts": "Cảnh báo bảo mật", "security_alerts_desc": "Tính năng này sẽ cảnh báo bạn về hoạt động độc hại bằng cách xem xét cục bộ các yêu cầu giao dịch và chữ ký của bạn. Hãy luôn tự mình thực hiện quy trình thẩm định trước khi chấp thuận bất kỳ yêu cầu nào. Không có gì đảm bảo rằng tính năng này sẽ phát hiện được tất cả các hoạt động độc hại. Bằng cách bật tính năng này, bạn đồng ý với các điều khoản sử dụng của nhà cung cấp.", + "smart_account_dapp_requests_heading": "Yêu cầu tài khoản thông minh từ dapp", + "smart_account_dapp_requests_desc": "Cho phép dapp yêu cầu các tính năng tài khoản thông minh cho tài khoản tiêu chuẩn. Điều này sẽ không ảnh hưởng đến các tài khoản đã là tài khoản thông minh.", "smart_transactions_opt_in_heading": "Giao dịch thông minh", "smart_transactions_opt_in_desc_supported_networks": "Bật Giao dịch thông minh để có các giao dịch đáng tin cậy và an toàn hơn trên các mạng được hỗ trợ.", "smart_transactions_learn_more": "Tìm hiểu thêm", @@ -3566,6 +3578,53 @@ "activity": "Hoạt động {{symbol}}", "disclaimer": "Dữ liệu thị trường được cung cấp bởi các nguồn bên thứ ba như CoinGecko. Dữ liệu chỉ mang tính chất tham khảo. MetaMask không chịu trách nhiệm về tính chính xác của dữ liệu." }, + "security_trust": { + "title": "Bảo mật và độ tin cậy", + "malicious": "Độc hại", + "risky": "Rủi ro", + "malicious_token_title": "Token độc hại", + "malicious_token_description": "{{symbol}} là một token độc hại. Tránh tương tác hoặc giao dịch với token này.", + "verified_token_title": "Token đã xác minh", + "verified_token_description": "{{symbol}} đang được giao dịch tích cực và được công nhận rộng rãi. Việc xác minh không phải là sự chứng thực của MetaMask.", + "risky_token_title": "Token rủi ro", + "risky_token_description": "Phát hiện tín hiệu cảnh báo đối với {{symbol}}. Hãy nghiên cứu kỹ trước khi giao dịch token này.", + "malicious_token_sheet_description": "Phát hiện tín hiệu rủi ro nghiêm trọng đối với {{symbol}}. Chúng tôi khuyến nghị không giao dịch token này.", + "got_it": "Tôi đã hiểu", + "proceed": "Tiếp tục", + "cancel": "Hủy", + "data_unavailable": "Không có dữ liệu bảo mật", + "subtitle_known": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_no_issues": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_suspicious": "Phát hiện tín hiệu cảnh báo. Hãy xem xét kỹ các vấn đề đã được đánh dấu trước khi giao dịch tài sản này.", + "subtitle_malicious": "Phát hiện tín hiệu rủi ro nghiêm trọng. Chúng tôi khuyến nghị tránh giao dịch với tài sản này.", + "subtitle_unavailable": "Không thể tải phân tích bảo mật cho token này.", + "token_distribution": "Phân bổ token", + "total_supply": "Tổng nguồn cung", + "top_10_holders": "Top 10 người nắm giữ", + "other": "Khác", + "no_hidden_fees_detected": "Không phát hiện phí ẩn", + "buy_sell_tax": "Thuế mua/bán", + "buy_tax": "Thuế mua", + "sell_tax": "Thuế bán", + "transfer": "Chuyển", + "token_info": "Thông tin token", + "created": "Đã tạo", + "token_age": "Tuổi token", + "network": "Mạng", + "type": "Nhập", + "official_links": "Liên kết chính thức", + "website": "Trang web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Không áp dụng", + "verified": "Đã xác minh", + "no_issues": "Không có vấn đề", + "suspicious": "Đáng ngờ", + "malicious_label": "Độc hại", + "more": "thêm", + "evaluation_disclaimer": "Đánh giá bảo mật này chỉ nhằm mục đích thẩm định và không cấu thành sự chứng thực hoặc khuyến nghị giao dịch." + }, "account_details": { "title": "Chi tiết tài khoản", "share_account": "Chia sẻ", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Thưởng có thể nhận", "claim_bonus": "Nhận thưởng", "claim_bonus_subtitle": "Tiền thưởng sẽ được trả trên {{networkName}}.", + "percentage_bonus_on_linea": "Thưởng {{percentage}}% trên Linea", + "claim": "Nhận", + "sounds_good": "Có vẻ tốt", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% phần thưởng quy đổi theo năm bạn đã nhận được khi nắm giữ mUSD. Bạn có thể nhận thưởng hằng ngày trên Linea.", "empty_state_cta": { "heading": "Cho vay {{tokenSymbol}} và nhận lãi", "body": "Cho vay {{tokenSymbol}} với {{protocol}} và kiếm lợi nhuận", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Đồng ổn định của bạn" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Kiếm lợi nhuận", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bạn không có đủ số dư tài nguyên để thực hiện hành động này." }, - "trx_unstaking_in_progress": "Đang tiến hành hủy ký gửi {{amount}} TRX. Quá trình hủy ký gửi sẽ mất 14 ngày.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Đang tiến hành hủy ký gửi {{amount}} TRX", + "description": "Quá trình hủy ký gửi sẽ mất 14 ngày" + }, + "unstaked_banner": { + "title": "Đã hoàn tất hủy ký gửi {{amount}} TRX", + "description": "Hiện có thể rút TRX đã hủy ký gửi của bạn", + "button": "Rút tiền", + "error": "Rút tiền thất bại" + } }, "stake_eth": "Ký gửi ETH", "unstake_eth": "Hủy ký gửi ETH", @@ -6376,7 +6498,8 @@ "approve": "Phê duyệt yêu cầu", "perps_deposit": "Nạp tiền", "predict_deposit": "Nạp tiền Dự đoán", - "predict_withdraw": "Rút tiền" + "predict_withdraw": "Rút tiền", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Trang web này muốn được cấp quyền chi tiêu token của bạn.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Giao dịch {{index}}", "transaction": "Bảo vệ", "available_balance": "Số dư khả dụng: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Tiếp tục", "deposit_edit_amount_done": "Nạp tiền", "deposit_edit_amount_predict_withdraw": "Rút tiền", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ví cứng hiện chưa được hỗ trợ. Hãy sử dụng ví nóng để tiếp tục.", "hardware_wallet_not_supported_solana": "Ví cứng hiện chưa được hỗ trợ cho Solana. Hãy sử dụng ví nóng để tiếp tục.", "price_impact_info_title": "Mức tác động giá", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Đây là cách giao dịch của bạn làm thay đổi giá thị trường của một token. Điều này phụ thuộc vào quy mô giao dịch, thanh khoản hiện có và phí nhà cung cấp. MetaMask không kiểm soát tác động giá.", "price_impact_info_gasless_description": "Tác động giá phản ánh cách lệnh hoán đổi của bạn ảnh hưởng đến giá thị trường của tài sản. Nếu bạn không có đủ tiền để trả phí gas, một phần token gốc của bạn sẽ tự động được phân bổ để trả phí, làm tăng tác động giá. MetaMask không can thiệp hoặc kiểm soát tác động giá.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Do quy mô giao dịch của bạn và thanh khoản hiện có, bạn sẽ nhận được ít hơn khoảng {{priceImpact}} so với giá thị trường. Điều này đã được tính vào báo giá của bạn.", "price_impact_high": "Tác động giá cao", "price_impact_execution_description": "Bạn sẽ mất khoảng {{priceImpact}} giá trị token trong lần hoán đổi này. Hãy thử giảm số lượng hoặc chọn tuyến thanh khoản tốt hơn.", "proceed": "Tiếp tục", @@ -6627,8 +6751,8 @@ "total_cost": "Tổng chi phí", "got_it": "Tôi đã hiểu", "price_impact_warning_title": "Tác động giá cao", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Tác động giá rất cao", + "price_impact_error_description": "Bạn sẽ mất khoảng {{priceImpact}} giá thị trường của token trong giao dịch hoán đổi này. Hãy thử giao dịch với số lượng nhỏ hơn hoặc một lộ trình có thanh khoản cao hơn để cải thiện tỷ giá." }, "quote_expired_modal": { "title": "Đã có báo giá mới", @@ -6940,7 +7064,7 @@ "upgrade_title": "Nâng cấp lên Metal", "continue_button": "Tiếp tục", "virtual_card": { - "name": "Virtual Card", + "name": "Thẻ ảo", "price": "Miễn phí", "feature_1": "Thẻ ảo dùng cho Apple Pay và Google Pay", "feature_2": "Thanh toán bằng tiền mã hoá (USDC, USDT, WETH, v.v.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Thẻ Metal", "price": "$199/năm", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Mọi thứ có trong thẻ ảo, cộng thêm:", + "feature_1": "Thẻ kim loại khắc tên cao cấp", + "feature_2": "Hoàn tiền 3% cho $10.000 đầu tiên mỗi năm", "feature_3": "Không tính phí giao dịch quốc tế" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Nhận tối đa $300 hoàn tiền mỗi năm", + "upgrade_to_metal_label": "Hoặc nâng cấp lên Metal để nhận phần thưởng gấp 3 lần" }, "review_order": { "title": "Xem lại đơn hàng của bạn", @@ -7104,7 +7228,7 @@ "ssn_description": "Theo yêu cầu của đơn vị phát hành thẻ. Sẽ không thực hiện kiểm tra tín dụng.", "invalid_ssn": "Số An Sinh Xã Hội không hợp lệ", "invalid_date_of_birth": "Ngày sinh không hợp lệ. Bạn phải ít nhất 18 tuổi", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Tên và họ phải trùng khớp với danh tính đã được xác minh của bạn" }, "physical_address": { "title": "Thêm địa chỉ của bạn", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Bạn sắp đạt đến hạn mức chi tiêu", "description": "Cập nhật để tránh bị từ chối", - "confirm_button_label": "Đặt hạn mức mới" + "confirm_button_label": "Đặt hạn mức mới", + "dismiss_button_label": "Đóng" }, "need_delegation": { "title": "Bạn cần kích hoạt thẻ", @@ -7301,7 +7426,6 @@ "dismiss": "Đóng", "update_success": "Đã cập nhật hạn mức chi tiêu thành công", "update_error": "Cập nhật hạn mức chi tiêu thất bại", - "solana_not_supported": "Kích hoạt token Solana trên card.metamask.io", "select_token": "Chọn token", "loading": "Đang tải các token khả dụng...", "load_error": "Không thể tải token. Vui lòng thử lại.", @@ -7343,9 +7467,7 @@ "limited": "Bị giới hạn", "not_enabled": "Chưa kích hoạt", "update_success": "Đã cập nhật mức ưu tiên chi tiêu thành công", - "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại", - "solana_not_supported_button_title": "Các token khác trên Solana", - "solana_not_supported_button_description": "Kích hoạt trên card.metamask.io" + "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại" }, "card_authentication": { "title": "Đăng nhập vào tài khoản thẻ của bạn", @@ -7443,6 +7565,11 @@ "title": "Đăng ký tham gia thất bại", "description": "Kiểm tra kết nối của bạn và thử lại." }, + "version_guard": { + "title": "Yêu cầu cập nhật", + "description": "Cần phiên bản MetaMask mới hơn để sử dụng Phần thưởng. Vui lòng cập nhật để tiếp tục.", + "update_button": "Cập nhật MetaMask" + }, "season_error": { "error_fetching_title": "Không thể tải mùa giải", "error_fetching_description": "Kiểm tra kết nối của bạn và thử lại.", @@ -7525,7 +7652,6 @@ "main_title": "Phần thưởng", "referral_title": "Giới thiệu", "tab_overview_title": "Tổng quan", - "tab_snapshots_title": "Ảnh chụp nhanh", "tab_activity_title": "Hoạt động", "referral_stats_earned_from_referrals": "Nhận được từ giới thiệu", "referral_stats_referrals": "Giới thiệu", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bạn chưa nhận được phần thưởng trong mùa này, nhưng vẫn còn cơ hội lần sau.", "verifying_rewards": "Chúng tôi đang kiểm tra mọi thứ trước khi bạn nhận phần thưởng." }, + "previous_season_view": { + "title": "Mùa trước" + }, "season_status": { "points_earned": "Điểm đã tích lũy" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Tính năng tăng cường đang hoạt động", "season_1": "Mùa 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Máy tính thưởng mUSD", + "description": "Xem bạn có thể nhận được bao nhiêu khi chuyển đổi đồng ổn định của mình sang mUSD.", + "amount_label": "Số lượng đã chuyển đổi", + "estimated_bonus": "Thưởng ước tính theo năm: tối đa 3%", + "initial_amount": "Số lượng ban đầu", + "daily_bonus": "Thưởng có thể nhận hằng ngày", + "annualized_bonus": "Thưởng theo năm", + "disclaimer": "Đây chỉ là ước tính. Phần thưởng có thể thay đổi.", "buy_button": "Mua mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Hoán đổi sang mUSD" }, "upcoming_rewards": { "title": "Phần thưởng bị khóa", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Không thể tải" }, - "snapshot": { + "campaign": { "starts_date": "Bắt đầu {{date}}", "ends_date": "Kết thúc {{date}}", - "results_coming_soon": "Kết quả sẽ sớm được công bố", - "tokens_on_the_way": "Token đang được gửi đến", + "ended_date": "Ended {{date}}", "pill_up_next": "Sắp tới", - "pill_live_now": "Đang diễn ra", - "pill_calculating": "Đang tính toán", - "pill_results_ready": "Kết quả đã sẵn sàng", - "pill_complete": "Hoàn tất" - }, - "snapshots_section": { - "title": "Ảnh chụp nhanh", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", - "retry_button": "Thử lại" - }, - "snapshots_tab": { + "pill_active": "Đang diễn ra", + "pill_complete": "Hoàn tất", + "enter_now": "Tham gia ngay", + "entered": "Đã tham gia", + "participant_count": "#{{count}}", + "opt_in_cta": "Đồng ý tham gia", + "opt_in_sheet_title": "Tham gia chiến dịch", + "opt_in_sheet_description_pre_link": "Bằng cách nhấn vào \"Đồng ý tham gia\", bạn đồng ý với MetaMask Phần thưởng", + "opt_in_sheet_link_text": "Điều khoản sử dụng bổ sung và Thông báo quyền riêng tư", + "opt_in_sheet_description_post_link": "Chúng tôi sẽ theo dõi hoạt động trên chuỗi để tự động tặng thưởng cho bạn.", + "geo_restriction_banner_title": "Không khả dụng tại khu vực của bạn", + "geo_restriction_banner_description": "Chiến dịch này không khả dụng tại khu vực của bạn do quy định địa phương." + }, + "campaign_mechanics": { + "title": "Cơ chế" + }, + "campaign_details": { + "start_date": "Bắt đầu: {{date}}", + "end_date": "Kết thúc: {{date}}", + "opt_in": "Đồng ý tham gia", + "opting_in": "Đang xác nhận tham gia...", + "opted_in": "Bạn đã tham gia chiến dịch này", + "opt_in_error": "Không thể tham gia. Vui lòng thử lại.", + "join_campaign": "Tham gia chiến dịch", + "checking_opt_in_status": "Đang kiểm tra trạng thái tham gia", + "swap": "Hoán đổi", + "how_it_works": "Cách hoạt động" + }, + "campaigns_preview": { + "title": "Chiến dịch", + "coming_soon": "Sắp ra mắt", + "notify_me": "Thông báo cho tôi" + }, + "earn_rewards": { + "title": "Nhận phần thưởng", + "musd_title": "Thưởng lên đến 3% cho đồng ổn định", + "musd_subtitle": "Tính toán thưởng mUSD của bạn", + "card_title": "Hoàn tiền lên đến 3%", + "card_subtitle": "Nhận MetaMask Card của bạn ngay", + "card_subtitle_cardholder": "Truy cập các quyền lợi MetaMask Card của bạn" + }, + "campaigns_view": { + "title": "Chiến dịch", "active_title": "Đang hoạt động", "upcoming_title": "Sắp tới", "previous_title": "Trước đó", - "empty_state": "Không có ảnh chụp nhanh nào", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", + "empty_state": "Không có chiến dịch nào", + "error_title": "Không thể tải các chiến dịch", + "error_description": "Chúng tôi không thể tải các chiến dịch. Vui lòng thử lại.", "retry_button": "Thử lại", "refreshing": "Đang làm mới..." } @@ -7953,13 +8112,12 @@ "continue": "Tiếp tục" }, "connecting": { - "title": "Kết nối {{device}} của bạn", + "title": "Đang kết nối {{device}} của bạn...", "searching": "Đang tìm {{device}}...", - "tips_header": "Để tiếp tục, hãy đảm bảo:", + "tips_header": "Đảm bảo:", "tip_unlock": "{{device}} của bạn đã được mở khóa", "tip_open_app": "Ứng dụng Ethereum đang mở", "tip_enable_bluetooth": "Bluetooth đã được bật", - "tip_dnd_off": "Chế độ Không làm phiền đã được tắt", "tip_bluetooth_permission": "Quyền truy cập Vị trí và Bluetooth đã được cấp", "tip_bluetooth_permission_v12": "Quyền truy cập Thiết bị lân cận đã được cấp", "tip_stay_close": "Thiết bị của bạn ở gần điện thoại" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Cần quyền truy cập Thiết bị lân cận", "bluetooth_off": "Vui lòng bật Bluetooth để kết nối với thiết bị của bạn", "bluetooth_scan_failed": "Không thể quét thiết bị. Vui lòng thử lại", - "bluetooth_connection_failed": "Bật Bluetooth trên thiết bị của bạn để tiếp tục", + "bluetooth_connection_failed": "Kết nối với thiết bị của bạn không thành công. Vui lòng thử lại", "not_supported": "Thao tác này không được hỗ trợ", "unknown_error": "Đảm bảo {{device}} của bạn đã được thiết lập với Cụm từ khôi phục bí mật hoặc cụm mật khẩu cho tài khoản này" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Tiền mặt", + "cash_empty_description": "Bạn chưa có mUSD nào. Hãy chuyển đổi đồng ổn định sang mUSD từ mục Tiền mặt trên trang chủ.", + "cash_empty_description_network_filter": "Không có mUSD trên mạng này. Hãy chuyển mạng để xem mUSD của bạn.", "tokens": "Token", "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", + "whats_happening": "Tình hình hiện nay", + "whats_happening_categories": { + "geopolitical": "Địa chính trị", + "macro": "Vĩ mô", + "regulatory": "Quy định", + "technical": "Kỹ thuật", + "social": "Xã hội", + "other": "Khác" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Nhập NFT", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 883af8b0d34..dbadf6900b1 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "提醒", @@ -120,8 +126,8 @@ "title": "正在向销毁地址发送资产" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "代币合约警告", + "message": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。" }, "gas_sponsorship_reserve_balance": { "message": "本次交易无法使用燃料赞助。您的账户中需要至少保留 %{minBalance} %{nativeTokenSymbol}。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "无法解析名称", "invalid_address": "地址无效", "contractAddressError": "您正在向代币的合约地址发送代币。这可能导致这些代币丢失。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "智能合约地址", + "smart_contract_address_warning": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。", "i_understand": "我理解", "cancel": "取消" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "须以 {{direction}}{{priceType}} 价格止损", "stop_loss_beyond_liquidation_error": "须以 {{direction}} 清算价格止损", "stop_loss_order_view_warning": "以 {{direction}} 清算价格止损", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "高于", "below": "低于", "done": "已完成", @@ -2086,14 +2094,15 @@ "a_closer_look": "深度观察", "whats_being_said": "市场声音", "footer_disclaimer": "AI 摘要仅供参考", - "trade_button": "交易", + "swap_button": "交换", + "buy_button": "买入", "sources_count": "+{{count}} 个来源", "sources_title": "新闻来源", "feedback_submitted": "反馈已提交", "helpful_prompt": "这对您有帮助吗?", "feedback": { "title": "反馈", - "description": "帮助我们改进人工智能生成的市场洞察。", + "description": "您的回答有助于优化我们的 AI 摘要。", "not_relevant": "不相关", "not_accurate": "不准确", "hard_to_understand": "难以理解", @@ -2206,7 +2215,7 @@ "available_balance": "可用余额", "claim_amount_text": "领取 ${{amount}}", "claim_winnings_text": "领取收益", - "claiming_text": "Claiming...", + "claiming_text": "正在领取……", "unrealized_pnl_label": "未实现盈亏", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "无法加载", @@ -2287,7 +2296,7 @@ "try_again": "请重试" }, "in_progress": { - "title": "Claim already in progress" + "title": "领取正在进行中" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "支付给交易所或市场的费用", "total_incl_fees": "包含费用", "close": "关闭", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "显示的价格基于订单完全成交的假设。若仅部分成交,实际金额可能会有所不同。", + "deposit_fee": "存款手续费", + "deposit_fee_description": "将资金充值到您的预测余额所需支付的手续费" }, "error": { "title": "无法连接预测市场", @@ -3059,6 +3068,7 @@ "networks_no_results": "未找到网络", "network_name_label": "网络名称", "network_name_placeholder": "网络名称(可选)", + "required": "必需", "network_rpc_url_label": "RPC(远程过程调用)URL", "network_rpc_name_label": "RPC(远程过程调用)名称", "network_rpc_placeholder": "新 RPC 网络", @@ -3298,6 +3308,8 @@ "blockaid_desc": "此功能通过主动审查交易和签名请求向您发出恶意活动提醒。", "security_alerts": "安全警报", "security_alerts_desc": "此功能通过本地审查您的交易和签名请求来提醒您注意恶意活动。在批准任何请求之前,请务必自行进行审慎调查。无法保证此功能能够检测到所有恶意活动。启用此功能即表示您同意提供商的使用条款。", + "smart_account_dapp_requests_heading": "来自 dapp 的智能账户请求", + "smart_account_dapp_requests_desc": "让 dapp 为标准账户请求智能账户功能。这不会影响已经是智能账户的账户。", "smart_transactions_opt_in_heading": "智能交易", "smart_transactions_opt_in_desc_supported_networks": "开启智能交易,以便在支持网络上实现更加安全可靠的交易。", "smart_transactions_learn_more": "了解详情", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 活动", "disclaimer": "市场数据由 CoinGecko 等第三方来源提供。数据仅供参考。MetaMask 不对其准确性负责。" }, + "security_trust": { + "title": "安全与信任", + "malicious": "恶意", + "risky": "高风险", + "malicious_token_title": "恶意代币", + "malicious_token_description": "{{symbol}} 是恶意代币。请避免与之交互或进行交易。", + "verified_token_title": "已验证代币", + "verified_token_description": "{{symbol}} 交易活跃且被广泛认可。验证并不代表 MetaMask 的背书。", + "risky_token_title": "高风险代币", + "risky_token_description": "检测到关于 {{symbol}} 的警示信号。交易此代币前,请仔细研究。", + "malicious_token_sheet_description": "检测到关于 {{symbol}} 的严重风险信号。建议不要交易此代币。", + "got_it": "知道了", + "proceed": "继续", + "cancel": "取消", + "data_unavailable": "无法获取安全数据", + "subtitle_known": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_no_issues": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_suspicious": "检测到警示信号。交易此资产前请仔细查看标记的问题。", + "subtitle_malicious": "检测到严重风险信号。建议规避此资产。", + "subtitle_unavailable": "无法加载此代币的安全分析。", + "token_distribution": "代币分配", + "total_supply": "总供给量", + "top_10_holders": "前十大持有者", + "other": "其他", + "no_hidden_fees_detected": "未检测到隐藏费用", + "buy_sell_tax": "买卖税", + "buy_tax": "买入税", + "sell_tax": "卖出税", + "transfer": "转账", + "token_info": "代币信息", + "created": "已创建", + "token_age": "代币年龄", + "network": "网络", + "type": "类型", + "official_links": "官方链接", + "website": "网站", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "不适用", + "verified": "已验证", + "no_issues": "未发现问题", + "suspicious": "可疑", + "malicious_label": "恶意", + "more": "展开", + "evaluation_disclaimer": "本安全审查仅供评估之用,不构成对交易的背书或推荐。" + }, "account_details": { "title": "账户详情", "share_account": "共享", @@ -5934,6 +5993,10 @@ "claimable_bonus": "可领取奖励", "claim_bonus": "领取奖励", "claim_bonus_subtitle": "奖励将在 {{networkName}} 上发放。", + "percentage_bonus_on_linea": "Linea 上 {{percentage}}% 的奖励", + "claim": "领取", + "sounds_good": "听起来不错", + "claimable_bonus_tooltip_with_percentage": "您持有 mUSD 所获得的 {{percentage}}% 年化奖励。该奖励可在 Linea 上每日领取。", "empty_state_cta": { "heading": "借出 {{tokenSymbol}} 并赚取", "body": "通过 {{protocol}} 借出您的 {{tokenSymbol}},", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "您的稳定币" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "赚取", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "您的资源余额不足以执行此操作。" }, - "trx_unstaking_in_progress": "{{amount}} TRX 解除质押正在进行中。解除质押需 14 天。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "正在解除质押 {{amount}} TRX", + "description": "解除质押需要 14 天" + }, + "unstaked_banner": { + "title": "解除质押 {{amount}} TRX 已完成", + "description": "您已解除质押的 TRX 现在可以提取了", + "button": "提取", + "error": "提款失败" + } }, "stake_eth": "质押 ETH", "unstake_eth": "解除质押 ETH", @@ -6376,7 +6498,8 @@ "approve": "批准请求", "perps_deposit": "充值", "predict_deposit": "存入预测资金", - "predict_withdraw": "提取" + "predict_withdraw": "提取", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "该网站想获得花费您的代币的许可。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "交易 {{index}}", "transaction": "交易", "available_balance": "可用余额: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "继续", "deposit_edit_amount_done": "充值", "deposit_edit_amount_predict_withdraw": "提取", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "目前暂不支持硬件钱包。请改用热钱包继续操作。", "hardware_wallet_not_supported_solana": "Solana 目前暂不支持硬件钱包。请改用热钱包继续操作。", "price_impact_info_title": "价格影响", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "这是您的交易对代币市场价格产生的影响。影响程度取决于交易规模、可用流动性和服务商费用。MetaMask 无法控制价格影响。", "price_impact_info_gasless_description": "价格影响反映您的兑换订单对资产市场价格的影响程度。如果您没有足够的资金支付燃料费,系统将自动分配部分源代币用于支付费用,这会扩大价格影响。MetaMask 不参与亦不控制价格影响。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "由于您的交易规模及当前可用流动性,您的成交价格将比市场价格低约 {{priceImpact}}。此差价已包含在当前报价中。", "price_impact_high": "高价格影响", "price_impact_execution_description": "在此兑换中,您将损失约 {{priceImpact}} 的代币价值。请尝试降低金额或选择流动性更高的路径。", "proceed": "继续", @@ -6627,8 +6751,8 @@ "total_cost": "总成本", "got_it": "知道了", "price_impact_warning_title": "高价格影响", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "价格影响极高", + "price_impact_error_description": "本次兑换将导致您损失约 {{priceImpact}} 的代币市值。请尝试减少交易量或选择流动性更高的路径,以获取更优汇率。" }, "quote_expired_modal": { "title": "有新的报价", @@ -6940,7 +7064,7 @@ "upgrade_title": "升级至金属卡", "continue_button": "继续", "virtual_card": { - "name": "Virtual Card", + "name": "虚拟卡", "price": "免费", "feature_1": "用于 Apple Pay 和 Google Pay 的虚拟卡", "feature_2": "使用加密货币支付(支持 USDC、USDT、WETH 等代币)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "金属卡", "price": "199美元/年", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "所有虚拟资产,外加:", + "feature_1": "高级镌刻金属卡", + "feature_2": "每年首 10000 美元消费可获 3% 返现", "feature_3": "无外币交易费用" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "每年最高可获得 300 美元返现", + "upgrade_to_metal_label": "或升级至金属卡可享三倍奖励" }, "review_order": { "title": "查看您的订单", @@ -7104,7 +7228,7 @@ "ssn_description": "此为发卡机构的要求。不会进行信用检查。", "invalid_ssn": "社会安全号码无效", "invalid_date_of_birth": "出生日期无效。您必须年满 18 周岁", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "姓名必须与已验证身份一致" }, "physical_address": { "title": "填写您的地址", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "您即将达到消费限额", "description": "更新以避免被拒", - "confirm_button_label": "设置新限额" + "confirm_button_label": "设置新限额", + "dismiss_button_label": "忽略" }, "need_delegation": { "title": "您需要启用您的卡", @@ -7301,7 +7426,6 @@ "dismiss": "忽略", "update_success": "消费限额更新成功", "update_error": "消费限额更新失败", - "solana_not_supported": "请在 card.metamask.io 启用 Solana 代币", "select_token": "选择代币", "loading": "正在加载可用代币……", "load_error": "无法加载代币。请重试。", @@ -7343,9 +7467,7 @@ "limited": "受限", "not_enabled": "未启用", "update_success": "消费优先级更新成功", - "update_error": "消费优先级更新失败", - "solana_not_supported_button_title": "Solana 上的其他代币", - "solana_not_supported_button_description": "请在 card.metamask.io 启用" + "update_error": "消费优先级更新失败" }, "card_authentication": { "title": "登录您的卡账户", @@ -7443,6 +7565,11 @@ "title": "参与失败", "description": "请检查连接后重试。" }, + "version_guard": { + "title": "需要更新", + "description": "需要更高版本的 MetaMask 才能使用奖励功能。请更新后继续。", + "update_button": "更新 MetaMask" + }, "season_error": { "error_fetching_title": "赛季无法加载", "error_fetching_description": "请检查连接后重试。", @@ -7525,7 +7652,6 @@ "main_title": "奖励", "referral_title": "推荐", "tab_overview_title": "概览", - "tab_snapshots_title": "快照", "tab_activity_title": "活动", "referral_stats_earned_from_referrals": "通过推荐所获奖励", "referral_stats_referrals": "推荐", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "您本季未获得奖励,但未来仍有机会。", "verifying_rewards": "在您领取奖励前,我们正在核对所有信息以确保准确无误。" }, + "previous_season_view": { + "title": "上一季" + }, "season_status": { "points_earned": "已获得积分" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "活跃加成", "season_1": "第 1 季", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 奖励计算器", + "description": "看看您将稳定币兑换为 mUSD 能赚取多少收益。", + "amount_label": "已兑换金额", + "estimated_bonus": "预估年化奖励:最高可达 3%", + "initial_amount": "初始金额", + "daily_bonus": "每日可领取奖励", + "annualized_bonus": "年化奖励", + "disclaimer": "仅为预估。奖励可能会有变动。", "buy_button": "购买 mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "兑换为 mUSD" }, "upcoming_rewards": { "title": "锁定的奖励", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "无法加载" }, - "snapshot": { + "campaign": { "starts_date": "开始于 {{date}}", "ends_date": "结束于 {{date}}", - "results_coming_soon": "结果即将公布", - "tokens_on_the_way": "代币即将到账", + "ended_date": "Ended {{date}}", "pill_up_next": "即将到来", - "pill_live_now": "现已上线", - "pill_calculating": "正在计算", - "pill_results_ready": "结果已就绪", - "pill_complete": "完成" - }, - "snapshots_section": { - "title": "快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", - "retry_button": "重试" - }, - "snapshots_tab": { + "pill_active": "进行中", + "pill_complete": "完成", + "enter_now": "立即参加", + "entered": "已参加", + "participant_count": "#{{count}}", + "opt_in_cta": "选择加入", + "opt_in_sheet_title": "加入活动", + "opt_in_sheet_description_pre_link": "点击“选择加入”,即表示您同意参加 MetaMask 奖励计划", + "opt_in_sheet_link_text": "补充使用条款及隐私声明", + "opt_in_sheet_description_post_link": "我们将通过追踪链上活动为您自动发放奖励。", + "geo_restriction_banner_title": "您所在区域不可用", + "geo_restriction_banner_description": "由于当地法规限制,此活动不适用于您所在地区。" + }, + "campaign_mechanics": { + "title": "机制" + }, + "campaign_details": { + "start_date": "开始时间:{{date}}", + "end_date": "结束时间:{{date}}", + "opt_in": "选择加入", + "opting_in": "正在选择加入……", + "opted_in": "您已选择加入此活动", + "opt_in_error": "加入失败。请重试。", + "join_campaign": "加入活动", + "checking_opt_in_status": "正在检查加入状态", + "swap": "交换", + "how_it_works": "如何运行" + }, + "campaigns_preview": { + "title": "活动", + "coming_soon": "即将推出", + "notify_me": "通知我" + }, + "earn_rewards": { + "title": "赚取奖励", + "musd_title": "稳定币最高可享 3% 奖励", + "musd_subtitle": "计算您的 mUSD 奖励", + "card_title": "最高可享 3% 返现", + "card_subtitle": "立即获取您的 MetaMask 卡", + "card_subtitle_cardholder": "查看您的 MetaMask 卡专属福利" + }, + "campaigns_view": { + "title": "活动", "active_title": "已激活", "upcoming_title": "即将到来", "previous_title": "先前", - "empty_state": "暂无快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", + "empty_state": "暂无活动", + "error_title": "无法加载活动", + "error_description": "我们无法加载活动。请重试。", "retry_button": "重试", "refreshing": "正在刷新……" } @@ -7953,13 +8112,12 @@ "continue": "继续" }, "connecting": { - "title": "连接您的 {{device}}", + "title": "正在连接您的 {{device}}……", "searching": "正在查找 {{device}}……", - "tips_header": "若要继续,请确保:", + "tips_header": "确保:", "tip_unlock": "您的 {{device}} 已解锁", "tip_open_app": "以太坊应用已打开", "tip_enable_bluetooth": "蓝牙已开启", - "tip_dnd_off": "勿扰模式已关闭", "tip_bluetooth_permission": "位置和蓝牙许可已授予", "tip_bluetooth_permission_v12": "附近设备许可已授予", "tip_stay_close": "您的设备需保持靠近手机" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "需要附近设备许可", "bluetooth_off": "请开启蓝牙以连接到您的设备", "bluetooth_scan_failed": "扫描设备失败。请重试", - "bluetooth_connection_failed": "在您的设备上启用蓝牙以继续", + "bluetooth_connection_failed": "连接您的设备失败。请重试", "not_supported": "不支持此操作", "unknown_error": "确保您的 {{device}} 已使用此账户的私钥助记词或密语进行设置" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "现金", + "cash_empty_description": "您目前尚未持有任何 mUSD。请从首页的“现金”部分将稳定币兑换为 mUSD。", + "cash_empty_description_network_filter": "此网络中没有 mUSD。请切换网络以查看您的 mUSD。", "tokens": "代币", "perpetuals": "永续合约", "predictions": "预测", + "whats_happening": "发生了什么", + "whats_happening_categories": { + "geopolitical": "地缘政治", + "macro": "宏观", + "regulatory": "监管", + "technical": "技术", + "social": "社会", + "other": "其他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "导入 NFT", From 09b7f3bd52a3995e2c6e1c04d81aecac3495f77b Mon Sep 17 00:00:00 2001 From: VGR Date: Wed, 25 Mar 2026 18:55:32 +0100 Subject: [PATCH 02/15] feat(rewards): add Ondo leaderboard view and position component (#27917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes / refs: https://consensyssoftware.atlassian.net/browse/RWDS-1104 CHANGELOG entry: Added Ondo leaderboard view displaying user's rank, tier, and financial position within the campaign ## Screenshots - Leaderboard position component in loadedn, loading & error state. This component will be shown on the ondo details page if a user has opted in. Screenshot from 2026-03-25 17-10-59 Screenshot from 2026-03-25 17-09-56 Screenshot from 2026-03-25
17-03-53 - Leaderboard page containing position at the top and the leaderboard below. Accessible via the ondo details page if opted in (via tap on section) or rendered inline on that page if campaign has passed. (regardless of opt in) Screenshot from 2026-03-25 17-01-49 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Adds new rewards API calls, controller caching/state, and UI navigation for Ondo leaderboard/position; moderate risk of regressions in rewards state persistence/invalidation and leaderboard fetch/display behavior. > > **Overview** > Adds an **Ondo campaign leaderboard experience**: a new `RewardsOndoCampaignLeaderboard` screen plus reusable `OndoLeaderboard` and `OndoLeaderboardPosition` components, wired into `RewardsNavigator` and linked from `OndoCampaignDetailsView`. > > Updates the campaign details UI to show *entered* status, conditionally render leaderboard content (inline leaderboard for completed campaigns when not opted in; position card + tappable header when opted in), and only fetch leaderboard data when it will be displayed. > > Extends the rewards stack end-to-end with new public/authenticated endpoints and caching: `RewardsDataService` + `RewardsController` actions for `getOndoCampaignLeaderboard` and `getOndoCampaignLeaderboardPosition`, new persisted controller state (`ondoCampaignLeaderboard`, `ondoCampaignLeaderboardPositions` with a not-found sentinel), and cache invalidation on opt-in/logout/subscription cache resets. > > Adds Redux state/actions for leaderboard UI (loading/error/selected tier and per-subscription positions), updates rehydration/reset behavior, and includes comprehensive unit tests for the new views, hooks, utilities, controller, and data service. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 92ef86cb5a3c8a46f879a759477a48228a836d81. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Sonnet 4.6 --- .../UI/Rewards/RewardsNavigator.tsx | 6 + .../Views/OndoCampaignDetailsView.test.tsx | 162 ++++++ .../Rewards/Views/OndoCampaignDetailsView.tsx | 102 +++- .../Views/OndoLeaderboardView.test.tsx | 181 +++++++ .../UI/Rewards/Views/OndoLeaderboardView.tsx | 86 ++++ .../components/Campaigns/CampaignStatus.tsx | 23 +- .../Campaigns/OndoLeaderboard.test.tsx | 366 ++++++++++++++ .../components/Campaigns/OndoLeaderboard.tsx | 338 +++++++++++++ .../Campaigns/OndoLeaderboard.utils.test.ts | 63 +++ .../Campaigns/OndoLeaderboard.utils.ts | 26 + .../OndoLeaderboardPosition.test.tsx | 462 ++++++++++++++++++ .../Campaigns/OndoLeaderboardPosition.tsx | 247 ++++++++++ .../hooks/useGetOndoLeaderboard.test.ts | 354 ++++++++++++++ .../UI/Rewards/hooks/useGetOndoLeaderboard.ts | 138 ++++++ .../useGetOndoLeaderboardPosition.test.ts | 282 +++++++++++ .../hooks/useGetOndoLeaderboardPosition.ts | 94 ++++ app/constants/navigation/Routes.ts | 1 + .../RewardsController.test.ts | 457 ++++++++++++++++- .../rewards-controller/RewardsController.ts | 244 ++++++++- .../services/rewards-data-service.test.ts | 106 ++++ .../services/rewards-data-service.ts | 75 ++- .../controllers/rewards-controller/types.ts | 316 ++++++++++-- .../rewards-controller-messenger/index.ts | 8 +- app/reducers/rewards/index.test.ts | 322 ++++++++++++ app/reducers/rewards/index.ts | 221 ++++++--- app/reducers/rewards/selectors.test.ts | 276 +++++++++++ app/reducers/rewards/selectors.ts | 56 +++ app/selectors/rewards/index.ts | 11 + .../logs/__snapshots__/index.test.ts.snap | 4 + app/util/test/initial-background-state.json | 2 + locales/languages/en.json | 26 + .../fixtures/json/default-fixture.json | 15 +- 32 files changed, 4926 insertions(+), 144 deletions(-) create mode 100644 app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx create mode 100644 app/components/UI/Rewards/Views/OndoLeaderboardView.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx create mode 100644 app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts create mode 100644 app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts create mode 100644 app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts create mode 100644 app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index dde305134d9..58fda357228 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -10,6 +10,7 @@ import OndoCampaignDetailsView from './Views/OndoCampaignDetailsView'; import SeasonOneCampaignDetailsView from './Views/SeasonOneCampaignDetailsView'; import CampaignMechanicsView from './Views/CampaignMechanicsView'; import MusdCalculatorView from './Views/MusdCalculatorView'; +import OndoLeaderboardView from './Views/OndoLeaderboardView'; import { useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors'; @@ -112,6 +113,11 @@ const RewardsNavigator: React.FC = () => { component={MusdCalculatorView} options={{ headerShown: false }} /> + ) : null} diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index 7d4fcd088e2..ea1e9b88bfd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -10,6 +10,7 @@ import { } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard'; import Routes from '../../../../constants/navigation/Routes'; const mockGoBack = jest.fn(); @@ -121,6 +122,28 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => { }; }); +jest.mock('../components/Campaigns/OndoLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'ondo-leaderboard' }), + }; +}); + +jest.mock('../components/Campaigns/OndoLeaderboardPosition', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'ondo-leaderboard-position', + }), + }; +}); + jest.mock('../components/Campaigns/CampaignOptInSheet', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -176,6 +199,11 @@ const mockUseGetCampaignParticipantStatus = typeof useGetCampaignParticipantStatus >; +jest.mock('../hooks/useGetOndoLeaderboard'); +const mockUseGetOndoLeaderboard = useGetOndoLeaderboard as jest.MockedFunction< + typeof useGetOndoLeaderboard +>; + jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { @@ -233,6 +261,17 @@ describe('OndoCampaignDetailsView', () => { hasError: false, refetch: jest.fn(), }); + mockUseGetOndoLeaderboard.mockReturnValue({ + leaderboard: null, + isLoading: false, + hasError: false, + tierNames: [], + selectedTier: null, + selectedTierData: null, + computedAt: null, + setSelectedTier: jest.fn(), + refetch: jest.fn(), + }); }); it('renders the container', () => { @@ -337,6 +376,36 @@ describe('OndoCampaignDetailsView', () => { const { queryByTestId } = render(); expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); + + it('renders OndoLeaderboardPosition when participant is opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId('ondo-leaderboard-position')).toBeDefined(); + }); + + it('does not render OndoLeaderboardPosition when participant is not opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId('ondo-leaderboard-position')).toBeNull(); + }); }); describe('opt-in CTA', () => { @@ -454,6 +523,99 @@ describe('OndoCampaignDetailsView', () => { }); }); + describe('leaderboard position', () => { + it('shows OndoLeaderboardPosition when participant is opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId('ondo-leaderboard-position')).toBeDefined(); + }); + + it('does not show OndoLeaderboardPosition when not opted in and campaign is active', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { queryByTestId } = render(); + expect(queryByTestId('ondo-leaderboard-position')).toBeNull(); + }); + + it('shows OndoLeaderboard when not opted in and campaign is complete', () => { + const lastMonth = new Date(); + lastMonth.setMonth(lastMonth.getMonth() - 1); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + startDate: lastMonth.toISOString(), + endDate: yesterday.toISOString(), + }), + ], + }); + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId('ondo-leaderboard')).toBeDefined(); + expect(queryByTestId('ondo-leaderboard-position')).toBeNull(); + }); + + it('does not show OndoLeaderboard when not opted in and campaign is active', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { queryByTestId } = render(); + expect(queryByTestId('ondo-leaderboard')).toBeNull(); + }); + + it('does not show leaderboard section header when campaign is complete and not opted in', () => { + const lastMonth = new Date(); + lastMonth.setMonth(lastMonth.getMonth() - 1); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + startDate: lastMonth.toISOString(), + endDate: yesterday.toISOString(), + }), + ], + }); + const { queryByText } = render(); + expect(queryByText('rewards.ondo_campaign_leaderboard.title')).toBeNull(); + }); + + it('shows leaderboard section header when opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByText } = render(); + expect( + getByText('rewards.ondo_campaign_leaderboard.title'), + ).toBeDefined(); + }); + }); + describe('navigation', () => { it('navigates back when the back button is pressed', () => { mockUseRewardCampaigns.mockReturnValue({ diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 33fd7490633..1c62b6281d7 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -1,17 +1,30 @@ import React, { useEffect, useMemo } from 'react'; -import { ScrollView } from 'react-native'; +import { Pressable, ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { Box, IconName, Skeleton } from '@metamask/design-system-react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Icon, + IconName, + IconSize, + Skeleton, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import CampaignStatus from '../components/Campaigns/CampaignStatus'; import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import OndoLeaderboard from '../components/Campaigns/OndoLeaderboard'; +import OndoLeaderboardPosition from '../components/Campaigns/OndoLeaderboardPosition'; import CampaignJoinCTA from '../components/Campaigns/CampaignJoinCTA'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -49,6 +62,29 @@ const OndoCampaignDetailsView: React.FC = () => { } }, [campaign, navigation]); + const isOptedIn = participantStatus?.status?.optedIn === true; + + // Only fetch leaderboard data when we'll actually render the OndoLeaderboard + // (non-opted-in view of a completed campaign) + const leaderboardCampaignId = useMemo( + () => + campaign && !isOptedIn && getCampaignStatus(campaign) === 'complete' + ? campaignId + : undefined, + [campaign, isOptedIn, campaignId], + ); + + const { + tierNames, + selectedTier, + selectedTierData, + computedAt, + setSelectedTier, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + refetch: refetchLeaderboard, + } = useGetOndoLeaderboard(leaderboardCampaignId); + return ( { {campaign && ( <> - - {campaign.details?.howItWorks && ( + + + {campaign.details?.howItWorks && + !isOptedIn && + getCampaignStatus(campaign) !== 'complete' && ( + <> + + + + + + )} + + {/* Leaderboard section - shown when opted in, or when campaign is complete */} + {(isOptedIn || Boolean(leaderboardCampaignId)) && ( <> - - + + {isOptedIn && ( + + navigation.navigate( + Routes.REWARDS_ONDO_CAMPAIGN_LEADERBOARD, + { campaignId }, + ) + } + > + + + {strings('rewards.ondo_campaign_leaderboard.title')} + + + + + )} + {isOptedIn ? ( + + ) : ( + + )} )} diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx new file mode 100644 index 00000000000..eb47e6eff84 --- /dev/null +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import OndoLeaderboardView, { + ONDO_LEADERBOARD_VIEW_TEST_IDS, +} from './OndoLeaderboardView'; +import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), + useRoute: () => ({ params: { campaignId: 'campaign-ondo-123' } }), +})); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + SafeAreaView: ({ + children, + testID, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { ...props, testID }, children), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'header-back-button', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children), + }; +}); + +jest.mock('../components/Campaigns/OndoLeaderboardPosition', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaignId }: { campaignId: string }) => + ReactActual.createElement(View, { + testID: `ondo-leaderboard-position-${campaignId}`, + }), + }; +}); + +jest.mock('../components/Campaigns/OndoLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-leaderboard' }), + }; +}); + +jest.mock('../hooks/useGetOndoLeaderboard'); + +const mockUseGetOndoLeaderboard = useGetOndoLeaderboard as jest.MockedFunction< + typeof useGetOndoLeaderboard +>; + +const hookDefaults = { + leaderboard: null, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: 'STARTER', + selectedTierData: { entries: [], total_participants: 10 }, + computedAt: '2024-03-20T12:00:00.000Z', + setSelectedTier: jest.fn(), + refetch: jest.fn(), +}; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +describe('OndoLeaderboardView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetOndoLeaderboard.mockReturnValue(hookDefaults); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the OndoLeaderboard component', () => { + const { getByTestId } = render(); + expect(getByTestId('campaign-leaderboard')).toBeDefined(); + }); + + it('renders the OndoLeaderboardPosition component with the campaign ID', () => { + const { getByTestId } = render(); + expect( + getByTestId('ondo-leaderboard-position-campaign-ondo-123'), + ).toBeDefined(); + }); + + it('calls useGetOndoLeaderboard with the campaign ID from route params', () => { + render(); + expect(mockUseGetOndoLeaderboard).toHaveBeenCalledWith('campaign-ondo-123'); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('ondo-leaderboard-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders while leaderboard is loading', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + isLoading: true, + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders while leaderboard has an error', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + hasError: true, + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx new file mode 100644 index 00000000000..81dacdeded2 --- /dev/null +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import OndoLeaderboardPosition from '../components/Campaigns/OndoLeaderboardPosition'; +import OndoLeaderboard from '../components/Campaigns/OndoLeaderboard'; +import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard'; +import { strings } from '../../../../../locales/i18n'; + +// ParamListBase requires an index signature, which interfaces don't support +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type OndoLeaderboardRouteParams = { + OndoLeaderboard: { campaignId: string }; +}; + +export const ONDO_LEADERBOARD_VIEW_TEST_IDS = { + CONTAINER: 'ondo-leaderboard-view-container', +} as const; + +const OndoLeaderboardView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute>(); + const { campaignId } = route.params; + + const { + tierNames, + selectedTier, + selectedTierData, + computedAt, + setSelectedTier, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + refetch: refetchLeaderboard, + } = useGetOndoLeaderboard(campaignId); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'ondo-leaderboard-back-button' }} + includesTopInset + /> + + + {/* User position card */} + + + + + {/* Full leaderboard */} + + + + + + + + ); +}; + +export default OndoLeaderboardView; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx index b1fdeaa75af..2adb4b36daf 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx @@ -12,6 +12,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { getCampaignStatusInfo } from './CampaignTile.utils'; +import { strings } from '../../../../../../locales/i18n'; export const CAMPAIGN_STATUS_TEST_IDS = { CONTAINER: 'campaign-status-container', @@ -24,9 +25,13 @@ export const CAMPAIGN_STATUS_TEST_IDS = { interface CampaignStatusProps { campaign: CampaignDto; + optedIn?: boolean; } -const CampaignStatus: React.FC = ({ campaign }) => { +const CampaignStatus: React.FC = ({ + campaign, + optedIn = false, +}) => { const tw = useTailwind(); const colorScheme = useColorScheme(); @@ -60,7 +65,12 @@ const CampaignStatus: React.FC = ({ campaign }) => { alignItems={BoxAlignItems.Center} twClassName="gap-1" > - + = ({ campaign }) => { > {statusLabel} + {optedIn && ( + + ({strings('rewards.campaign.entered')}) + + )} { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock( + '../../../../../component-library/components-temp/Tabs/TabsBar', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + tabs, + activeIndex, + onTabPress, + testID, + }: { + tabs: { key: string; label: string }[]; + activeIndex: number; + onTabPress: (index: number) => void; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + tabs.map((tab, idx) => + ReactActual.createElement( + Pressable, + { + key: tab.key, + onPress: () => onTabPress(idx), + testID: `tab-${tab.key}`, + }, + ReactActual.createElement( + Text, + { style: idx === activeIndex ? { fontWeight: 'bold' } : {} }, + tab.label, + ), + ), + ), + ), + }; + }, +); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: `${testID}-retry` }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const translations: Record = { + 'rewards.ondo_campaign_leaderboard.title': 'Leaderboard', + 'rewards.ondo_campaign_leaderboard.updated_at': `Updated at ${params?.time ?? ''}`, + 'rewards.ondo_campaign_leaderboard.total_participants': `${params?.count ?? ''} participants`, + 'rewards.ondo_campaign_leaderboard.no_data': + 'No leaderboard data available', + 'rewards.ondo_campaign_leaderboard.no_entries_in_tier': + 'No entries in this tier', + 'rewards.ondo_campaign_leaderboard.error_loading': + 'Failed to load leaderboard', + 'rewards.ondo_campaign_leaderboard.error_loading_description': + 'Please try again', + 'rewards.ondo_campaign_leaderboard.retry': 'Retry', + }; + return translations[key] || key; + }, +})); + +const createMockEntry = ( + overrides: Partial = {}, +): CampaignLeaderboardEntry => ({ + rank: 1, + referral_code: 'ABC123', + rate_of_return: 0.15, + ...overrides, +}); + +const defaultProps = { + tierNames: ['STARTER', 'MID', 'UPPER'], + selectedTier: 'STARTER', + onTierChange: jest.fn(), + entries: [ + createMockEntry({ rank: 1, referral_code: 'AAA111', rate_of_return: 0.2 }), + createMockEntry({ rank: 2, referral_code: 'BBB222', rate_of_return: 0.15 }), + createMockEntry({ + rank: 3, + referral_code: 'CCC333', + rate_of_return: -0.05, + }), + ], + totalParticipants: 150, + computedAt: '2024-03-20T12:00:00.000Z', + isLoading: false, + hasError: false, + onRetry: jest.fn(), +}; + +describe('OndoLeaderboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading state', () => { + it('renders skeleton when loading with no data', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.LOADING)).toBeDefined(); + }); + + it('does not render skeleton when loading but has data', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.LOADING)).toBeNull(); + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + }); + + describe('error state', () => { + it('renders error banner when has error and no data', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.ERROR)).toBeDefined(); + }); + + it('does not render error banner when has error but data is present', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect(queryByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.ERROR)).toBeNull(); + }); + + it('calls onRetry when error retry is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ERROR}-retry`), + ); + expect(defaultProps.onRetry).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty state', () => { + it('renders empty state when no tier names', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.EMPTY)).toBeDefined(); + }); + }); + + describe('leaderboard content', () => { + it('renders container with leaderboard title', () => { + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect(getByText('Leaderboard')).toBeDefined(); + }); + + it('renders computed at timestamp', () => { + const { getByTestId } = render(); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.COMPUTED_AT), + ).toBeDefined(); + }); + + it('does not render computed at when null', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.COMPUTED_AT), + ).toBeNull(); + }); + + it('renders tier tabs when multiple tiers', () => { + const { getByTestId } = render(); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.TIER_TOGGLE), + ).toBeDefined(); + }); + + it('does not render tier tabs when single tier', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.TIER_TOGGLE), + ).toBeNull(); + }); + + it('calls onTierChange when tab is pressed', () => { + const onTierChange = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('tab-MID')); + expect(onTierChange).toHaveBeenCalledWith('MID'); + }); + + it('renders total participants count', () => { + const { getByText } = render(); + + expect(getByText('150 participants')).toBeDefined(); + }); + + it('renders leaderboard list', () => { + const { getByTestId } = render(); + + expect(getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.LIST)).toBeDefined(); + }); + + it('renders entry rows with correct data', () => { + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ENTRY_ROW}-1`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ENTRY_ROW}-2`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ENTRY_ROW}-3`), + ).toBeDefined(); + + expect(getByText('#1')).toBeDefined(); + expect(getByText('AAA111')).toBeDefined(); + expect(getByText('+20.00%')).toBeDefined(); + + expect(getByText('#2')).toBeDefined(); + expect(getByText('BBB222')).toBeDefined(); + expect(getByText('+15.00%')).toBeDefined(); + + expect(getByText('#3')).toBeDefined(); + expect(getByText('CCC333')).toBeDefined(); + expect(getByText('-5.00%')).toBeDefined(); + }); + + it('renders empty entries message when no entries', () => { + const { getByText } = render( + , + ); + + expect(getByText('No entries in this tier')).toBeDefined(); + }); + }); + + describe('currentUserReferralCode highlighting', () => { + it('renders all entry rows regardless of currentUserReferralCode', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ENTRY_ROW}-1`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_LEADERBOARD_TEST_IDS.ENTRY_ROW}-2`), + ).toBeDefined(); + }); + + it('renders without currentUserReferralCode', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + }); + + describe('rate of return formatting', () => { + it('formats positive rate of return with plus sign', () => { + const entries = [createMockEntry({ rank: 1, rate_of_return: 0.1523 })]; + const { getByText } = render( + , + ); + + expect(getByText('+15.23%')).toBeDefined(); + }); + + it('formats negative rate of return without plus sign', () => { + const entries = [createMockEntry({ rank: 1, rate_of_return: -0.0832 })]; + const { getByText } = render( + , + ); + + expect(getByText('-8.32%')).toBeDefined(); + }); + + it('formats zero rate of return with plus sign', () => { + const entries = [createMockEntry({ rank: 1, rate_of_return: 0 })]; + const { getByText } = render( + , + ); + + expect(getByText('+0.00%')).toBeDefined(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx new file mode 100644 index 00000000000..31d508ebb02 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx @@ -0,0 +1,338 @@ +import React, { useMemo } from 'react'; +import { FlatList, ListRenderItemInfo } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + FontWeight, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import TabsBar from '../../../../../component-library/components-temp/Tabs/TabsBar'; +import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatRateOfReturn, formatComputedAt } from './OndoLeaderboard.utils'; + +const ListSeparator = () => ; + +export const CAMPAIGN_LEADERBOARD_TEST_IDS = { + CONTAINER: 'campaign-leaderboard-container', + TIER_TOGGLE: 'campaign-leaderboard-tier-toggle', + LIST: 'campaign-leaderboard-list', + ENTRY_ROW: 'campaign-leaderboard-entry-row', + COMPUTED_AT: 'campaign-leaderboard-computed-at', + LOADING: 'campaign-leaderboard-loading', + ERROR: 'campaign-leaderboard-error', + EMPTY: 'campaign-leaderboard-empty', +} as const; + +interface CampaignLeaderboardProps { + tierNames: string[]; + selectedTier: string | null; + onTierChange: (tier: string) => void; + entries: CampaignLeaderboardEntry[]; + totalParticipants: number; + computedAt: string | null; + isLoading: boolean; + hasError: boolean; + onRetry?: () => void; + currentUserReferralCode?: string | null; +} + +/** + * LeaderboardEntryRow displays a single leaderboard entry + */ +const LeaderboardEntryRow: React.FC<{ + entry: CampaignLeaderboardEntry; + isCurrentUser?: boolean; +}> = ({ entry, isCurrentUser = false }) => ( + + + + #{entry.rank} + + + {entry.referral_code} + + + = 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + } + > + {formatRateOfReturn(entry.rate_of_return)} + + +); + +/** + * LeaderboardSkeleton displays loading skeleton for the leaderboard section + */ +const LeaderboardSkeleton: React.FC = () => { + const tw = useTailwind(); + + return ( + + {/* Header skeleton */} + + + + + {/* Tier tabs skeleton */} + + + + + + {/* Participants count skeleton */} + + {/* Leaderboard rows skeleton */} + + {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( + + + + + + + + + {i < 10 && } + + ))} + + + ); +}; + +/** + * OndoLeaderboard displays the leaderboard tiers and entries for a campaign. + * Position-specific data (user rank, tier, deposited value) is handled separately + * by the OndoLeaderboardPosition component. + */ +const OndoLeaderboard: React.FC = ({ + tierNames, + selectedTier, + onTierChange, + entries, + totalParticipants, + computedAt, + isLoading, + hasError, + onRetry, + currentUserReferralCode, +}) => { + const tabs = useMemo( + () => + tierNames.map((name) => ({ + key: name, + label: name.toUpperCase(), + content: null, + })), + [tierNames], + ); + + const selectedIndex = selectedTier ? tierNames.indexOf(selectedTier) : 0; + + const renderEntry = ({ + item, + }: ListRenderItemInfo) => ( + + ); + + const keyExtractor = (item: CampaignLeaderboardEntry) => + `${item.rank}-${item.referral_code}`; + + if (isLoading && entries.length === 0) { + return ; + } + + if (hasError && entries.length === 0) { + return ( + + ); + } + + if (tierNames.length === 0) { + return ( + + + {strings('rewards.ondo_campaign_leaderboard.no_data')} + + + ); + } + + return ( + + {/* Header with title and computed at */} + + + {strings('rewards.ondo_campaign_leaderboard.title')} + + {computedAt && ( + + {strings('rewards.ondo_campaign_leaderboard.updated_at', { + time: formatComputedAt(computedAt), + })} + + )} + + + {/* Tier selector */} + {tabs.length > 1 && ( + + onTierChange(tierNames[index])} + testID={CAMPAIGN_LEADERBOARD_TEST_IDS.TIER_TOGGLE} + /> + + )} + + {/* Error banner when has error but no data to display */} + {hasError && !isLoading && entries.length === 0 && ( + + + {strings('rewards.ondo_campaign_leaderboard.error_loading')} + + {onRetry && ( + + {strings('rewards.ondo_campaign_leaderboard.retry')} + + )} + + )} + + {/* Total participants */} + + + {strings('rewards.ondo_campaign_leaderboard.total_participants', { + count: totalParticipants.toLocaleString(), + })} + + + + {/* Leaderboard list */} + {entries.length > 0 ? ( + + + + ) : ( + + + {strings('rewards.ondo_campaign_leaderboard.no_entries_in_tier')} + + + )} + + ); +}; + +export default OndoLeaderboard; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts new file mode 100644 index 00000000000..fe6109a9a4e --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts @@ -0,0 +1,63 @@ +import { formatRateOfReturn, formatComputedAt } from './OndoLeaderboard.utils'; + +describe('OndoLeaderboard.utils', () => { + describe('formatRateOfReturn', () => { + it('formats positive rate with plus sign', () => { + expect(formatRateOfReturn(0.15)).toBe('+15.00%'); + }); + + it('formats negative rate without plus sign', () => { + expect(formatRateOfReturn(-0.05)).toBe('-5.00%'); + }); + + it('formats zero with plus sign', () => { + expect(formatRateOfReturn(0)).toBe('+0.00%'); + }); + + it('rounds to two decimal places', () => { + expect(formatRateOfReturn(0.1523)).toBe('+15.23%'); + }); + + it('formats large positive rate', () => { + expect(formatRateOfReturn(1.0)).toBe('+100.00%'); + }); + + it('formats small negative rate', () => { + expect(formatRateOfReturn(-0.0832)).toBe('-8.32%'); + }); + }); + + describe('formatComputedAt', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-03-20T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns empty string for null', () => { + expect(formatComputedAt(null)).toBe(''); + }); + + it('returns empty string for empty string', () => { + expect(formatComputedAt('')).toBe(''); + }); + + it('returns a non-empty string for a valid ISO timestamp', () => { + const result = formatComputedAt('2024-03-20T12:00:00.000Z'); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('returns empty string for an unparseable value', () => { + // new Date('not-a-date').toLocaleTimeString() returns 'Invalid Date', + // but our function catches and returns '' only on thrown errors. + // For invalid dates, toLocaleTimeString may return 'Invalid Date' without throwing. + // We only assert it does not throw and returns a string. + const result = formatComputedAt('not-a-date'); + expect(typeof result).toBe('string'); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts new file mode 100644 index 00000000000..d31fe607f94 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts @@ -0,0 +1,26 @@ +/** + * Formats the rate of return as a percentage string + */ +export const formatRateOfReturn = (rate: number): string => { + const percentage = rate * 100; + const sign = percentage >= 0 ? '+' : ''; + return `${sign}${percentage.toFixed(2)}%`; +}; + +/** + * Formats the computed at timestamp to a human-readable time string. + * Accepts string | null — returns '' for null or unparseable values. + */ +export const formatComputedAt = (isoString: string | null): string => { + if (!isoString) return ''; + try { + const date = new Date(isoString); + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'shortOffset', + }); + } catch { + return ''; + } +}; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx new file mode 100644 index 00000000000..b3a6402a1d4 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx @@ -0,0 +1,462 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import OndoLeaderboardPosition, { + ONDO_LEADERBOARD_POSITION_TEST_IDS, +} from './OndoLeaderboardPosition'; +import { useGetOndoLeaderboardPosition } from '../../hooks/useGetOndoLeaderboardPosition'; +import type { CampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../../selectors/rewards'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), +})); + +jest.mock('../../hooks/useGetOndoLeaderboardPosition'); + +const mockUseGetOndoLeaderboardPosition = + useGetOndoLeaderboardPosition as jest.MockedFunction< + typeof useGetOndoLeaderboardPosition + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const SUBSCRIPTION_ID = 'sub-456'; + +function setupSelectors({ isOptedIn = true }: { isOptedIn?: boolean } = {}) { + const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return SUBSCRIPTION_ID; + if (selector === mockOptedInSelector) return isOptedIn; + return undefined; + }); +} + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: `${testID}-retry` }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../util/formatFiat', () => ({ + __esModule: true, + default: (amount: { toFixed: (dp: number) => string }) => + `$${Number(amount.toFixed(2)).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const translations: Record = { + 'rewards.ondo_campaign_leaderboard_position.title': 'Your Position', + 'rewards.ondo_campaign_leaderboard_position.rank': 'Rank', + 'rewards.ondo_campaign_leaderboard_position.tier': 'Tier', + 'rewards.ondo_campaign_leaderboard_position.rate_of_return': 'Return', + 'rewards.ondo_campaign_leaderboard_position.total_deposited': + 'Total Deposited', + 'rewards.ondo_campaign_leaderboard_position.current_value': + 'Current Value', + 'rewards.ondo_campaign_leaderboard_position.updated_at': `Updated ${params?.time ?? ''}`, + 'rewards.ondo_campaign_leaderboard_position.not_found': + 'Not on the leaderboard yet', + 'rewards.ondo_campaign_leaderboard_position.error_loading': + 'Failed to load your position', + 'rewards.ondo_campaign_leaderboard_position.retry': 'Retry', + }; + return translations[key] || key; + }, +})); + +const CAMPAIGN_ID = 'campaign-123'; +const mockRefetch = jest.fn(); + +const MOCK_POSITION: CampaignLeaderboardPositionDto = { + rank: 5, + projected_tier: 'MID', + rate_of_return: 0.15, + total_usd_deposited: 10000.0, + current_usd_value: 12500.5, + computed_at: '2024-03-20T12:00:00.000Z', + total_in_tier: 150, + net_deposit: 8500.0, +}; + +describe('OndoLeaderboardPosition', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupSelectors(); + }); + + describe('opt-in guard', () => { + it('renders nothing when user is not opted in', () => { + setupSelectors({ isOptedIn: false }); + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.LOADING), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.ERROR), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.NOT_FOUND), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeNull(); + }); + + it('renders nothing when not opted in even if position data exists', () => { + setupSelectors({ isOptedIn: false }); + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: MOCK_POSITION, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeNull(); + }); + }); + + describe('loading state', () => { + it('renders skeleton when loading with no data', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: true, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.LOADING), + ).toBeDefined(); + }); + + it('does not render skeleton when loading but has data', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: MOCK_POSITION, + isLoading: true, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.LOADING), + ).toBeNull(); + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + }); + + describe('error state', () => { + it('renders error banner when has error and no data', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.ERROR), + ).toBeDefined(); + }); + + it('shows data and hides error banner when has error but position is present', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: MOCK_POSITION, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.ERROR), + ).toBeNull(); + }); + }); + + describe('initial/unfetched state', () => { + it('renders nothing before any fetch has completed', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.LOADING), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.ERROR), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.NOT_FOUND), + ).toBeNull(); + expect( + queryByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeNull(); + }); + }); + + describe('not found state', () => { + it('renders not found message when fetch completed with no data', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.NOT_FOUND), + ).toBeDefined(); + }); + }); + + describe('position data display', () => { + beforeEach(() => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: MOCK_POSITION, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + }); + + it('renders position container', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('renders title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Your Position')).toBeDefined(); + }); + + it('renders rank', () => { + const { getByTestId } = render( + , + ); + + const rankEl = getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.RANK); + expect(rankEl).toBeDefined(); + }); + + it('renders rank value as #5', () => { + const { getByText } = render( + , + ); + + expect(getByText('#5')).toBeDefined(); + }); + + it('renders projected tier', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.TIER), + ).toBeDefined(); + }); + + it('renders tier value', () => { + const { getByText } = render( + , + ); + + expect(getByText('MID')).toBeDefined(); + }); + + it('renders rate of return with positive sign', () => { + const { getByText } = render( + , + ); + + expect(getByText('+15.00%')).toBeDefined(); + }); + + it('renders negative rate of return without plus sign', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: { ...MOCK_POSITION, rate_of_return: -0.05 }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByText } = render( + , + ); + + expect(getByText('-5.00%')).toBeDefined(); + }); + + it('renders computed_at timestamp', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.COMPUTED_AT), + ).toBeDefined(); + }); + + it('renders total deposited and current value stat cells', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.TOTAL_DEPOSITED), + ).toBeDefined(); + expect( + getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.CURRENT_VALUE), + ).toBeDefined(); + }); + }); + + describe('hook integration', () => { + it('passes campaignId to hook', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + render(); + + expect(mockUseGetOndoLeaderboardPosition).toHaveBeenCalledWith( + CAMPAIGN_ID, + ); + }); + + it('passes undefined campaignId when not provided', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + render(); + + expect(mockUseGetOndoLeaderboardPosition).toHaveBeenCalledWith(undefined); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx new file mode 100644 index 00000000000..35815af8647 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + Text, + TextColor, + TextVariant, + FontWeight, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useSelector } from 'react-redux'; +import { strings } from '../../../../../../locales/i18n'; +import { useGetOndoLeaderboardPosition } from '../../hooks/useGetOndoLeaderboardPosition'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatRateOfReturn, formatComputedAt } from './OndoLeaderboard.utils'; +import formatFiat from '../../../../../util/formatFiat'; +import { BigNumber } from 'bignumber.js'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../../selectors/rewards'; + +export const ONDO_LEADERBOARD_POSITION_TEST_IDS = { + CONTAINER: 'ondo-leaderboard-position-container', + RANK: 'ondo-leaderboard-position-rank', + TIER: 'ondo-leaderboard-position-tier', + RATE_OF_RETURN: 'ondo-leaderboard-position-rate-of-return', + TOTAL_DEPOSITED: 'ondo-leaderboard-position-total-deposited', + CURRENT_VALUE: 'ondo-leaderboard-position-current-value', + COMPUTED_AT: 'ondo-leaderboard-position-computed-at', + LOADING: 'ondo-leaderboard-position-loading', + ERROR: 'ondo-leaderboard-position-error', + NOT_FOUND: 'ondo-leaderboard-position-not-found', +} as const; + +interface OndoLeaderboardPositionProps { + campaignId: string | undefined; +} + +const formatUsd = (value: number): string => + formatFiat(new BigNumber(value), 'USD'); + +const PositionSkeleton: React.FC = () => { + const tw = useTailwind(); + return ( + + {/* Title row */} + + {strings('rewards.ondo_campaign_leaderboard_position.title')} + + {/* Divider */} + + {/* Row 1: Rank | Tier */} + + + + + + {/* Row 2: Total Deposited | Current Value | Return */} + + + + + + + ); +}; + +const CELL_STYLE = { flex: 1 } as const; + +interface StatCellProps { + label: string; + value: string; + valueColor?: TextColor; + testID?: string; + style?: { flex: number }; +} + +const StatCell: React.FC = ({ + label, + value, + valueColor = TextColor.TextDefault, + testID, + style, +}) => ( + + + {label} + + + {value} + + +); + +/** + * OndoLeaderboardPosition displays the current user's position on the + * Ondo GM campaign leaderboard: rank, tier, rate of return, total deposited, + * and current USD value. + */ +const OndoLeaderboardPosition: React.FC = ({ + campaignId, +}) => { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const { position, isLoading, hasError, hasFetched, refetch } = + useGetOndoLeaderboardPosition(campaignId); + + if (!isOptedIn) { + return null; + } + + if (isLoading && !position) { + return ; + } + + if (hasError && !position) { + return ( + + ); + } + + if (!position) { + if (hasFetched) { + return ( + + + {strings('rewards.ondo_campaign_leaderboard_position.not_found')} + + + ); + } + return null; + } + + const rorColor = + position.rate_of_return >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + + return ( + + {/* Row 1: Title + Last Updated */} + + + {strings('rewards.ondo_campaign_leaderboard_position.title')} + + + {strings('rewards.ondo_campaign_leaderboard_position.updated_at', { + time: formatComputedAt(position.computed_at), + })} + + + + {/* Divider */} + + + {/* Grid row 1: Rank | Tier | (empty) */} + + + + + + + {/* Grid row 2: Total Deposited | Current Value | Return */} + + + + + + + ); +}; + +export default OndoLeaderboardPosition; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts new file mode 100644 index 00000000000..17957474134 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts @@ -0,0 +1,354 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetOndoLeaderboard } from './useGetOndoLeaderboard'; +import Engine from '../../../../core/Engine'; +import { + selectOndoCampaignLeaderboard, + selectOndoCampaignLeaderboardLoading, + selectOndoCampaignLeaderboardError, + selectOndoCampaignLeaderboardTierNames, + selectOndoCampaignLeaderboardSelectedTier, +} from '../../../../reducers/rewards/selectors'; +import { + setOndoCampaignLeaderboard, + setOndoCampaignLeaderboardLoading, + setOndoCampaignLeaderboardError, + setOndoCampaignLeaderboardSelectedTier, +} from '../../../../reducers/rewards'; +import type { CampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectOndoCampaignLeaderboard: jest.fn(), + selectOndoCampaignLeaderboardLoading: jest.fn(), + selectOndoCampaignLeaderboardError: jest.fn(), + selectOndoCampaignLeaderboardTierNames: jest.fn(), + selectOndoCampaignLeaderboardSelectedTier: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setOndoCampaignLeaderboard: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignLeaderboard', + payload, + })), + setOndoCampaignLeaderboardLoading: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignLeaderboardLoading', + payload, + })), + setOndoCampaignLeaderboardError: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignLeaderboardError', + payload, + })), + setOndoCampaignLeaderboardSelectedTier: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignLeaderboardSelectedTier', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_LEADERBOARD: CampaignLeaderboardDto = { + campaign_id: CAMPAIGN_ID, + computed_at: '2024-03-20T12:00:00.000Z', + tiers: { + STARTER: { + entries: [ + { rank: 1, referral_code: 'ABC123', rate_of_return: 0.15 }, + { rank: 2, referral_code: 'DEF456', rate_of_return: 0.1 }, + ], + total_participants: 50, + }, + MID: { + entries: [{ rank: 1, referral_code: 'GHI789', rate_of_return: 0.2 }], + total_participants: 30, + }, + }, +}; + +interface SelectorState { + leaderboard: CampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; + tierNames: string[]; + selectedTier: string | null; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectOndoCampaignLeaderboard) return state.leaderboard; + if (selector === selectOndoCampaignLeaderboardLoading) + return state.isLoading; + if (selector === selectOndoCampaignLeaderboardError) return state.hasError; + if (selector === selectOndoCampaignLeaderboardTierNames) + return state.tierNames; + if (selector === selectOndoCampaignLeaderboardSelectedTier) + return state.selectedTier; + return undefined; + }); +} + +describe('useGetOndoLeaderboard', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ + leaderboard: null, + isLoading: false, + hasError: false, + tierNames: [], + selectedTier: null, + }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetOndoLeaderboard(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardError(false), + ); + }); + + it('fetches leaderboard and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_LEADERBOARD as never); + + renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + // Wait for async operations + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getOndoCampaignLeaderboard', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboard(MOCK_LEADERBOARD), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardLoading(false), + ); + }); + + it('dispatches error action on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardLoading(false), + ); + }); + + it('returns leaderboard data from selector', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: 'STARTER', + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + expect(result.current.leaderboard).toEqual(MOCK_LEADERBOARD); + expect(result.current.tierNames).toEqual(['STARTER', 'MID']); + expect(result.current.selectedTier).toBe('STARTER'); + expect(result.current.computedAt).toBe('2024-03-20T12:00:00.000Z'); + }); + + it('returns selectedTierData for the currently selected tier', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: 'MID', + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + expect(result.current.selectedTierData).toEqual(MOCK_LEADERBOARD.tiers.MID); + }); + + it('returns null for selectedTierData when no tier is selected', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: null, + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + expect(result.current.selectedTierData).toBeNull(); + }); + + it('setSelectedTier dispatches action when tier is valid', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: 'STARTER', + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + act(() => { + result.current.setSelectedTier('MID'); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardSelectedTier('MID'), + ); + }); + + it('setSelectedTier does not dispatch action when tier is invalid', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: 'STARTER', + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + mockDispatch.mockClear(); + + act(() => { + result.current.setSelectedTier('INVALID'); + }); + + expect(mockDispatch).not.toHaveBeenCalledWith( + setOndoCampaignLeaderboardSelectedTier('INVALID'), + ); + }); + + it('applies defaultTier when it becomes available', async () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: null, + }); + + renderHook(() => + useGetOndoLeaderboard(CAMPAIGN_ID, { defaultTier: 'MID' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardSelectedTier('MID'), + ); + }); + + it('does not apply defaultTier when it is not in tierNames', async () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + tierNames: ['STARTER', 'MID'], + selectedTier: null, + }); + mockDispatch.mockClear(); + + renderHook(() => + useGetOndoLeaderboard(CAMPAIGN_ID, { defaultTier: 'UPPER' }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).not.toHaveBeenCalledWith( + setOndoCampaignLeaderboardSelectedTier('UPPER'), + ); + }); + + it('refetch function re-fetches the leaderboard', async () => { + mockCall.mockResolvedValue(MOCK_LEADERBOARD as never); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardLoading(true), + ); + }); + + it('returns loading state from selector', () => { + setupSelectors({ + leaderboard: null, + isLoading: true, + hasError: false, + tierNames: [], + selectedTier: null, + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ + leaderboard: null, + isLoading: false, + hasError: true, + tierNames: [], + selectedTier: null, + }); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + expect(result.current.hasError).toBe(true); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts new file mode 100644 index 00000000000..e6a0ff78c91 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts @@ -0,0 +1,138 @@ +import { useMemo, useEffect, useCallback, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectOndoCampaignLeaderboard, + selectOndoCampaignLeaderboardLoading, + selectOndoCampaignLeaderboardError, + selectOndoCampaignLeaderboardTierNames, + selectOndoCampaignLeaderboardSelectedTier, +} from '../../../../reducers/rewards/selectors'; +import { + setOndoCampaignLeaderboard, + setOndoCampaignLeaderboardLoading, + setOndoCampaignLeaderboardError, + setOndoCampaignLeaderboardSelectedTier, +} from '../../../../reducers/rewards'; +import type { + CampaignLeaderboardDto, + CampaignLeaderboardTier, +} from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseGetOndoLeaderboardOptions { + /** The tier to initially select (e.g., from user's position). Falls back to first tier if not found. */ + defaultTier?: string | null; +} + +export interface UseGetOndoLeaderboardResult { + /** Full leaderboard data, or null when not yet loaded */ + leaderboard: CampaignLeaderboardDto | null; + /** Whether the leaderboard is being fetched */ + isLoading: boolean; + /** Whether there was an error fetching the leaderboard */ + hasError: boolean; + /** List of available tier names (e.g., ['STARTER', 'MID', 'UPPER']) */ + tierNames: string[]; + /** Currently selected tier name */ + selectedTier: string | null; + /** Leaderboard data for the currently selected tier */ + selectedTierData: CampaignLeaderboardTier | null; + /** When the leaderboard was last computed (ISO string) */ + computedAt: string | null; + /** Change the selected tier */ + setSelectedTier: (tier: string) => void; + /** Manually re-fetch the leaderboard */ + refetch: () => Promise; +} + +/** + * Hook to fetch the campaign leaderboard showing top 20 participants per tier. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes by the RewardsController. + * + * @param campaignId - The campaign ID to fetch leaderboard for + * @param options - Optional configuration including defaultTier for initial selection + */ +export const useGetOndoLeaderboard = ( + campaignId: string | undefined, + options?: UseGetOndoLeaderboardOptions, +): UseGetOndoLeaderboardResult => { + const { defaultTier } = options ?? {}; + const dispatch = useDispatch(); + + const leaderboard = useSelector(selectOndoCampaignLeaderboard); + const isLoading = useSelector(selectOndoCampaignLeaderboardLoading); + const hasError = useSelector(selectOndoCampaignLeaderboardError); + const tierNames = useSelector(selectOndoCampaignLeaderboardTierNames); + const selectedTier = useSelector(selectOndoCampaignLeaderboardSelectedTier); + + // Track if we've already applied the defaultTier to avoid overriding user selection + const hasAppliedDefaultTier = useRef(false); + + const fetchLeaderboard = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setOndoCampaignLeaderboardLoading(false)); + dispatch(setOndoCampaignLeaderboardError(false)); + return; + } + + try { + dispatch(setOndoCampaignLeaderboardLoading(true)); + dispatch(setOndoCampaignLeaderboardError(false)); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getOndoCampaignLeaderboard', + campaignId, + ); + dispatch(setOndoCampaignLeaderboard(result)); + } catch { + dispatch(setOndoCampaignLeaderboardError(true)); + } finally { + dispatch(setOndoCampaignLeaderboardLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchLeaderboard(); + }, [fetchLeaderboard]); + + // Update selected tier when defaultTier becomes available (e.g., after position loads) + // Only apply once - don't override subsequent user selections + useEffect(() => { + if ( + !hasAppliedDefaultTier.current && + defaultTier && + tierNames.includes(defaultTier) + ) { + hasAppliedDefaultTier.current = true; + dispatch(setOndoCampaignLeaderboardSelectedTier(defaultTier)); + } + }, [defaultTier, tierNames, dispatch]); + + const setSelectedTier = useCallback( + (tier: string) => { + if (tierNames.includes(tier)) { + dispatch(setOndoCampaignLeaderboardSelectedTier(tier)); + } + }, + [tierNames, dispatch], + ); + + const selectedTierData = useMemo( + () => (selectedTier ? (leaderboard?.tiers[selectedTier] ?? null) : null), + [selectedTier, leaderboard], + ); + + return { + leaderboard, + isLoading, + hasError, + tierNames, + selectedTier, + selectedTierData, + computedAt: leaderboard?.computed_at ?? null, + setSelectedTier, + refetch: fetchLeaderboard, + }; +}; + +export default useGetOndoLeaderboard; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts new file mode 100644 index 00000000000..95767881519 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -0,0 +1,282 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectOndoCampaignLeaderboardPositionById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setOndoCampaignLeaderboardPosition: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignLeaderboardPosition', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSelectCampaignLeaderboardPositionById = + selectOndoCampaignLeaderboardPositionById as jest.MockedFunction< + typeof selectOndoCampaignLeaderboardPositionById + >; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MOCK_POSITION: CampaignLeaderboardPositionDto = { + projected_tier: 'MID', + rank: 5, + total_in_tier: 150, + rate_of_return: 0.15, + current_usd_value: 12500.5, + total_usd_deposited: 10000.0, + net_deposit: 8500.0, + computed_at: '2024-03-20T12:00:00.000Z', +}; + +interface SelectorState { + subscriptionId: string | null; + position: CampaignLeaderboardPositionDto | null; + isOptedIn?: boolean; +} + +function setupSelectors(state: SelectorState) { + const isOptedIn = state.isOptedIn ?? true; + const mockPositionSelector = jest.fn().mockReturnValue(state.position); + const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignLeaderboardPositionById.mockReturnValue( + mockPositionSelector, + ); + mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return state.subscriptionId; + if (selector === mockPositionSelector) return state.position; + if (selector === mockOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useGetOndoLeaderboardPosition', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + }); + }); + + it('does not fetch when subscriptionId is missing', async () => { + setupSelectors({ + subscriptionId: null, + position: null, + }); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when not opted in', async () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + isOptedIn: false, + }); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when campaignId is undefined', async () => { + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(undefined), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('fetches position and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + renderHook(() => useGetOndoLeaderboardPosition(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getOndoCampaignLeaderboardPosition', + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardPosition({ + subscriptionId: SUBSCRIPTION_ID, + campaignId: CAMPAIGN_ID, + position: MOCK_POSITION, + }), + ); + }); + + it('dispatches error action on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasError).toBe(true); + }); + + it('returns position data from selector', () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: MOCK_POSITION, + }); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toEqual(MOCK_POSITION); + }); + + it('returns loading state', async () => { + mockCall.mockResolvedValue(MOCK_POSITION as never); + + const { result, waitForNextUpdate } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + // Wait for the fetch to complete + await act(async () => { + await waitForNextUpdate(); + }); + + // After fetch completes, check the final state + expect(result.current.isLoading).toBe(false); + }); + + it('returns error state', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the position', async () => { + mockCall.mockResolvedValue(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it('returns null position when not loaded', () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + }); + + const { result } = renderHook(() => + useGetOndoLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toBeNull(); + }); + + it('calls selectOndoCampaignLeaderboardPositionById with subscriptionId and campaignId', () => { + renderHook(() => useGetOndoLeaderboardPosition(CAMPAIGN_ID)); + + expect(mockSelectCampaignLeaderboardPositionById).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + CAMPAIGN_ID, + ); + }); + + it('subscribes to RewardsController:leaderboardPositionInvalidated to auto-refetch', () => { + renderHook(() => useGetOndoLeaderboardPosition(CAMPAIGN_ID)); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + 'RewardsController:leaderboardPositionInvalidated', + ]), + expect.any(Function), + ); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts new file mode 100644 index 00000000000..be4f0b96cc3 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; +import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; + +export interface UseGetOndoLeaderboardPositionResult { + /** User's leaderboard position, or null when not found/not yet loaded */ + position: CampaignLeaderboardPositionDto | null; + /** Whether the position is being fetched */ + isLoading: boolean; + /** Whether there was an error fetching the position */ + hasError: boolean; + /** Whether at least one fetch attempt has completed (success or error) */ + hasFetched: boolean; + /** Manually re-fetch the position */ + refetch: () => Promise; +} + +/** + * Hook to fetch the current user's position on the Ondo campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes by the RewardsController. + */ +export const useGetOndoLeaderboardPosition = ( + campaignId: string | undefined, +): UseGetOndoLeaderboardPositionResult => { + const dispatch = useDispatch(); + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const position = useSelector( + selectOndoCampaignLeaderboardPositionById( + subscriptionId ?? undefined, + campaignId, + ), + ); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + const fetchPosition = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setIsLoading(false); + setHasError(false); + setHasFetched(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getOndoCampaignLeaderboardPosition', + campaignId, + subscriptionId, + ); + dispatch( + setOndoCampaignLeaderboardPosition({ + subscriptionId, + campaignId, + position: result, + }), + ); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + setHasFetched(true); + } + }, [dispatch, subscriptionId, campaignId, isOptedIn]); + + useEffect(() => { + fetchPosition(); + }, [fetchPosition]); + + // Refetch whenever opting into a campaign invalidates the cached position + const invalidationEvents = useMemo( + () => ['RewardsController:leaderboardPositionInvalidated'] as const, + [], + ); + useInvalidateByRewardEvents(invalidationEvents, fetchPosition); + + return { position, isLoading, hasError, hasFetched, refetch: fetchPosition }; +}; + +export default useGetOndoLeaderboardPosition; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 94a630398d3..329263d9c7d 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -104,6 +104,7 @@ const Routes = { REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW: 'RewardsCampaignDetails', REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW: 'RewardsSeasonOneCampaignDetails', REWARDS_CAMPAIGN_MECHANICS: 'RewardsCampaignMechanics', + REWARDS_ONDO_CAMPAIGN_LEADERBOARD: 'RewardsOndoCampaignLeaderboard', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', WHATS_HAPPENING_DETAIL: 'WhatsHappeningDetailView', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index d8614930c54..bd86585b793 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -18,6 +18,9 @@ import { type SeasonStateDto, SeasonRewardType, type LineaTokenRewardDto, + type CampaignState, + type CampaignParticipantStatusState, + type CampaignLeaderboardPositionState, } from './types'; import type { CaipAccountId, Json } from '@metamask/utils'; import { base58 } from 'ethers/lib/utils'; @@ -15831,6 +15834,8 @@ describe('RewardsController', () => { "campaignParticipantStatus": {}, "campaigns": {}, "offDeviceSubscriptionAccounts": {}, + "ondoCampaignLeaderboard": {}, + "ondoCampaignLeaderboardPositions": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, @@ -15853,6 +15858,8 @@ describe('RewardsController', () => { "campaignParticipantStatus": {}, "campaigns": {}, "offDeviceSubscriptionAccounts": {}, + "ondoCampaignLeaderboard": {}, + "ondoCampaignLeaderboardPositions": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, @@ -15880,6 +15887,8 @@ describe('RewardsController', () => { "campaignParticipantStatus": {}, "campaigns": {}, "offDeviceSubscriptionAccounts": {}, + "ondoCampaignLeaderboard": {}, + "ondoCampaignLeaderboardPositions": {}, "pointsEvents": {}, "rewardsEnvUrl": null, "seasonStatuses": {}, @@ -16852,6 +16861,168 @@ describe('RewardsController', () => { ).toBeUndefined(); expect(testController.state.pointsEvents[compositeKey]).toBeUndefined(); }); + + it('invalidates campaign and ondo leaderboard data when seasonId is provided', async () => { + // Arrange + const subscriptionId = 'test-subscription-id'; + const seasonId = 'test-season-id'; + const campaignId = 'test-campaign-id'; + + const initialState = getRewardsControllerDefaultState(); + const seasonCompositeKey = `${seasonId}:${subscriptionId}`; + const campaignCompositeKey = `${subscriptionId}:${campaignId}`; + + initialState.seasonStatuses[seasonCompositeKey] = {} as SeasonStatusState; + initialState.campaigns.CAMPAIGNS_CACHE_KEY = { + campaigns: [ + { + id: campaignId, + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2024-01-01', + endDate: '2024-12-31', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + featured: false, + }, + ], + lastFetched: Date.now(), + } as CampaignState; + initialState.campaignParticipantStatus[campaignCompositeKey] = { + optedIn: true, + participantCount: 10, + lastFetched: Date.now(), + } as CampaignParticipantStatusState; + initialState.ondoCampaignLeaderboardPositions[campaignCompositeKey] = { + projected_tier: 'MID', + rank: 5, + total_in_tier: 100, + lastFetched: Date.now(), + } as CampaignLeaderboardPositionState; + + const testController = new RewardsController({ + messenger: mockMessenger, + state: initialState, + }); + + // Act + testController.invalidateSubscriptionCache(subscriptionId, seasonId); + + // Assert - season data cleared + expect( + testController.state.seasonStatuses[seasonCompositeKey], + ).toBeUndefined(); + // Assert - campaigns are NOT cleared (global data, not subscription-specific) + expect(testController.state.campaigns.CAMPAIGNS_CACHE_KEY).toBeDefined(); + expect( + testController.state.campaignParticipantStatus[campaignCompositeKey], + ).toBeUndefined(); + expect( + testController.state.ondoCampaignLeaderboardPositions[ + campaignCompositeKey + ], + ).toBeUndefined(); + }); + + it('invalidates campaign and ondo leaderboard data when no seasonId is provided', async () => { + // Arrange + const subscriptionId = 'test-subscription-id'; + const otherSubscriptionId = 'other-subscription-id'; + const campaignId1 = 'campaign-1'; + const campaignId2 = 'campaign-2'; + + const initialState = getRewardsControllerDefaultState(); + const campaignKey1 = `${subscriptionId}:${campaignId1}`; + const campaignKey2 = `${subscriptionId}:${campaignId2}`; + const otherCampaignKey = `${otherSubscriptionId}:${campaignId1}`; + + initialState.campaigns.CAMPAIGNS_CACHE_KEY = { + campaigns: [ + { + id: campaignId1, + type: CampaignType.ONDO_HOLDING, + name: 'Campaign 1', + startDate: '2024-01-01', + endDate: '2024-12-31', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + featured: false, + }, + { + id: campaignId2, + type: CampaignType.ONDO_HOLDING, + name: 'Campaign 2', + startDate: '2024-01-01', + endDate: '2024-12-31', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + featured: false, + }, + ], + lastFetched: Date.now(), + } as CampaignState; + initialState.campaignParticipantStatus[campaignKey1] = { + optedIn: true, + participantCount: 5, + lastFetched: Date.now(), + } as CampaignParticipantStatusState; + initialState.campaignParticipantStatus[campaignKey2] = { + optedIn: false, + participantCount: 3, + lastFetched: Date.now(), + } as CampaignParticipantStatusState; + initialState.campaignParticipantStatus[otherCampaignKey] = { + optedIn: true, + participantCount: 7, + lastFetched: Date.now(), + } as CampaignParticipantStatusState; + initialState.ondoCampaignLeaderboardPositions[campaignKey1] = { + projected_tier: 'STARTER', + rank: 10, + total_in_tier: 200, + lastFetched: Date.now(), + } as CampaignLeaderboardPositionState; + initialState.ondoCampaignLeaderboardPositions[otherCampaignKey] = { + projected_tier: 'UPPER', + rank: 1, + total_in_tier: 50, + lastFetched: Date.now(), + } as CampaignLeaderboardPositionState; + + const testController = new RewardsController({ + messenger: mockMessenger, + state: initialState, + }); + + // Act + testController.invalidateSubscriptionCache(subscriptionId); + + // Assert - campaigns are NOT cleared (global data, not subscription-specific) + expect(testController.state.campaigns.CAMPAIGNS_CACHE_KEY).toBeDefined(); + expect( + testController.state.campaignParticipantStatus[campaignKey1], + ).toBeUndefined(); + expect( + testController.state.campaignParticipantStatus[campaignKey2], + ).toBeUndefined(); + expect( + testController.state.ondoCampaignLeaderboardPositions[campaignKey1], + ).toBeUndefined(); + + // Assert - other subscription data untouched + expect( + testController.state.campaignParticipantStatus[otherCampaignKey], + ).toBeDefined(); + expect( + testController.state.ondoCampaignLeaderboardPositions[otherCampaignKey], + ).toBeDefined(); + }); }); describe('isOptInSupported', () => { @@ -18610,6 +18781,7 @@ describe('RewardsController', () => { endDate: string; termsAndConditions: Json | null; excludedRegions: string[]; + statusLabel: string; details: null; featured: boolean; }> = {}, @@ -18621,6 +18793,7 @@ describe('RewardsController', () => { endDate: '2027-01-01T00:00:00.000Z', termsAndConditions: null, excludedRegions: [], + statusLabel: 'Active', details: null, featured: false, ...overrides, @@ -18694,25 +18867,23 @@ describe('RewardsController', () => { expect(result).toEqual(mockCampaigns); expect(result).toHaveLength(2); - const cachedData = controller.state.campaigns[mockSubscriptionId]; - expect(cachedData).toBeDefined(); - expect(cachedData.campaigns).toEqual(mockCampaigns); - expect(cachedData.lastFetched).toBeGreaterThan(Date.now() - 1000); + const cached = controller.state.campaigns.CAMPAIGNS_CACHE_KEY; + expect(cached).toBeDefined(); + expect(cached.campaigns).toEqual(mockCampaigns); + expect(cached.lastFetched).toBeGreaterThan(Date.now() - 1000); }); it('returns cached campaigns when cache is fresh', async () => { const recentTime = Date.now() - 60000; // 1 minute ago (within 5 minute threshold) - const mockCachedCampaigns = [ - createTestCampaign({ id: 'cached-campaign' }), - ]; + const mockCachedCampaign = createTestCampaign({ id: 'cached-campaign' }); controller = new RewardsController({ messenger: mockMessenger, state: { ...getRewardsControllerDefaultState(), campaigns: { - [mockSubscriptionId]: { - campaigns: mockCachedCampaigns, + CAMPAIGNS_CACHE_KEY: { + campaigns: [mockCachedCampaign], lastFetched: recentTime, }, }, @@ -18721,13 +18892,13 @@ describe('RewardsController', () => { const result = await controller.getCampaigns(mockSubscriptionId); - expect(result).toEqual(mockCachedCampaigns); + expect(result).toEqual([mockCachedCampaign]); expect(mockMessenger.call).not.toHaveBeenCalled(); }); it('fetches fresh campaigns when cache is stale', async () => { const staleTime = Date.now() - 1000 * 60 * 10; // 10 minutes ago (beyond 5 minute threshold) - const staleCampaigns = [createTestCampaign({ id: 'stale-campaign' })]; + const staleCampaign = createTestCampaign({ id: 'stale-campaign' }); const freshCampaigns = [createTestCampaign({ id: 'fresh-campaign' })]; controller = new RewardsController({ @@ -18735,8 +18906,8 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaigns: { - [mockSubscriptionId]: { - campaigns: staleCampaigns, + CAMPAIGNS_CACHE_KEY: { + campaigns: [staleCampaign], lastFetched: staleTime, }, }, @@ -19014,6 +19185,266 @@ describe('RewardsController', () => { }); }); + describe('getOndoCampaignLeaderboard', () => { + let mockMessenger: jest.Mocked; + const mockCampaignId = 'campaign-ondo-123'; + const mockLeaderboard = { + campaign_id: mockCampaignId, + computed_at: '2024-03-20T12:00:00.000Z', + tiers: { + STARTER: { entries: [], total_participants: 100 }, + }, + }; + + beforeEach(() => { + mockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns empty leaderboard when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = + await disabledController.getOndoCampaignLeaderboard(mockCampaignId); + + expect(result).toEqual({ + campaign_id: mockCampaignId, + computed_at: '', + tiers: {}, + }); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches leaderboard from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockLeaderboard); + + const result = await ctrl.getOndoCampaignLeaderboard(mockCampaignId); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getOndoCampaignLeaderboard', + mockCampaignId, + ); + expect(result).toEqual(mockLeaderboard); + expect(ctrl.state.ondoCampaignLeaderboard[mockCampaignId]).toBeDefined(); + }); + + it('returns cached leaderboard when cache is fresh', async () => { + const recentTime = Date.now() - 60000; + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: { + ...getRewardsControllerDefaultState(), + ondoCampaignLeaderboard: { + [mockCampaignId]: { + ...mockLeaderboard, + lastFetched: recentTime, + }, + }, + }, + }); + + const result = await ctrl.getOndoCampaignLeaderboard(mockCampaignId); + + expect(result).toEqual(mockLeaderboard); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('logs when fetching fresh leaderboard', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockLeaderboard); + mockLogger.log.mockClear(); + + await ctrl.getOndoCampaignLeaderboard(mockCampaignId); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching fresh campaign leaderboard via API call', + ); + }); + }); + + describe('getOndoCampaignLeaderboardPosition', () => { + let mockMessenger: jest.Mocked; + const mockCampaignId = 'campaign-ondo-456'; + const mockSubscriptionId = 'sub-789'; + const mockPosition = { + projected_tier: 'MID', + rank: 5, + total_in_tier: 150, + rate_of_return: 0.15, + current_usd_value: 12500.5, + total_usd_deposited: 10000.0, + net_deposit: 8500.0, + computed_at: '2024-03-20T12:00:00.000Z', + }; + + beforeEach(() => { + mockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns null when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = + await disabledController.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches position from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockPosition); + + const result = await ctrl.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getOndoCampaignLeaderboardPosition', + mockCampaignId, + mockSubscriptionId, + ); + expect(result).toEqual(mockPosition); + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + expect( + ctrl.state.ondoCampaignLeaderboardPositions[cacheKey], + ).toBeDefined(); + }); + + it('returns cached position when cache is fresh', async () => { + const recentTime = Date.now() - 60000; + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: { + ...getRewardsControllerDefaultState(), + ondoCampaignLeaderboardPositions: { + [cacheKey]: { + ...mockPosition, + lastFetched: recentTime, + }, + }, + }, + }); + + const result = await ctrl.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toEqual(mockPosition); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null and caches a not-found sentinel when API returns null (user not on leaderboard)', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(null); + + const result = await ctrl.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + const cached = ctrl.state.ondoCampaignLeaderboardPositions[cacheKey]; + expect(cached).toBeDefined(); + expect(cached).toMatchObject({ notFound: true }); + expect((cached as { lastFetched: number }).lastFetched).toBeGreaterThan( + 0, + ); + }); + + it('does not re-fetch when a fresh not-found sentinel is cached (TTL respected)', async () => { + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: { + ...getRewardsControllerDefaultState(), + ondoCampaignLeaderboardPositions: { + [cacheKey]: { + notFound: true as const, + lastFetched: Date.now() - 60000, + }, + }, + }, + }); + + const result = await ctrl.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('logs when fetching fresh position', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockPosition); + mockLogger.log.mockClear(); + + await ctrl.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching fresh campaign leaderboard position via API call', + ); + }); + }); + describe('getClientVersionRequirements', () => { it('fetches version requirements from the data service', async () => { const mockRequirements = { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 090ca4c9996..b9b8de8fdde 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -23,6 +23,8 @@ import { type RewardDto, type CampaignDto, type CampaignParticipantStatusDto, + type CampaignLeaderboardDto, + type CampaignLeaderboardPositionDto, type PointsEstimateHistoryEntry, ClaimRewardDto, PointsEventsDtoState, @@ -34,6 +36,8 @@ import { type LineaTokenRewardDto, type OffDeviceSubscriptionAccountsState, type ClientVersionRequirementDto, + type CampaignState, + type CampaignDtoState, BASE32_REGEX, CampaignType, } from './types'; @@ -100,10 +104,17 @@ const OFF_DEVICE_SUBSCRIPTION_ACCOUNTS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 // Campaigns cache threshold const CAMPAIGNS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes +const CAMPAIGNS_CACHE_KEY = 'CAMPAIGNS_CACHE_KEY'; // Campaign participant status cache threshold const CAMPAIGN_PARTICIPANT_STATUS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes +// Campaign leaderboard cache threshold (hourly job updates, but we cache for 5 min) +const ONDO_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes + +// Campaign leaderboard position cache threshold +const ONDO_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes + // Points events cache threshold (first page only) const POINTS_EVENTS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute cache @@ -189,6 +200,18 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + ondoCampaignLeaderboard: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + ondoCampaignLeaderboardPositions: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, pointsEstimateHistory: { includeInStateLogs: true, persist: true, @@ -219,6 +242,8 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ offDeviceSubscriptionAccounts: {}, campaigns: {}, campaignParticipantStatus: {}, + ondoCampaignLeaderboard: {}, + ondoCampaignLeaderboardPositions: {}, pointsEstimateHistory: [], rewardsEnvUrl: null, }); @@ -597,6 +622,14 @@ export class RewardsController extends BaseController< 'RewardsController:getCampaignParticipantStatus', this.getCampaignParticipantStatus.bind(this), ); + this.messenger.registerActionHandler( + 'RewardsController:getOndoCampaignLeaderboard', + this.getOndoCampaignLeaderboard.bind(this), + ); + this.messenger.registerActionHandler( + 'RewardsController:getOndoCampaignLeaderboardPosition', + this.getOndoCampaignLeaderboardPosition.bind(this), + ); this.messenger.registerActionHandler( 'RewardsController:claimReward', this.claimReward.bind(this), @@ -2636,6 +2669,17 @@ export class RewardsController extends BaseController< delete state.accounts[state.activeAccount.account]; state.activeAccount = null; } + // Clear all cached data keyed by this subscription ID + Object.keys(state.campaignParticipantStatus).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.campaignParticipantStatus[key]; + } + }); + Object.keys(state.ondoCampaignLeaderboardPositions).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.ondoCampaignLeaderboardPositions[key]; + } + }); }); Logger.log('RewardsController: Logout completed successfully'); @@ -3387,18 +3431,20 @@ export class RewardsController extends BaseController< if (!rewardsEnabled) { return []; } - const result = await wrapWithCache({ - key: subscriptionId, + + return wrapWithCache({ + key: CAMPAIGNS_CACHE_KEY, ttl: CAMPAIGNS_CACHE_THRESHOLD_MS, readCache: (key) => { - const cachedCampaigns = this.state.campaigns[key] || undefined; - if (!cachedCampaigns) return; - return { - payload: cachedCampaigns.campaigns, - lastFetched: cachedCampaigns.lastFetched, - }; + const cached = this.state.campaigns[key]; + return cached + ? { + payload: cached.campaigns as CampaignDto[], + lastFetched: cached.lastFetched, + } + : undefined; }, - fetchFresh: async () => + fetchFresh: () => this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh campaigns data via API call', @@ -3409,20 +3455,15 @@ export class RewardsController extends BaseController< )) as CampaignDto[]; return response || []; }, subscriptionId), - writeCache: (key: string, payload: CampaignDto[]) => { - this.#writeCampaignsCache(key, payload); + writeCache: (key, campaigns) => { + this.update((state) => { + (state.campaigns as Record)[key] = { + campaigns: campaigns as CampaignDtoState[], + lastFetched: Date.now(), + } as CampaignState; + }); }, }); - - return result; - } - - #writeCampaignsCache(key: string, campaigns: CampaignDto[]): void { - const cacheEntry = { campaigns, lastFetched: Date.now() }; - this.update((state) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state.campaigns as Record)[key] = cacheEntry as any; - }); } /** @@ -3449,9 +3490,10 @@ export class RewardsController extends BaseController< campaignId, )) as CampaignParticipantStatusDto; }, subscriptionId); - // Invalidate the participant status cache so the next fetch gets fresh data + // Invalidate the participant status cache and leaderboard position cache this.update((state) => { delete state.campaignParticipantStatus[key]; + delete state.ondoCampaignLeaderboardPositions[key]; }); // Only emit if the user wasn't already opted in, to avoid redundant refetches if (!wasAlreadyOptedIn) { @@ -3459,6 +3501,13 @@ export class RewardsController extends BaseController< campaignId, subscriptionId, }); + this.messenger.publish( + 'RewardsController:leaderboardPositionInvalidated', + { + campaignId, + subscriptionId, + }, + ); } return result; } @@ -3515,6 +3564,137 @@ export class RewardsController extends BaseController< return result; } + /** + * Get the campaign leaderboard showing top 20 participants per tier. + * This is a public endpoint - no authentication required. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard data grouped by tier. + */ + async getOndoCampaignLeaderboard( + campaignId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return { campaign_id: campaignId, computed_at: '', tiers: {} }; + } + + const result = await wrapWithCache({ + key: campaignId, + ttl: ONDO_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.ondoCampaignLeaderboard[k]; + if (!cached) return undefined; + return { + payload: { + campaign_id: cached.campaign_id, + computed_at: cached.computed_at, + tiers: cached.tiers, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => { + Logger.log( + 'RewardsController: Fetching fresh campaign leaderboard via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getOndoCampaignLeaderboard', + campaignId, + )) as CampaignLeaderboardDto; + }, + writeCache: (k, payload) => { + this.update((state) => { + state.ondoCampaignLeaderboard[k] = { + campaign_id: payload.campaign_id, + computed_at: payload.computed_at, + tiers: payload.tiers, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } + + /** + * Get the current user's position on the campaign leaderboard. + * This is an authenticated endpoint. + * Results are cached for 5 minutes. + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found. + */ + async getOndoCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + + const key = `${subscriptionId}:${campaignId}`; + const result = await wrapWithCache({ + key, + ttl: ONDO_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.ondoCampaignLeaderboardPositions[k]; + if (!cached) return undefined; + if ('notFound' in cached) { + return { payload: null, lastFetched: cached.lastFetched }; + } + return { + payload: { + projected_tier: cached.projected_tier, + rank: cached.rank, + total_in_tier: cached.total_in_tier, + rate_of_return: cached.rate_of_return, + current_usd_value: cached.current_usd_value, + total_usd_deposited: cached.total_usd_deposited, + net_deposit: cached.net_deposit, + computed_at: cached.computed_at, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching fresh campaign leaderboard position via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getOndoCampaignLeaderboardPosition', + campaignId, + subscriptionId, + )) as CampaignLeaderboardPositionDto | null; + }, subscriptionId), + writeCache: (k, payload) => { + if (payload === null) { + this.update((state) => { + state.ondoCampaignLeaderboardPositions[k] = { + notFound: true, + lastFetched: Date.now(), + }; + }); + } else { + this.update((state) => { + state.ondoCampaignLeaderboardPositions[k] = { + projected_tier: payload.projected_tier, + rank: payload.rank, + total_in_tier: payload.total_in_tier, + rate_of_return: payload.rate_of_return, + current_usd_value: payload.current_usd_value, + total_usd_deposited: payload.total_usd_deposited, + net_deposit: payload.net_deposit, + computed_at: payload.computed_at, + lastFetched: Date.now(), + }; + }); + } + }, + }); + return result; + } + /** * Claim a reward * @param rewardId - The reward ID @@ -3738,6 +3918,16 @@ export class RewardsController extends BaseController< delete state.activeBoosts[compositeKey]; delete state.pointsEvents[compositeKey]; delete state.subscriptionReferralDetails[compositeKey]; + Object.keys(state.campaignParticipantStatus).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.campaignParticipantStatus[key]; + } + }); + Object.keys(state.ondoCampaignLeaderboardPositions).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.ondoCampaignLeaderboardPositions[key]; + } + }); }); } else { // Invalidate all seasons for this subscription @@ -3774,6 +3964,16 @@ export class RewardsController extends BaseController< OffDeviceSubscriptionAccountsState > )[subscriptionId]; + Object.keys(state.campaignParticipantStatus).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.campaignParticipantStatus[key]; + } + }); + Object.keys(state.ondoCampaignLeaderboardPositions).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.ondoCampaignLeaderboardPositions[key]; + } + }); }); } diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index 7ae9f273a0e..b2f996bcae7 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -4491,6 +4491,112 @@ describe('RewardsDataService', () => { }); }); + describe('getOndoCampaignLeaderboard', () => { + const mockCampaignId = 'campaign-ondo-123'; + const mockLeaderboard = { + campaign_id: mockCampaignId, + computed_at: '2024-03-20T12:00:00.000Z', + tiers: { + STARTER: { entries: [], total_participants: 100 }, + MID: { entries: [], total_participants: 50 }, + }, + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockLeaderboard), + } as unknown as Response); + }); + + it('calls the correct public endpoint with GET and returns leaderboard data', async () => { + const result = await service.getOndoCampaignLeaderboard(mockCampaignId); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/ondo-gm/${mockCampaignId}/leaderboard`, + expect.objectContaining({ + method: 'GET', + }), + ); + expect(result).toEqual(mockLeaderboard); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); + + await expect( + service.getOndoCampaignLeaderboard(mockCampaignId), + ).rejects.toThrow('Get campaign leaderboard failed: 500'); + }); + }); + + describe('getOndoCampaignLeaderboardPosition', () => { + const mockCampaignId = 'campaign-ondo-456'; + const mockSubscriptionId = 'sub-789'; + const mockToken = 'test-bearer-token'; + const mockPosition = { + projected_tier: 'MID', + rank: 5, + total_in_tier: 150, + rate_of_return: 0.15, + current_usd_value: 12500.5, + total_usd_deposited: 10000.0, + net_deposit: 8500.0, + computed_at: '2024-03-20T12:00:00.000Z', + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPosition), + } as unknown as Response); + }); + + it('calls the correct authenticated endpoint with GET and returns position', async () => { + const result = await service.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/ondo-gm/${mockCampaignId}/leaderboard/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockPosition); + }); + + it('returns null when response status is 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response); + + const result = await service.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + }); + + it('throws when response is not ok with non-404 status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); + + await expect( + service.getOndoCampaignLeaderboardPosition( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow('Get campaign leaderboard position failed: 500'); + }); + }); + describe('getClientVersionRequirements', () => { const mockVersionRequirements = { minimumMobileVersion: '7.72.0', diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index a28fda4d0e9..2be7a865b3a 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -28,6 +28,8 @@ import type { CampaignDto, CampaignParticipantStatusDto, ClientVersionRequirementDto, + CampaignLeaderboardDto, + CampaignLeaderboardPositionDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -220,6 +222,16 @@ export interface RewardsDataServiceGetClientVersionRequirementsAction { handler: RewardsDataService['getClientVersionRequirements']; } +export interface RewardsDataServiceGetOndoCampaignLeaderboardAction { + type: `${typeof SERVICE_NAME}:getOndoCampaignLeaderboard`; + handler: RewardsDataService['getOndoCampaignLeaderboard']; +} + +export interface RewardsDataServiceGetOndoCampaignLeaderboardPositionAction { + type: `${typeof SERVICE_NAME}:getOndoCampaignLeaderboardPosition`; + handler: RewardsDataService['getOndoCampaignLeaderboardPosition']; +} + export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; handler: RewardsDataService['getRewardsEnvUrl']; @@ -272,7 +284,9 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetCampaignsAction | RewardsDataServiceOptInToCampaignAction | RewardsDataServiceGetCampaignParticipantStatusAction - | RewardsDataServiceGetClientVersionRequirementsAction; + | RewardsDataServiceGetClientVersionRequirementsAction + | RewardsDataServiceGetOndoCampaignLeaderboardAction + | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -419,6 +433,14 @@ export class RewardsDataService { `${SERVICE_NAME}:getCampaignParticipantStatus`, this.getCampaignParticipantStatus.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getOndoCampaignLeaderboard`, + this.getOndoCampaignLeaderboard.bind(this), + ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getOndoCampaignLeaderboardPosition`, + this.getOndoCampaignLeaderboardPosition.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1390,4 +1412,55 @@ export class RewardsDataService { return (await response.json()) as CampaignParticipantStatusDto; } + + /** + * Get the campaign leaderboard showing top 20 participants per tier. + * This is a public endpoint - no authentication required. + * @param campaignId - The campaign ID to get leaderboard for. + * @returns The leaderboard data grouped by tier. + */ + async getOndoCampaignLeaderboard( + campaignId: string, + ): Promise { + const response = await this.makeRequest( + `/ondo-gm/${campaignId}/leaderboard`, + { method: 'GET' }, + ); + + if (!response.ok) { + throw new Error(`Get campaign leaderboard failed: ${response.status}`); + } + + return (await response.json()) as CampaignLeaderboardDto; + } + + /** + * Get the current user's position on the campaign leaderboard. + * This is an authenticated endpoint. + * @param campaignId - The campaign ID to get position for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The user's leaderboard position, or null if not found (404). + */ + async getOndoCampaignLeaderboardPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/ondo-gm/${campaignId}/leaderboard/me`, + { method: 'GET' }, + subscriptionId, + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error( + `Get campaign leaderboard position failed: ${response.status}`, + ); + } + + return (await response.json()) as CampaignLeaderboardPositionDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 680aadc81d4..5b80f815e9a 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -158,40 +158,78 @@ export interface CampaignDto { featured: boolean; } +/** + * Serializable version of OndoCampaignStep for state storage. + */ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type CampaignsState = { - campaigns: { - id: string; - type: CampaignType; - name: string; - startDate: string; - endDate: string; - termsAndConditions: Json | null; - excludedRegions: string[]; - image?: { - lightModeUrl: string; - darkModeUrl: string; - }; - details: { - howItWorks: { - title: string; - description: string; - phases: { - name: string; - daysLabel: string; - sortOrder: number; - steps: { - title: string; - description: string; - iconName: string; - }[]; - days?: number | null; - }[]; - notes?: Json | null; - }; - } | null; - featured: boolean; - }[]; +export type OndoCampaignStepState = { + title: string; + description: string; + iconName: string; +}; + +/** + * Serializable version of OndoCampaignPhase for state storage. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OndoCampaignPhaseState = { + name: string; + daysLabel: string; + sortOrder: number; + steps: OndoCampaignStepState[]; + days?: number | null; +}; + +/** + * Serializable version of OndoCampaignHowItWorks for state storage. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OndoCampaignHowItWorksState = { + title: string; + description: string; + phases: OndoCampaignPhaseState[]; + notes?: Json | null; +}; + +/** + * Serializable version of ThemeImage for state storage. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ThemeImageState = { + lightModeUrl: string; + darkModeUrl: string; +}; + +/** + * Serializable version of CampaignDetails for state storage. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignDetailsState = { + image: ThemeImageState; + howItWorks: OndoCampaignHowItWorksState; +}; + +/** + * Serializable version of CampaignDto for state storage. + * Uses plain string for type instead of CampaignType enum to satisfy StateConstraint. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignDtoState = { + id: string; + type: string; + name: string; + startDate: string; + endDate: string; + termsAndConditions: Json | null; + excludedRegions: string[]; + statusLabel: string; + details: CampaignDetailsState | null; + featured: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignState = { + campaigns: CampaignDtoState[]; lastFetched: number; }; @@ -209,6 +247,171 @@ export interface CampaignParticipantStatusDto { participantCount: number; } +/** + * A single entry in the campaign leaderboard + */ +export interface CampaignLeaderboardEntry { + /** + * The rank of the participant within their tier + * @example 1 + */ + rank: number; + + /** + * The participant's referral code (used as identifier) + * @example 'ABC123' + */ + referral_code: string; + + /** + * The rate of return as a decimal ratio (0.15 = 15%, -0.05 = -5%) + * @example 0.15 + */ + rate_of_return: number; +} + +/** + * Leaderboard data for a single tier + */ +export interface CampaignLeaderboardTier { + /** + * Top entries in the tier (up to 20) + */ + entries: CampaignLeaderboardEntry[]; + + /** + * Total number of participants in this tier + * @example 150 + */ + total_participants: number; +} + +/** + * Response DTO for GET /ondo-gm/:campaignId/leaderboard + * Public endpoint returning top 20 per tier + */ +export interface CampaignLeaderboardDto { + /** + * The campaign ID + * @example '123e4567-e89b-12d3-a456-426614174000' + */ + campaign_id: string; + + /** + * When the leaderboard was last computed (ISO timestamp) + * @example '2024-03-20T12:00:00.000Z' + */ + computed_at: string; + + /** + * Leaderboard data by tier name (e.g. STARTER, MID, UPPER) + * Keys are dynamic based on campaign config + */ + tiers: Record; +} + +/** + * Response DTO for GET /ondo-gm/:campaignId/leaderboard/me + * Authenticated endpoint returning the current user's position + */ +export interface CampaignLeaderboardPositionDto { + /** + * The user's projected tier based on net deposit + * @example 'MID' + */ + projected_tier: string; + + /** + * The user's rank within their tier + * @example 5 + */ + rank: number; + + /** + * Total number of participants in the user's tier + * @example 150 + */ + total_in_tier: number; + + /** + * The user's rate of return as a decimal ratio + * @example 0.15 + */ + rate_of_return: number; + + /** + * Current USD value of the user's positions + * @example 12500.50 + */ + current_usd_value: number; + + /** + * Total USD deposited by the user + * @example 10000.00 + */ + total_usd_deposited: number; + + /** + * Net deposit amount (deposits - withdrawals at cost basis) + * @example 8500.00 + */ + net_deposit: number; + + /** + * When the leaderboard was last computed (ISO timestamp) + * @example '2024-03-20T12:00:00.000Z' + */ + computed_at: string; +} + +/** + * State for cached leaderboard data in the controller + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignLeaderboardState = { + campaign_id: string; + computed_at: string; + tiers: Record< + string, + { + entries: { + rank: number; + referral_code: string; + rate_of_return: number; + }[]; + total_participants: number; + } + >; + lastFetched: number; +}; + +/** + * State for cached leaderboard position in the controller + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignLeaderboardPositionFoundState = { + projected_tier: string; + rank: number; + total_in_tier: number; + rate_of_return: number; + current_usd_value: number; + total_usd_deposited: number; + net_deposit: number; + computed_at: string; + lastFetched: number; +}; + +/** Sentinel stored when the API returns null (user not on leaderboard), so the TTL is respected. */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CampaignLeaderboardPositionNotFoundState = { + notFound: true; + lastFetched: number; +}; + +export type CampaignLeaderboardPositionState = + | CampaignLeaderboardPositionFoundState + | CampaignLeaderboardPositionNotFoundState; + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignParticipantStatusState = { optedIn: boolean; @@ -1241,10 +1444,17 @@ export type RewardsControllerState = { offDeviceSubscriptionAccounts: { [subscriptionId: string]: OffDeviceSubscriptionAccountsState; }; - campaigns: { [subscriptionId: string]: CampaignsState }; + /** Campaign data keyed by 'REWARDS_CAMPAIGNS' */ + campaigns: { [key: string]: CampaignState }; campaignParticipantStatus: { [compositeId: string]: CampaignParticipantStatusState; }; + /** Ondo campaign leaderboard data keyed by campaignId */ + ondoCampaignLeaderboard: { [campaignId: string]: CampaignLeaderboardState }; + /** Ondo campaign leaderboard position keyed by compositeId (subscriptionId:campaignId) */ + ondoCampaignLeaderboardPositions: { + [compositeId: string]: CampaignLeaderboardPositionState; + }; /** * History of points estimates for Customer Support diagnostics. * Stores the last N successful estimates to verify user-reported discrepancies. @@ -1322,6 +1532,20 @@ export interface RewardsControllerCampaignOptedInEvent { ]; } +/** + * Event emitted when a user opts into a campaign, invalidating the cached + * leaderboard position so hooks can refetch fresh data. + */ +export interface RewardsControllerLeaderboardPositionInvalidatedEvent { + type: 'RewardsController:leaderboardPositionInvalidated'; + payload: [ + { + campaignId: string; + subscriptionId: string; + }, + ]; +} + /** * Events that can be emitted by the RewardsController */ @@ -1331,7 +1555,8 @@ export type RewardsControllerEvents = | RewardsControllerRewardClaimedEvent | RewardsControllerBalanceUpdatedEvent | RewardsControllerPointsEventsUpdatedEvent - | RewardsControllerCampaignOptedInEvent; + | RewardsControllerCampaignOptedInEvent + | RewardsControllerLeaderboardPositionInvalidatedEvent; /** * Patch type for state changes @@ -1621,6 +1846,25 @@ export interface RewardsControllerGetCampaignParticipantStatusAction { ) => Promise; } +/** + * Action for getting the campaign leaderboard (public, top 20 per tier) + */ +export interface RewardsControllerGetOndoCampaignLeaderboardAction { + type: 'RewardsController:getOndoCampaignLeaderboard'; + handler: (campaignId: string) => Promise; +} + +/** + * Action for getting the current user's leaderboard position (authenticated) + */ +export interface RewardsControllerGetOndoCampaignLeaderboardPositionAction { + type: 'RewardsController:getOndoCampaignLeaderboardPosition'; + handler: ( + campaignId: string, + subscriptionId: string, + ) => Promise; +} + /** * Action for getting CAIP-10 accounts linked to a subscription that are not on this device */ @@ -1741,6 +1985,8 @@ export type RewardsControllerActions = | RewardsControllerGetCampaignsAction | RewardsControllerOptInToCampaignAction | RewardsControllerGetCampaignParticipantStatusAction + | RewardsControllerGetOndoCampaignLeaderboardAction + | RewardsControllerGetOndoCampaignLeaderboardPositionAction | RewardsControllerGetOffDeviceSubscriptionAccountsAction | RewardsControllerClaimRewardAction | RewardsControllerGetSeasonOneLineaRewardTokensAction diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 9399f44c49f..71a74f238f2 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -57,6 +57,8 @@ import { RewardsDataServiceOptInToCampaignAction, RewardsDataServiceGetCampaignParticipantStatusAction, RewardsDataServiceGetClientVersionRequirementsAction, + RewardsDataServiceGetOndoCampaignLeaderboardAction, + RewardsDataServiceGetOndoCampaignLeaderboardPositionAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -100,7 +102,9 @@ type AllowedActions = | RewardsDataServiceGetCampaignsAction | RewardsDataServiceOptInToCampaignAction | RewardsDataServiceGetCampaignParticipantStatusAction - | RewardsDataServiceGetClientVersionRequirementsAction; + | RewardsDataServiceGetClientVersionRequirementsAction + | RewardsDataServiceGetOndoCampaignLeaderboardAction + | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction; // Don't reexport as per guidelines type AllowedEvents = @@ -166,6 +170,8 @@ export function getRewardsControllerMessenger( 'RewardsDataService:setRewardsEnvUrl', 'RewardsDataService:getDefaultRewardsEnvUrl', 'RewardsDataService:getClientVersionRequirements', + 'RewardsDataService:getOndoCampaignLeaderboard', + 'RewardsDataService:getOndoCampaignLeaderboardPosition', ], events: [ 'AccountTreeController:selectedAccountGroupChange', diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 17b6301fa77..bfcac261088 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -28,6 +28,11 @@ import rewardsReducer, { setCampaignsLoading, setCampaignsError, setCampaignParticipantStatus, + setOndoCampaignLeaderboard, + setOndoCampaignLeaderboardLoading, + setOndoCampaignLeaderboardError, + setOndoCampaignLeaderboardSelectedTier, + setOndoCampaignLeaderboardPosition, bulkLinkStarted, bulkLinkAccountResult, bulkLinkCompleted, @@ -48,6 +53,8 @@ import { PointsEventDto, CampaignDto, CampaignType, + CampaignLeaderboardDto, + CampaignLeaderboardPositionDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { AccountGroupId } from '@metamask/account-api'; import { brandColor } from '@metamask/design-tokens'; @@ -2139,6 +2146,11 @@ describe('rewardsReducer', () => { campaignsError: false, campaignsHasLoaded: false, campaignParticipantStatuses: {}, + ondoCampaignLeaderboard: null, + ondoCampaignLeaderboardLoading: false, + ondoCampaignLeaderboardError: false, + ondoCampaignLeaderboardSelectedTier: null, + ondoCampaignLeaderboardPositions: {}, versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, @@ -2247,6 +2259,11 @@ describe('rewardsReducer', () => { campaignsError: false, campaignsHasLoaded: false, campaignParticipantStatuses: {}, + ondoCampaignLeaderboard: null, + ondoCampaignLeaderboardLoading: false, + ondoCampaignLeaderboardError: false, + ondoCampaignLeaderboardSelectedTier: null, + ondoCampaignLeaderboardPositions: {}, versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, @@ -2558,6 +2575,50 @@ describe('rewardsReducer', () => { // Assert expect(state).toEqual(currentState); }); + + it('should restore ondoCampaignLeaderboardPositions from persisted state', () => { + const mockPosition: CampaignLeaderboardPositionDto = { + projected_tier: 'MID', + rank: 3, + total_in_tier: 150, + rate_of_return: 0.15, + current_usd_value: 12500.5, + total_usd_deposited: 10000, + net_deposit: 8500, + computed_at: '2024-03-20T12:00:00.000Z', + }; + const persistedRewardsState: RewardsState = { + ...initialState, + ondoCampaignLeaderboardPositions: { + 'sub-1:campaign-1': mockPosition, + }, + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { rewards: persistedRewardsState }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.ondoCampaignLeaderboardPositions).toEqual({ + 'sub-1:campaign-1': mockPosition, + }); + }); + + it('should default ondoCampaignLeaderboardPositions to {} when absent from persisted state (upgrade path)', () => { + const persistedRewardsStateWithoutField = { + ...initialState, + ondoCampaignLeaderboardPositions: undefined, + } as unknown as RewardsState; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { rewards: persistedRewardsStateWithoutField }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.ondoCampaignLeaderboardPositions).toEqual({}); + }); }); describe('unknown actions', () => { @@ -4827,3 +4888,264 @@ describe('setVersionGuardError', () => { expect(state.versionGuardLoading).toBe(initialState.versionGuardLoading); }); }); + +const mockLeaderboard: CampaignLeaderboardDto = { + campaign_id: 'campaign-1', + computed_at: '2024-03-20T12:00:00.000Z', + tiers: { + STARTER: { + entries: [ + { rank: 1, referral_code: 'TOP001', rate_of_return: 0.325 }, + { rank: 2, referral_code: 'TOP002', rate_of_return: 0.284 }, + { rank: 3, referral_code: 'TOP003', rate_of_return: 0.261 }, + { rank: 4, referral_code: 'TOP004', rate_of_return: 0.238 }, + { rank: 5, referral_code: 'TOP005', rate_of_return: 0.217 }, + { rank: 6, referral_code: 'TOP006', rate_of_return: 0.198 }, + { rank: 7, referral_code: 'TOP007', rate_of_return: 0.182 }, + { rank: 8, referral_code: 'TOP008', rate_of_return: 0.167 }, + { rank: 9, referral_code: 'TOP009', rate_of_return: 0.154 }, + { rank: 10, referral_code: 'TOP010', rate_of_return: 0.141 }, + { rank: 11, referral_code: 'TOP011', rate_of_return: 0.129 }, + { rank: 12, referral_code: 'TOP012', rate_of_return: 0.118 }, + { rank: 13, referral_code: 'TOP013', rate_of_return: 0.108 }, + { rank: 14, referral_code: 'TOP014', rate_of_return: 0.099 }, + { rank: 15, referral_code: 'TOP015', rate_of_return: 0.091 }, + { rank: 16, referral_code: 'TOP016', rate_of_return: 0.083 }, + { rank: 17, referral_code: 'TOP017', rate_of_return: 0.076 }, + { rank: 18, referral_code: 'TOP018', rate_of_return: 0.069 }, + { rank: 19, referral_code: 'MY_CODE', rate_of_return: 0.063 }, + { rank: 20, referral_code: 'TOP020', rate_of_return: 0.057 }, + ], + total_participants: 150, + }, + MID: { + entries: [ + { rank: 1, referral_code: 'MID001', rate_of_return: 0.412 }, + { rank: 2, referral_code: 'MID002', rate_of_return: 0.368 }, + { rank: 3, referral_code: 'MID003', rate_of_return: 0.341 }, + ], + total_participants: 75, + }, + }, +}; + +const mockPosition: CampaignLeaderboardPositionDto = { + projected_tier: 'STARTER', + rank: 19, + total_in_tier: 150, + rate_of_return: 0.063, + current_usd_value: 5063, + total_usd_deposited: 5000, + net_deposit: 4800, + computed_at: '2024-03-20T12:00:00.000Z', +}; + +describe('setOndoCampaignLeaderboard', () => { + it('should set leaderboard data', () => { + const action = setOndoCampaignLeaderboard(mockLeaderboard); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboard).toEqual(mockLeaderboard); + expect(state.ondoCampaignLeaderboardError).toBe(false); + }); + + it('should set first tier as selected when not already set', () => { + const action = setOndoCampaignLeaderboard(mockLeaderboard); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboardSelectedTier).toBe('STARTER'); + }); + + it('should not override existing selected tier', () => { + const stateWithSelectedTier: RewardsState = { + ...initialState, + ondoCampaignLeaderboardSelectedTier: 'MID', + }; + const action = setOndoCampaignLeaderboard(mockLeaderboard); + + const state = rewardsReducer(stateWithSelectedTier, action); + + expect(state.ondoCampaignLeaderboardSelectedTier).toBe('MID'); + }); + + it('should reset selected tier to first when current selection does not exist in new data', () => { + const stateWithStaleSelection: RewardsState = { + ...initialState, + ondoCampaignLeaderboardSelectedTier: 'UPPER', + }; + const action = setOndoCampaignLeaderboard(mockLeaderboard); + + const state = rewardsReducer(stateWithStaleSelection, action); + + expect(state.ondoCampaignLeaderboardSelectedTier).toBe('STARTER'); + }); + + it('should set leaderboard to null', () => { + const stateWithLeaderboard: RewardsState = { + ...initialState, + ondoCampaignLeaderboard: mockLeaderboard, + }; + const action = setOndoCampaignLeaderboard(null); + + const state = rewardsReducer(stateWithLeaderboard, action); + + expect(state.ondoCampaignLeaderboard).toBeNull(); + }); + + it('should reset error when setting leaderboard', () => { + const stateWithError: RewardsState = { + ...initialState, + ondoCampaignLeaderboardError: true, + }; + const action = setOndoCampaignLeaderboard(mockLeaderboard); + + const state = rewardsReducer(stateWithError, action); + + expect(state.ondoCampaignLeaderboardError).toBe(false); + }); +}); + +describe('setOndoCampaignLeaderboardLoading', () => { + it('should set loading to true when no leaderboard exists', () => { + const action = setOndoCampaignLeaderboardLoading(true); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboardLoading).toBe(true); + }); + + it('should not set loading to true when leaderboard already exists', () => { + const stateWithLeaderboard: RewardsState = { + ...initialState, + ondoCampaignLeaderboard: mockLeaderboard, + ondoCampaignLeaderboardLoading: false, + }; + const action = setOndoCampaignLeaderboardLoading(true); + + const state = rewardsReducer(stateWithLeaderboard, action); + + expect(state.ondoCampaignLeaderboardLoading).toBe(false); + }); + + it('should set loading to false', () => { + const stateWithLoading: RewardsState = { + ...initialState, + ondoCampaignLeaderboardLoading: true, + }; + const action = setOndoCampaignLeaderboardLoading(false); + + const state = rewardsReducer(stateWithLoading, action); + + expect(state.ondoCampaignLeaderboardLoading).toBe(false); + }); +}); + +describe('setOndoCampaignLeaderboardError', () => { + it('should set error to true', () => { + const action = setOndoCampaignLeaderboardError(true); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboardError).toBe(true); + }); + + it('should set error to false', () => { + const stateWithError: RewardsState = { + ...initialState, + ondoCampaignLeaderboardError: true, + }; + const action = setOndoCampaignLeaderboardError(false); + + const state = rewardsReducer(stateWithError, action); + + expect(state.ondoCampaignLeaderboardError).toBe(false); + }); +}); + +describe('setOndoCampaignLeaderboardSelectedTier', () => { + it('should set selected tier', () => { + const action = setOndoCampaignLeaderboardSelectedTier('MID'); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboardSelectedTier).toBe('MID'); + }); + + it('should update selected tier', () => { + const stateWithSelectedTier: RewardsState = { + ...initialState, + ondoCampaignLeaderboardSelectedTier: 'STARTER', + }; + const action = setOndoCampaignLeaderboardSelectedTier('UPPER'); + + const state = rewardsReducer(stateWithSelectedTier, action); + + expect(state.ondoCampaignLeaderboardSelectedTier).toBe('UPPER'); + }); +}); + +describe('setOndoCampaignLeaderboardPosition', () => { + it('should set position for a campaign', () => { + const action = setOndoCampaignLeaderboardPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-1', + position: mockPosition, + }); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignLeaderboardPositions['sub-1:campaign-1']).toEqual( + mockPosition, + ); + }); + + it('should remove position when null is provided', () => { + const stateWithPosition: RewardsState = { + ...initialState, + ondoCampaignLeaderboardPositions: { 'sub-1:campaign-1': mockPosition }, + }; + const action = setOndoCampaignLeaderboardPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-1', + position: null, + }); + + const state = rewardsReducer(stateWithPosition, action); + + expect( + state.ondoCampaignLeaderboardPositions['sub-1:campaign-1'], + ).toBeUndefined(); + }); + + it('should store positions for multiple campaigns', () => { + let currentState = initialState; + + currentState = rewardsReducer( + currentState, + setOndoCampaignLeaderboardPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-1', + position: mockPosition, + }), + ); + + const position2 = { ...mockPosition, rank: 10, projected_tier: 'MID' }; + currentState = rewardsReducer( + currentState, + setOndoCampaignLeaderboardPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-2', + position: position2, + }), + ); + + expect( + currentState.ondoCampaignLeaderboardPositions['sub-1:campaign-1'], + ).toEqual(mockPosition); + expect( + currentState.ondoCampaignLeaderboardPositions['sub-1:campaign-2'], + ).toEqual(position2); + }); +}); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 045386329e0..17eeaef24a0 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction, Action } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, Action, Draft } from '@reduxjs/toolkit'; import { SeasonStatusState, SeasonTierDto, @@ -10,6 +10,8 @@ import { SeasonWayToEarnDto, CampaignDto, CampaignParticipantStatusDto, + CampaignLeaderboardDto, + CampaignLeaderboardPositionDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -129,6 +131,19 @@ export interface RewardsState { versionGuardMinimumMobileVersion: string | null; versionGuardLoading: boolean; versionGuardError: boolean; + + // Campaign leaderboard (keyed by campaignId) + ondoCampaignLeaderboard: CampaignLeaderboardDto | null; + ondoCampaignLeaderboardLoading: boolean; + ondoCampaignLeaderboardError: boolean; + // Currently selected tier for leaderboard display + ondoCampaignLeaderboardSelectedTier: string | null; + + // Campaign leaderboard position (user's position, keyed by composite key `${subscriptionId}:${campaignId}`) + ondoCampaignLeaderboardPositions: Record< + string, + CampaignLeaderboardPositionDto + >; } export const initialState: RewardsState = { @@ -204,6 +219,15 @@ export const initialState: RewardsState = { versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, + + // Campaign leaderboard initial state + ondoCampaignLeaderboard: null, + ondoCampaignLeaderboardLoading: false, + ondoCampaignLeaderboardError: false, + ondoCampaignLeaderboardSelectedTier: null, + + // Campaign leaderboard position initial state + ondoCampaignLeaderboardPositions: {}, }; interface RehydrateAction extends Action<'persist/REHYDRATE'> { @@ -310,6 +334,10 @@ const rewardsSlice = createSlice({ resetRewardsState: (state) => { Object.assign(state, initialState); + // Explicitly clear leaderboard state (also covered by initialState above) + state.ondoCampaignLeaderboard = null; + state.ondoCampaignLeaderboardSelectedTier = null; + state.ondoCampaignLeaderboardPositions = {}; }, setOnboardingActiveStep: (state, action: PayloadAction) => { @@ -454,7 +482,7 @@ const rewardsSlice = createSlice({ }, // Campaigns reducers - setCampaigns: (state, action: PayloadAction) => { + setCampaigns: (state, action: PayloadAction[]>) => { state.campaigns = action.payload; state.campaignsError = false; state.campaignsHasLoaded = true; @@ -497,6 +525,65 @@ const rewardsSlice = createSlice({ state.versionGuardError = action.payload; }, + // Campaign leaderboard reducers + setOndoCampaignLeaderboard: ( + state, + action: PayloadAction, + ) => { + state.ondoCampaignLeaderboard = action.payload; + state.ondoCampaignLeaderboardError = false; + // Set the first tier as selected if not already set, or if the current + // selection no longer exists in the incoming data (e.g. different campaign) + if (action.payload) { + const tierNames = Object.keys(action.payload.tiers); + if ( + tierNames.length > 0 && + (!state.ondoCampaignLeaderboardSelectedTier || + !tierNames.includes(state.ondoCampaignLeaderboardSelectedTier)) + ) { + state.ondoCampaignLeaderboardSelectedTier = tierNames[0]; + } + } + }, + setOndoCampaignLeaderboardLoading: ( + state, + action: PayloadAction, + ) => { + if (action.payload && state.ondoCampaignLeaderboard) { + return; + } + state.ondoCampaignLeaderboardLoading = action.payload; + }, + setOndoCampaignLeaderboardError: ( + state, + action: PayloadAction, + ) => { + state.ondoCampaignLeaderboardError = action.payload; + }, + setOndoCampaignLeaderboardSelectedTier: ( + state, + action: PayloadAction, + ) => { + state.ondoCampaignLeaderboardSelectedTier = action.payload; + }, + + // Campaign leaderboard position reducers + setOndoCampaignLeaderboardPosition: ( + state, + action: PayloadAction<{ + subscriptionId: string; + campaignId: string; + position: CampaignLeaderboardPositionDto | null; + }>, + ) => { + const key = `${action.payload.subscriptionId}:${action.payload.campaignId}`; + if (action.payload.position) { + state.ondoCampaignLeaderboardPositions[key] = action.payload.position; + } else { + delete state.ondoCampaignLeaderboardPositions[key]; + } + }, + // Bulk link reducers bulkLinkStarted: ( state, @@ -565,65 +652,71 @@ const rewardsSlice = createSlice({ state.bulkLink.isRunning = false; state.bulkLink.wasInterrupted = false; }) - .addCase('persist/REHYDRATE', (state, action: RehydrateAction) => { - if (action.payload?.rewards) { - // Detect if bulk link was interrupted (app closed while running) - const previousBulkLink = action.payload.rewards.bulkLink; - const wasInterrupted = previousBulkLink?.isRunning === true; - - return { - // Reset non-persistent state (state is persisted via controller) - ...initialState, - - // UI state we want to restore from previous visit - seasonId: action.payload.rewards.seasonId, - seasonName: action.payload.rewards.seasonName, - seasonStartDate: action.payload.rewards.seasonStartDate, - seasonEndDate: action.payload.rewards.seasonEndDate, - seasonTiers: action.payload.rewards.seasonTiers, - seasonActivityTypes: action.payload.rewards.seasonActivityTypes, - seasonWaysToEarn: action.payload.rewards.seasonWaysToEarn, - seasonShouldInstallNewVersion: - action.payload.rewards.seasonShouldInstallNewVersion, - referralCode: action.payload.rewards.referralCode, - refereeCount: action.payload.rewards.refereeCount, - currentTier: action.payload.rewards.currentTier, - nextTier: action.payload.rewards.nextTier, - nextTierPointsNeeded: action.payload.rewards.nextTierPointsNeeded, - balanceTotal: action.payload.rewards.balanceTotal, - balanceRefereePortion: action.payload.rewards.balanceRefereePortion, - balanceUpdatedAt: action.payload.rewards.balanceUpdatedAt, - activeBoosts: action.payload.rewards.activeBoosts, - pointsEvents: action.payload.rewards.pointsEvents, - unlockedRewards: action.payload.rewards.unlockedRewards, - campaigns: action.payload.rewards.campaigns, - campaignParticipantStatuses: - action.payload.rewards.campaignParticipantStatuses, - hideUnlinkedAccountsBanner: - action.payload.rewards.hideUnlinkedAccountsBanner, - hideCurrentAccountNotOptedInBanner: - action.payload.rewards.hideCurrentAccountNotOptedInBanner, - - // Bulk link state - preserve interrupted status for resume capability - bulkLink: { - ...initialState.bulkLink, - wasInterrupted, - // Preserve previous progress for UI display (how many were done before interruption) - linkedAccounts: wasInterrupted - ? (previousBulkLink?.linkedAccounts ?? 0) - : 0, - failedAccounts: wasInterrupted - ? (previousBulkLink?.failedAccounts ?? 0) - : 0, - // Preserve subscription ID for resume validation - initialSubscriptionId: wasInterrupted - ? (previousBulkLink?.initialSubscriptionId ?? null) - : null, - }, - }; - } - return state; - }); + .addCase( + 'persist/REHYDRATE', + (state, action: RehydrateAction): RewardsState | void => { + if (action.payload?.rewards) { + // Detect if bulk link was interrupted (app closed while running) + const previousBulkLink = action.payload.rewards.bulkLink; + const wasInterrupted = previousBulkLink?.isRunning === true; + + return { + // Reset non-persistent state (state is persisted via controller) + ...initialState, + + // UI state we want to restore from previous visit + seasonId: action.payload.rewards.seasonId, + seasonName: action.payload.rewards.seasonName, + seasonStartDate: action.payload.rewards.seasonStartDate, + seasonEndDate: action.payload.rewards.seasonEndDate, + seasonTiers: action.payload.rewards.seasonTiers, + seasonActivityTypes: action.payload.rewards.seasonActivityTypes, + seasonWaysToEarn: action.payload.rewards.seasonWaysToEarn, + seasonShouldInstallNewVersion: + action.payload.rewards.seasonShouldInstallNewVersion, + referralCode: action.payload.rewards.referralCode, + refereeCount: action.payload.rewards.refereeCount, + currentTier: action.payload.rewards.currentTier, + nextTier: action.payload.rewards.nextTier, + nextTierPointsNeeded: action.payload.rewards.nextTierPointsNeeded, + balanceTotal: action.payload.rewards.balanceTotal, + balanceRefereePortion: + action.payload.rewards.balanceRefereePortion, + balanceUpdatedAt: action.payload.rewards.balanceUpdatedAt, + activeBoosts: action.payload.rewards.activeBoosts, + pointsEvents: action.payload.rewards.pointsEvents, + unlockedRewards: action.payload.rewards.unlockedRewards, + campaigns: action.payload.rewards.campaigns, + campaignParticipantStatuses: + action.payload.rewards.campaignParticipantStatuses, + ondoCampaignLeaderboardPositions: + action.payload.rewards.ondoCampaignLeaderboardPositions ?? {}, + hideUnlinkedAccountsBanner: + action.payload.rewards.hideUnlinkedAccountsBanner, + hideCurrentAccountNotOptedInBanner: + action.payload.rewards.hideCurrentAccountNotOptedInBanner, + + // Bulk link state - preserve interrupted status for resume capability + bulkLink: { + ...initialState.bulkLink, + wasInterrupted, + // Preserve previous progress for UI display (how many were done before interruption) + linkedAccounts: wasInterrupted + ? (previousBulkLink?.linkedAccounts ?? 0) + : 0, + failedAccounts: wasInterrupted + ? (previousBulkLink?.failedAccounts ?? 0) + : 0, + // Preserve subscription ID for resume validation + initialSubscriptionId: wasInterrupted + ? (previousBulkLink?.initialSubscriptionId ?? null) + : null, + }, + } as RewardsState; + } + return state as unknown as RewardsState; + }, + ); }, }); @@ -661,6 +754,12 @@ export const { setVersionGuardMinimumMobileVersion, setVersionGuardLoading, setVersionGuardError, + // Campaign leaderboard actions + setOndoCampaignLeaderboard, + setOndoCampaignLeaderboardLoading, + setOndoCampaignLeaderboardError, + setOndoCampaignLeaderboardSelectedTier, + setOndoCampaignLeaderboardPosition, // Bulk link actions bulkLinkStarted, bulkLinkAccountResult, diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9e870f116a8..deef0d15527 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -57,6 +57,17 @@ import { selectVersionGuardMinimumMobileVersion, selectVersionGuardLoading, selectVersionGuardError, + selectOndoCampaignLeaderboard, + selectOndoCampaignLeaderboardLoading, + selectOndoCampaignLeaderboardError, + selectOndoCampaignLeaderboardSelectedTier, + selectOndoCampaignLeaderboardTiers, + selectOndoCampaignLeaderboardComputedAt, + selectOndoCampaignLeaderboardTierNames, + selectOndoCampaignLeaderboardEntriesByTier, + selectOndoCampaignLeaderboardTotalParticipantsByTier, + selectOndoCampaignLeaderboardPositions, + selectOndoCampaignLeaderboardPositionById, } from './selectors'; // eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../util/remoteFeatureFlag'; @@ -3384,4 +3395,269 @@ describe('Rewards selectors', () => { }); }); }); + + const mockLeaderboard = { + campaign_id: 'campaign-1', + computed_at: '2024-03-20T12:00:00.000Z', + tiers: { + STARTER: { + entries: [ + { rank: 1, referral_code: 'ABC123', rate_of_return: 0.15 }, + { rank: 2, referral_code: 'DEF456', rate_of_return: 0.1 }, + ], + total_participants: 50, + }, + MID: { + entries: [{ rank: 1, referral_code: 'GHI789', rate_of_return: 0.2 }], + total_participants: 30, + }, + }, + }; + + const mockPosition = { + projected_tier: 'STARTER', + rank: 5, + total_in_tier: 50, + rate_of_return: 0.12, + current_usd_value: 1000, + total_usd_deposited: 900, + net_deposit: 800, + computed_at: '2024-03-20T12:00:00.000Z', + referral_code: 'XYZ789', + }; + + describe('selectOndoCampaignLeaderboard', () => { + it('returns null when leaderboard is not set', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: null, + }); + expect(selectOndoCampaignLeaderboard(state)).toBeNull(); + }); + + it('returns leaderboard when set', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect(selectOndoCampaignLeaderboard(state)).toEqual(mockLeaderboard); + }); + }); + + describe('selectOndoCampaignLeaderboardLoading', () => { + it('returns false when not loading', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardLoading: false, + }); + expect(selectOndoCampaignLeaderboardLoading(state)).toBe(false); + }); + + it('returns true when loading', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardLoading: true, + }); + expect(selectOndoCampaignLeaderboardLoading(state)).toBe(true); + }); + }); + + describe('selectOndoCampaignLeaderboardError', () => { + it('returns false when no error', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardError: false, + }); + expect(selectOndoCampaignLeaderboardError(state)).toBe(false); + }); + + it('returns true when has error', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardError: true, + }); + expect(selectOndoCampaignLeaderboardError(state)).toBe(true); + }); + }); + + describe('selectOndoCampaignLeaderboardSelectedTier', () => { + it('returns null when no tier selected', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardSelectedTier: null, + }); + expect(selectOndoCampaignLeaderboardSelectedTier(state)).toBeNull(); + }); + + it('returns selected tier', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardSelectedTier: 'STARTER', + }); + expect(selectOndoCampaignLeaderboardSelectedTier(state)).toBe('STARTER'); + }); + }); + + describe('selectOndoCampaignLeaderboardTiers', () => { + it('returns empty object when no leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: null, + }); + expect(selectOndoCampaignLeaderboardTiers(state)).toEqual({}); + }); + + it('returns tiers from leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect(selectOndoCampaignLeaderboardTiers(state)).toEqual( + mockLeaderboard.tiers, + ); + }); + }); + + describe('selectOndoCampaignLeaderboardComputedAt', () => { + it('returns null when no leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: null, + }); + expect(selectOndoCampaignLeaderboardComputedAt(state)).toBeNull(); + }); + + it('returns computed_at from leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect(selectOndoCampaignLeaderboardComputedAt(state)).toBe( + '2024-03-20T12:00:00.000Z', + ); + }); + }); + + describe('selectOndoCampaignLeaderboardTierNames', () => { + it('returns empty array when no leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: null, + }); + expect(selectOndoCampaignLeaderboardTierNames(state)).toEqual([]); + }); + + it('returns tier names from leaderboard', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect(selectOndoCampaignLeaderboardTierNames(state)).toEqual([ + 'STARTER', + 'MID', + ]); + }); + }); + + describe('selectOndoCampaignLeaderboardEntriesByTier', () => { + it('returns empty array when tier name is null', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect(selectOndoCampaignLeaderboardEntriesByTier(null)(state)).toEqual( + [], + ); + }); + + it('returns empty array when tier does not exist', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect( + selectOndoCampaignLeaderboardEntriesByTier('UPPER')(state), + ).toEqual([]); + }); + + it('returns entries for specified tier', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect( + selectOndoCampaignLeaderboardEntriesByTier('STARTER')(state), + ).toEqual(mockLeaderboard.tiers.STARTER.entries); + }); + }); + + describe('selectOndoCampaignLeaderboardTotalParticipantsByTier', () => { + it('returns 0 when tier name is null', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect( + selectOndoCampaignLeaderboardTotalParticipantsByTier(null)(state), + ).toBe(0); + }); + + it('returns 0 when tier does not exist', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect( + selectOndoCampaignLeaderboardTotalParticipantsByTier('UPPER')(state), + ).toBe(0); + }); + + it('returns total participants for specified tier', () => { + const state = createMockRootState({ + ondoCampaignLeaderboard: mockLeaderboard, + }); + expect( + selectOndoCampaignLeaderboardTotalParticipantsByTier('STARTER')(state), + ).toBe(50); + }); + }); + + describe('selectOndoCampaignLeaderboardPositions', () => { + it('returns empty object when no positions', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: {}, + }); + expect(selectOndoCampaignLeaderboardPositions(state)).toEqual({}); + }); + + it('returns positions when set', () => { + const positions = { 'sub-1:campaign-1': mockPosition }; + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: positions, + }); + expect(selectOndoCampaignLeaderboardPositions(state)).toEqual(positions); + }); + }); + + describe('selectOndoCampaignLeaderboardPositionById', () => { + it('returns null when subscriptionId is undefined', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: { 'sub-1:campaign-1': mockPosition }, + }); + expect( + selectOndoCampaignLeaderboardPositionById( + undefined, + 'campaign-1', + )(state), + ).toBeNull(); + }); + + it('returns null when campaignId is undefined', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: { 'sub-1:campaign-1': mockPosition }, + }); + expect( + selectOndoCampaignLeaderboardPositionById('sub-1', undefined)(state), + ).toBeNull(); + }); + + it('returns null when position does not exist', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: {}, + }); + expect( + selectOndoCampaignLeaderboardPositionById('sub-1', 'campaign-1')(state), + ).toBeNull(); + }); + + it('returns position for specified subscription and campaign', () => { + const state = createMockRootState({ + ondoCampaignLeaderboardPositions: { 'sub-1:campaign-1': mockPosition }, + }); + expect( + selectOndoCampaignLeaderboardPositionById('sub-1', 'campaign-1')(state), + ).toEqual(mockPosition); + }); + }); }); diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 9f195ab5fe3..fc23ae09d95 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -1,3 +1,4 @@ +import { createSelector } from 'reselect'; import { RootState } from '..'; import { RewardsTab, OnboardingStep } from './types'; import { hasMinimumRequiredVersion } from '../../util/remoteFeatureFlag'; @@ -201,3 +202,58 @@ export const selectIsRewardsVersionBlocked = (state: RootState): boolean => { if (!minVersion) return false; return !hasMinimumRequiredVersion(minVersion); }; + +// Campaign leaderboard selectors +export const selectOndoCampaignLeaderboard = (state: RootState) => + state.rewards.ondoCampaignLeaderboard; + +export const selectOndoCampaignLeaderboardLoading = (state: RootState) => + state.rewards.ondoCampaignLeaderboardLoading; + +export const selectOndoCampaignLeaderboardError = (state: RootState) => + state.rewards.ondoCampaignLeaderboardError; + +export const selectOndoCampaignLeaderboardSelectedTier = (state: RootState) => + state.rewards.ondoCampaignLeaderboardSelectedTier; + +// Stable fallbacks to avoid returning new references from input selectors +const EMPTY_TIERS: Record = {}; +const EMPTY_ENTRIES: never[] = []; + +export const selectOndoCampaignLeaderboardTiers = (state: RootState) => + state.rewards.ondoCampaignLeaderboard?.tiers ?? EMPTY_TIERS; + +export const selectOndoCampaignLeaderboardComputedAt = (state: RootState) => + state.rewards.ondoCampaignLeaderboard?.computed_at ?? null; + +export const selectOndoCampaignLeaderboardTierNames = createSelector( + selectOndoCampaignLeaderboardTiers, + (tiers) => Object.keys(tiers), +); + +export const selectOndoCampaignLeaderboardEntriesByTier = + (tierName: string | null) => (state: RootState) => + tierName && state.rewards.ondoCampaignLeaderboard?.tiers[tierName] + ? state.rewards.ondoCampaignLeaderboard.tiers[tierName].entries + : EMPTY_ENTRIES; + +export const selectOndoCampaignLeaderboardTotalParticipantsByTier = + (tierName: string | null) => (state: RootState) => + tierName && state.rewards.ondoCampaignLeaderboard?.tiers[tierName] + ? state.rewards.ondoCampaignLeaderboard.tiers[tierName].total_participants + : 0; + +// Campaign leaderboard position selectors +export const selectOndoCampaignLeaderboardPositions = (state: RootState) => + state.rewards.ondoCampaignLeaderboardPositions; + +export const selectOndoCampaignLeaderboardPositionById = + (subscriptionId: string | undefined, campaignId: string | undefined) => + (state: RootState) => + subscriptionId && + campaignId && + state.rewards.ondoCampaignLeaderboardPositions + ? (state.rewards.ondoCampaignLeaderboardPositions[ + `${subscriptionId}:${campaignId}` + ] ?? null) + : null; diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts index 3bbf603e993..8e998423d34 100644 --- a/app/selectors/rewards/index.ts +++ b/app/selectors/rewards/index.ts @@ -42,6 +42,17 @@ export const selectRewardsSubscriptionId = createSelector( }, ); +export const selectCampaignParticipantOptedIn = + (subscriptionId: string | null, campaignId: string | undefined) => + (state: RootState): boolean => { + if (!subscriptionId || !campaignId) return false; + return ( + state.engine.backgroundState.RewardsController.campaignParticipantStatus[ + `${subscriptionId}:${campaignId}` + ]?.optedIn === true + ); + }; + export const selectRewardsActiveAccountAddress = createSelector( selectRewardsControllerState, (rewardsControllerState): string | null => { diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 626afc17150..1174836fbcb 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -678,6 +678,8 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "campaignParticipantStatus": {}, "campaigns": {}, "offDeviceSubscriptionAccounts": {}, + "ondoCampaignLeaderboard": {}, + "ondoCampaignLeaderboardPositions": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, @@ -1492,6 +1494,8 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "campaignParticipantStatus": {}, "campaigns": {}, "offDeviceSubscriptionAccounts": {}, + "ondoCampaignLeaderboard": {}, + "ondoCampaignLeaderboardPositions": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index ce06d0c807d..b112b409e95 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -708,6 +708,8 @@ "pointsEstimateHistory": [], "pointsEvents": {}, "campaigns": {}, + "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignLeaderboard": {}, "campaignParticipantStatus": {}, "unlockedRewards": {}, "seasonStatuses": {}, diff --git a/locales/languages/en.json b/locales/languages/en.json index 181f89f6565..72fc3d4bf53 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7948,6 +7948,32 @@ "swap": "Swap", "how_it_works": "How it works" }, + "ondo_campaign_leaderboard_position": { + "title": "Your Position", + "rank": "Rank", + "tier": "Tier", + "rate_of_return": "Return", + "total_deposited": "Total Deposited", + "current_value": "Current Value", + "updated_at": "Updated {{time}}", + "not_found": "Not on the leaderboard yet. Please check back later.", + "error_loading": "Failed to load your position", + "error_loading_description": "There was an error loading your leaderboard position. Please try again.", + "retry": "Retry" + }, + "ondo_campaign_leaderboard": { + "title": "Leaderboard", + "your_position": "Your position", + "of_total": "of {{total}} participants", + "total_participants": "{{count}} participants", + "updated_at": "Updated {{time}}", + "error_loading": "Failed to load leaderboard", + "error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", + "error_loading_position": "Failed to load your position", + "retry": "Retry", + "no_data": "No leaderboard data available", + "no_entries_in_tier": "No participants in this tier yet" + }, "campaigns_preview": { "title": "Campaigns", "coming_soon": "Coming soon", diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index c602cc5d5c0..a3ab50a2c2b 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -747,6 +747,16 @@ "totalAccounts": 0, "wasInterrupted": false }, + "ondoCampaignLeaderboard": null, + "ondoCampaignLeaderboardError": false, + "ondoCampaignLeaderboardLoading": false, + "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignLeaderboardSelectedTier": null, + "campaignParticipantStatuses": {}, + "campaigns": [], + "campaignsError": false, + "campaignsHasLoaded": false, + "campaignsLoading": false, "candidateSubscriptionId": "pending", "currentTier": null, "geoLocation": null, @@ -780,7 +790,10 @@ "snapshotsLoading": false, "unlockedRewardError": false, "unlockedRewardLoading": false, - "unlockedRewards": null + "unlockedRewards": null, + "versionGuardError": false, + "versionGuardLoading": false, + "versionGuardMinimumMobileVersion": null }, "rpcEvents": { "signingEvent": { From 776ab6a6596b516145f0d1759a47a60a7a0d13e7 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Wed, 25 Mar 2026 19:06:43 +0100 Subject: [PATCH 03/15] feat: migrate Button (bridge scope) (#27535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replaced `Button` with DSRN component equivalent. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/62fd7302-f338-4936-8a78-54cbc9ecde5b ### **After** https://github.com/user-attachments/assets/8bcbcb1b-f407-4970-bf2a-7e94b1357ce6 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Replaces multiple Bridge UI actions (confirm swap, quick-picks, token select, block explorer links) with the new design-system `Button`, which can subtly change press/disabled/loading behavior and accessibility. Risk is moderate because it touches core swap/bridge user interactions but is largely a UI component swap with updated tests. > > **Overview** > Migrates Bridge-scoped buttons from the legacy component-library `Button` to `@metamask/design-system-react-native` `Button`, updating props/APIs (e.g., `variant`, `isDisabled`, `isLoading`, `isFullWidth`) and switching from `label` prop to children rendering. > > Updates Bridge tests to match the new button behavior: disabled state assertions now use `accessibilityState.disabled`, loading assertions look for a `spinner-container` overlay, and one navigation test invokes the `Button` component’s `onPress` directly due to Pressable host limitations when disabled. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1edf81a88a21cbdecd306868ae4651506c54f811. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/BridgeView/BridgeView.test.tsx | 12 ++- .../SwapsConfirmButton.test.tsx | 70 ++++++++-------- .../components/SwapsConfirmButton/index.tsx | 23 +++--- .../SwapsKeypad/QuickPickButtons.tsx | 18 +++-- .../components/TokenInputArea/index.tsx | 16 ++-- .../BlockExplorersModal.tsx | 79 +++++++++---------- .../TransactionDetails/TransactionDetails.tsx | 11 ++- 7 files changed, 118 insertions(+), 111 deletions(-) diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx index cf42816fa2c..b8051b5fd6f 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx @@ -29,6 +29,7 @@ import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/te import { RootState } from '../../../../../reducers'; import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata'; import { BridgeTrendingTokensSectionTestIds } from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.testIds'; +import { Button } from '@metamask/design-system-react-native'; // Mock the account-tree-controller file that imports the problematic module jest.mock( @@ -1536,14 +1537,21 @@ describe('BridgeView', () => { mockState, ); - const { getByTestId } = renderScreen( + const rendered = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT }, { state: testState }, ); + // The new Pressable-based Button does not expose onPress on the host View + // when disabled, so we find the Button component instance and call onPress directly. + const buttons = rendered.UNSAFE_getAllByType(Button); + const confirmButton = buttons.find( + (b) => b.props.testID === BridgeViewSelectorsIDs.CONFIRM_BUTTON, + ); + await act(async () => { - fireEvent.press(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)); + confirmButton?.props.onPress?.(); }); await waitFor(() => { diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx index 3717032b41a..d0a7a6f4716 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx @@ -324,7 +324,7 @@ describe('SwapsConfirmButton', () => { }, }; - const { queryByText } = renderWithProvider( + const { getByTestId } = renderWithProvider( { }, ); - // When submitting, buttonIsInLoadingState is true so Button renders - // an ActivityIndicator instead of the label text - expect(queryByText(strings('bridge.submitting_transaction'))).toBeNull(); + // When submitting, buttonIsInLoadingState is true so Button shows + // a spinner overlay (label text is rendered but visually hidden via opacity-0) + expect(getByTestId('spinner-container')).toBeTruthy(); }); }); @@ -361,7 +361,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button when balance is insufficient', () => { @@ -378,7 +378,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button when transaction is submitting', () => { @@ -401,7 +401,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button for hardware wallet with Solana source', () => { @@ -433,7 +433,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button when blockaid error exists', () => { @@ -455,7 +455,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button when gas is insufficient', () => { @@ -472,7 +472,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); it('disables button when walletAddress is missing', () => { @@ -489,7 +489,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); }); }); @@ -503,7 +503,7 @@ describe('SwapsConfirmButton', () => { activeQuote: null, })); - const { queryByText, getByTestId } = renderWithProvider( + const { getByTestId } = renderWithProvider( { const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); // Button is disabled (isLoading && !activeQuote) - expect(button.props.disabled).toBe(true); - // Label text is hidden behind ActivityIndicator - expect(queryByText(strings('bridge.confirm_swap'))).toBeNull(); + expect(button.props.accessibilityState?.disabled).toBe(true); + // Spinner overlay is shown (label text rendered but visually hidden via opacity-0) + expect(getByTestId('spinner-container')).toBeTruthy(); }); it('shows loading indicator when submitting transaction', () => { @@ -529,7 +529,7 @@ describe('SwapsConfirmButton', () => { }, }; - const { queryByText, getByTestId } = renderWithProvider( + const { getByTestId } = renderWithProvider( { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); - // Label hidden by loading indicator - expect(queryByText(strings('bridge.submitting_transaction'))).toBeNull(); + expect(button.props.accessibilityState?.disabled).toBe(true); + // Spinner overlay is shown (label text rendered but visually hidden via opacity-0) + expect(getByTestId('spinner-container')).toBeTruthy(); }); it('shows loading when awaiting quote (valid amount, no quote, not loading)', () => { @@ -554,7 +554,7 @@ describe('SwapsConfirmButton', () => { activeQuote: null, })); - const { queryByText, getByTestId } = renderWithProvider( + const { getByTestId } = renderWithProvider( { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); - // Label hidden by loading indicator during debounce gap - expect(queryByText(strings('bridge.confirm_swap'))).toBeNull(); + expect(button.props.accessibilityState?.disabled).toBe(true); + // Spinner overlay is shown (label text rendered but visually hidden via opacity-0) + expect(getByTestId('spinner-container')).toBeTruthy(); }); it('does not show loading when source amount is empty', () => { @@ -656,7 +656,7 @@ describe('SwapsConfirmButton', () => { const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); // Not disabled because activeQuote exists - expect(button.props.disabled).toBe(false); + expect(button.props.accessibilityState?.disabled).toBe(false); // Label is visible (no loading indicator) expect(getByText(strings('bridge.confirm_swap'))).toBeTruthy(); }); @@ -676,7 +676,7 @@ describe('SwapsConfirmButton', () => { const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); // Button is disabled - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); // But not in loading state (isLoading=false, isSubmittingTx=false) // so the label is visible expect(getByText(strings('bridge.insufficient_funds'))).toBeTruthy(); @@ -696,7 +696,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); // Label visible because not loading/submitting expect(getByText(strings('bridge.insufficient_gas'))).toBeTruthy(); }); @@ -705,7 +705,7 @@ describe('SwapsConfirmButton', () => { describe('Stale Quote (isQuoteStale)', () => { it('shows loading when amount changes to non-zero and quote is stale', () => { // First render with sourceAmount='1.0' — settledAmountRef latches to '1.0' - const { queryByText, getByTestId, store } = renderWithProvider( + const { getByTestId, store } = renderWithProvider( { }); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); - // Label hidden by loading indicator - expect(queryByText(strings('bridge.confirm_swap'))).toBeNull(); + expect(button.props.accessibilityState?.disabled).toBe(true); + // Spinner overlay is shown (label text rendered but visually hidden via opacity-0) + expect(getByTestId('spinner-container')).toBeTruthy(); }); it('disables button without loading when amount changes to zero and quote is stale', () => { @@ -747,7 +747,7 @@ describe('SwapsConfirmButton', () => { }); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState?.disabled).toBe(true); // Label visible (not loading) — shows default "Confirm swap" expect(getByText(strings('bridge.confirm_swap'))).toBeTruthy(); }); @@ -765,7 +765,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(false); + expect(button.props.accessibilityState?.disabled).toBe(false); expect(getByText(strings('bridge.confirm_swap'))).toBeTruthy(); }); }); @@ -815,7 +815,7 @@ describe('SwapsConfirmButton', () => { ); const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(false); + expect(button.props.accessibilityState?.disabled).toBe(false); }); it('calls resetState and updateQuoteParams when expired quote button is pressed', async () => { @@ -874,7 +874,7 @@ describe('SwapsConfirmButton', () => { ).toBeTruthy(); // Button is not disabled — user can press "Get new quote" const button = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(button.props.disabled).toBe(false); + expect(button.props.accessibilityState?.disabled).toBe(false); }); it('does not show "Get new quote" when expired and loading with active quote', () => { @@ -1099,7 +1099,7 @@ describe('SwapsConfirmButton', () => { // Button should be disabled when walletAddress is missing const continueButton = getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON); - expect(continueButton.props.disabled).toBe(true); + expect(continueButton.props.accessibilityState?.disabled).toBe(true); // Verify submitBridgeTx is not called since button is disabled expect(mockSubmitBridgeTx).not.toHaveBeenCalled(); diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx index be12f21f417..5686b8f3d77 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useEffect, useRef } from 'react'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { BridgeViewSelectorsIDs } from '../../Views/BridgeView/BridgeView.testIds'; import { useSelector } from 'react-redux'; @@ -213,14 +213,15 @@ export const SwapsConfirmButton = ({ return ( ); }; diff --git a/app/components/UI/Bridge/components/SwapsKeypad/QuickPickButtons.tsx b/app/components/UI/Bridge/components/SwapsKeypad/QuickPickButtons.tsx index 526d93c23fb..b4436c28fdb 100644 --- a/app/components/UI/Bridge/components/SwapsKeypad/QuickPickButtons.tsx +++ b/app/components/UI/Bridge/components/SwapsKeypad/QuickPickButtons.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { View } from 'react-native'; import { quickPickButtonsStyles as styles } from './styles'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import { QuickPickButtonOption } from './types'; interface Props { @@ -22,12 +23,13 @@ export const QuickPickButtons = ({ options, show }: Props) => { {options.map((option) => ( ))} ); diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 4f95ec01a5d..0de1dd526b7 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -21,8 +21,9 @@ import { selectCurrentCurrency } from '../../../../../selectors/currencyRateCont import { BigNumber } from 'ethers'; import { BridgeToken } from '../../types'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; -import Button, { - ButtonVariants, +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; +import OldButton, { + ButtonVariants as OldButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; @@ -309,15 +310,16 @@ export const TokenInputArea = forwardRef< /> ) : ( )} @@ -355,8 +357,8 @@ export const TokenInputArea = forwardRef< tokenBalance && onMaxPress && shouldShowMaxButton && ( - )} {bridgeDestExplorerData?.explorerTxUrl && ( )} diff --git a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx index 61047f04702..2c1c92aef53 100644 --- a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx +++ b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx @@ -25,9 +25,7 @@ import { StyleSheet, TouchableOpacity } from 'react-native'; import { calcHexGasTotal } from '../../utils/transactionGas'; import { strings } from '../../../../../../locales/i18n'; import BridgeStepList from './BridgeStepList'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import Routes from '../../../../../constants/navigation/Routes'; import { BridgeToken } from '../../types'; import { @@ -417,8 +415,7 @@ export const BridgeTransactionDetails = ( {isIntentNotCompletedItem || ( )} From 88aec0c23456b29eba5d12c91c3e067422add271 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Wed, 25 Mar 2026 15:19:01 -0300 Subject: [PATCH 04/15] fix(card): fix missing network issue (#27833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes Card spending-limit delegation when the token’s chain (e.g. Monad) is **not yet added** to the user’s network list. Previously, `useCardDelegation` resolved the approval transaction’s `networkClientId` via **`NetworkController.findNetworkClientIdByChainId`**, which only works for networks the wallet already knows about—so flows could fail for Card-supported chains that were missing from the client. **What changed**: - **`useEnsureCardNetworkExists` (new)**: Hook that, given a CAIP chain ID, returns the correct `networkClientId`. If the EVM network is already in **`selectEvmNetworkConfigurationsByChainId`**, it returns the existing client id. If not, it looks up the chain in **`PopularList`**, calls **`NetworkController.addNetwork`** with that RPC/metadata, **`enableNetwork`** so the network filter includes it, then returns the new `networkClientId`. Throws a clear error if the chain is not in `PopularList` or if `addNetwork` does not yield a client id. - **`useCardDelegation`**: Replaces `findNetworkClientIdByChainId` + `safeFormatChainIdToHex` with **`await ensureNetworkExists(token.caipChainId)`** before **`TransactionController.addTransaction`** for the approval. - **Tests**: **`useEnsureCardNetworkExists.test.ts`** covers existing vs missing network, Monad-style `PopularList` add + enable, errors for unknown chains and failed adds; **`useCardDelegation.test.ts`** mocks the new hook and asserts **`ensureNetworkExists`** is called with the token’s CAIP chain id. ## **Changelog** CHANGELOG entry: Card delegation now auto-adds Card-supported EVM networks from the popular list when missing, so approval transactions get a valid `networkClientId` (e.g. Monad). ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card delegation when network is not in the wallet Scenario: Delegation on a chain already in the network list Given I have the Card token’s chain already added (e.g. Polygon) When I complete the spending limit / delegation approval flow Then the approval transaction submits without adding a duplicate network Scenario: Delegation on a Card-supported chain not yet added (e.g. Monad) Given the token’s CAIP chain is in PopularList but not in my networks When I run the Card delegation approval flow Then the app adds the network from the popular list, enables it, and submits the approval on that network Scenario: Unsupported chain not in PopularList Given a chain ID that is not in PopularList (if testable) When delegation runs Then the user sees a failure path consistent with the thrown error (network not found in PopularList) ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/0cd1b22f-5296-41f3-b961-316f05c3ae79 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk because it changes transaction routing by potentially auto-adding and enabling EVM networks at runtime, which could impact network configuration/state if the PopularList entry or enablement flow is wrong. > > **Overview** > Fixes Card spending-limit delegation failing when the token’s chain isn’t already in the user’s configured networks by resolving a valid `networkClientId` even for missing chains. > > Adds `useEnsureCardNetworkExists`, which checks existing EVM network configs and otherwise adds the chain from `PopularList` via `NetworkController.addNetwork` and enables it before returning the new `networkClientId`. `useCardDelegation` now calls this hook (replacing `findNetworkClientIdByChainId`) and tests are updated/added to cover the new behavior and error cases. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9386501051c496b9906bae6edbf8ba5d80eee4e1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Card/hooks/useCardDelegation.test.ts | 27 ++- .../UI/Card/hooks/useCardDelegation.ts | 13 +- .../hooks/useEnsureCardNetworkExists.test.ts | 214 ++++++++++++++++++ .../Card/hooks/useEnsureCardNetworkExists.ts | 89 ++++++++ 4 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 app/components/UI/Card/hooks/useEnsureCardNetworkExists.test.ts create mode 100644 app/components/UI/Card/hooks/useEnsureCardNetworkExists.ts diff --git a/app/components/UI/Card/hooks/useCardDelegation.test.ts b/app/components/UI/Card/hooks/useCardDelegation.test.ts index 724baeef993..3107ea29fe5 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.test.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.test.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { useCardDelegation, UserCancelledError } from './useCardDelegation'; import { useCardSDK } from '../sdk'; import { useNeedsGasFaucet } from './useNeedsGasFaucet'; +import { useEnsureCardNetworkExists } from './useEnsureCardNetworkExists'; import { CardSDK } from '../sdk/CardSDK'; import { CardTokenAllowance, AllowanceState } from '../types'; import Engine from '../../../../core/Engine'; @@ -33,6 +34,10 @@ jest.mock('./useNeedsGasFaucet', () => ({ useNeedsGasFaucet: jest.fn(), })); +jest.mock('./useEnsureCardNetworkExists', () => ({ + useEnsureCardNetworkExists: jest.fn(), +})); + jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: jest.fn(), })); @@ -58,9 +63,6 @@ jest.mock('../../../../core/Engine', () => ({ TransactionController: { addTransaction: jest.fn(), }, - NetworkController: { - findNetworkClientIdByChainId: jest.fn(), - }, }, controllerMessenger: { subscribeOnceIf: jest.fn(), @@ -98,6 +100,11 @@ const mockUseAnalytics = jest.mocked(useAnalytics); const mockUseNeedsGasFaucet = useNeedsGasFaucet as jest.MockedFunction< typeof useNeedsGasFaucet >; +const mockEnsureNetworkExists = jest.fn(); +const mockUseEnsureCardNetworkExists = + useEnsureCardNetworkExists as jest.MockedFunction< + typeof useEnsureCardNetworkExists + >; const mockToTokenMinimalUnit = toTokenMinimalUnit as jest.MockedFunction< typeof toTokenMinimalUnit >; @@ -205,9 +212,11 @@ describe('useCardDelegation', () => { id: 'transaction-meta-id-123', }, }); - Engine.context.NetworkController.findNetworkClientIdByChainId = jest - .fn() - .mockReturnValue(mockNetworkClientId); + // Setup useEnsureCardNetworkExists mock + mockEnsureNetworkExists.mockResolvedValue(mockNetworkClientId); + mockUseEnsureCardNetworkExists.mockReturnValue({ + ensureNetworkExists: mockEnsureNetworkExists, + }); // Setup controllerMessenger mock to simulate transaction confirmation Engine.controllerMessenger.subscribeOnceIf = jest @@ -1087,7 +1096,7 @@ describe('useCardDelegation', () => { ); }); - it('finds network client by chain ID', async () => { + it('ensures network exists for the token chain ID', async () => { const mockToken = createMockToken({ caipChainId: 'eip155:137' }); const params = createMockDelegationParams(); @@ -1097,9 +1106,7 @@ describe('useCardDelegation', () => { await result.current.submitDelegation(params); }); - expect( - Engine.context.NetworkController.findNetworkClientIdByChainId, - ).toHaveBeenCalled(); + expect(mockEnsureNetworkExists).toHaveBeenCalledWith('eip155:137'); }); it('subscribes to transaction confirmation event', async () => { diff --git a/app/components/UI/Card/hooks/useCardDelegation.ts b/app/components/UI/Card/hooks/useCardDelegation.ts index 3cdb90027c9..61c94122524 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.ts @@ -16,9 +16,8 @@ import Logger from '../../../../util/Logger'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { useCardSDK } from '../sdk'; import { CardNetwork, CardTokenAllowance } from '../types'; -import { safeFormatChainIdToHex } from '../util/safeFormatChainIdToHex'; -import { Hex } from '@metamask/utils'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { useEnsureCardNetworkExists } from './useEnsureCardNetworkExists'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { ARBITRARY_ALLOWANCE } from '../constants'; import { toTokenMinimalUnit } from '../../../../util/number'; @@ -78,8 +77,8 @@ interface SignCardMessageResult { */ export const useCardDelegation = (token?: CardTokenAllowance | null) => { const { sdk } = useCardSDK(); - const { KeyringController, TransactionController, NetworkController } = - Engine.context; + const { KeyringController, TransactionController } = Engine.context; + const { ensureNetworkExists } = useEnsureCardNetworkExists(); const selectAccountByScope = useSelector( selectSelectedInternalAccountByScope, ); @@ -143,8 +142,8 @@ export const useCardDelegation = (token?: CardTokenAllowance | null) => { throw new Error('Missing token address'); } - const networkClientId = NetworkController.findNetworkClientIdByChainId( - safeFormatChainIdToHex(token.caipChainId ?? '') as Hex, + const networkClientId = await ensureNetworkExists( + token.caipChainId ?? '', ); // Convert amount to minimal units based on token decimals @@ -235,7 +234,7 @@ export const useCardDelegation = (token?: CardTokenAllowance | null) => { rethrowAsUserCancelledIfApplicable(error); } }, - [sdk, token, TransactionController, NetworkController], + [sdk, token, TransactionController, ensureNetworkExists], ); /** diff --git a/app/components/UI/Card/hooks/useEnsureCardNetworkExists.test.ts b/app/components/UI/Card/hooks/useEnsureCardNetworkExists.test.ts new file mode 100644 index 00000000000..b05f6973b31 --- /dev/null +++ b/app/components/UI/Card/hooks/useEnsureCardNetworkExists.test.ts @@ -0,0 +1,214 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { useSelector } from 'react-redux'; +import { useEnsureCardNetworkExists } from './useEnsureCardNetworkExists'; +import Engine from '../../../../core/Engine'; +import { useNetworkEnablement } from '../../../hooks/useNetworkEnablement/useNetworkEnablement'; + +jest.mock('../../../../core/Engine'); +jest.mock('../../../hooks/useNetworkEnablement/useNetworkEnablement'); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockEnableNetwork = jest.fn(); +const mockAddNetwork = jest.fn(); +const mockUseSelector = useSelector as jest.MockedFunction; + +interface MockEngineContext { + NetworkController: { + addNetwork: jest.Mock; + }; +} + +const MONAD_CAIP_CHAIN_ID = 'eip155:143'; +const MONAD_HEX_CHAIN_ID = '0x8f'; +const UNKNOWN_CAIP_CHAIN_ID = 'eip155:99999'; + +describe('useEnsureCardNetworkExists', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useNetworkEnablement as jest.Mock).mockReturnValue({ + enableNetwork: mockEnableNetwork, + }); + + (Engine.context as unknown as MockEngineContext) = { + NetworkController: { + addNetwork: mockAddNetwork, + }, + }; + + // Default: no networks configured + mockUseSelector.mockReturnValue({}); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns ensureNetworkExists function', () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + expect(result.current).toEqual({ + ensureNetworkExists: expect.any(Function), + }); + }); + + describe('ensureNetworkExists', () => { + describe('when network already exists in user configurations', () => { + const existingNetworkClientId = 'monad-mainnet'; + + beforeEach(() => { + mockUseSelector.mockReturnValue({ + [MONAD_HEX_CHAIN_ID]: { + chainId: MONAD_HEX_CHAIN_ID, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: existingNetworkClientId }], + }, + }); + }); + + it('returns the existing networkClientId', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + let networkClientId: string | undefined; + await act(async () => { + networkClientId = + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(networkClientId).toBe(existingNetworkClientId); + }); + + it('does not call addNetwork', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(mockAddNetwork).not.toHaveBeenCalled(); + }); + + it('does not call enableNetwork', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(mockEnableNetwork).not.toHaveBeenCalled(); + }); + }); + + describe('when network is missing', () => { + const newNetworkClientId = 'monad-mainnet-new'; + + beforeEach(() => { + mockAddNetwork.mockResolvedValue({ + chainId: MONAD_HEX_CHAIN_ID, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: newNetworkClientId }], + }); + }); + + it('calls addNetwork with Monad config from PopularList', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(mockAddNetwork).toHaveBeenCalledWith({ + chainId: MONAD_HEX_CHAIN_ID, + blockExplorerUrls: ['https://monadscan.com/'], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: 0, + name: 'Monad', + nativeCurrency: 'MON', + rpcEndpoints: [ + { + url: expect.stringContaining( + 'https://monad-mainnet.infura.io/v3/', + ), + failoverUrls: expect.any(Array), + name: 'Monad', + type: RpcEndpointType.Custom, + }, + ], + }); + }); + + it('enables the network after adding it', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(mockEnableNetwork).toHaveBeenCalledWith(MONAD_CAIP_CHAIN_ID); + expect(mockEnableNetwork).toHaveBeenCalledTimes(1); + }); + + it('returns the new networkClientId', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + let networkClientId: string | undefined; + await act(async () => { + networkClientId = + await result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID); + }); + + expect(networkClientId).toBe(newNetworkClientId); + }); + + it('throws when addNetwork returns no networkClientId', async () => { + mockAddNetwork.mockResolvedValue({ + chainId: MONAD_HEX_CHAIN_ID, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{}], + }); + + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await expect( + result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID), + ).rejects.toThrow( + `Failed to get networkClientId after adding network for ${MONAD_CAIP_CHAIN_ID}`, + ); + }); + }); + + it('propagates addNetwork errors', async () => { + const networkError = new Error('Network addition failed'); + mockAddNetwork.mockRejectedValue(networkError); + + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await expect( + result.current.ensureNetworkExists(MONAD_CAIP_CHAIN_ID), + ).rejects.toThrow('Network addition failed'); + }); + }); + }); + + describe('when chain ID is not in PopularList', () => { + it('throws a descriptive error', async () => { + const { result } = renderHook(() => useEnsureCardNetworkExists()); + + await act(async () => { + await expect( + result.current.ensureNetworkExists(UNKNOWN_CAIP_CHAIN_ID), + ).rejects.toThrow( + `Network not found in PopularList for chain ID ${UNKNOWN_CAIP_CHAIN_ID}`, + ); + }); + + expect(mockAddNetwork).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/app/components/UI/Card/hooks/useEnsureCardNetworkExists.ts b/app/components/UI/Card/hooks/useEnsureCardNetworkExists.ts new file mode 100644 index 00000000000..bd440b10897 --- /dev/null +++ b/app/components/UI/Card/hooks/useEnsureCardNetworkExists.ts @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { CaipChainId, Hex } from '@metamask/utils'; +import { RpcEndpointType } from '@metamask/network-controller'; +import { toHex } from '@metamask/controller-utils'; +import Engine from '../../../../core/Engine'; +import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController'; +import { useNetworkEnablement } from '../../../hooks/useNetworkEnablement/useNetworkEnablement'; +import { PopularList } from '../../../../util/networks/customNetworks'; +import { safeFormatChainIdToHex } from '../util/safeFormatChainIdToHex'; + +/** + * Ensures a Card-supported EVM network exists in the user's network list. + * If the network is missing, it automatically adds it from the PopularList and enables it. + */ +export const useEnsureCardNetworkExists = () => { + const networkConfigurations = useSelector( + selectEvmNetworkConfigurationsByChainId, + ); + const { enableNetwork } = useNetworkEnablement(); + + /** + * Ensures the network for the given CAIP chain ID exists and returns its networkClientId. + */ + const ensureNetworkExists = useCallback( + async (caipChainId: string): Promise => { + const hexChainId = safeFormatChainIdToHex(caipChainId) as Hex; + + // Network already exists — return existing networkClientId + const existingConfig = networkConfigurations[hexChainId]; + if (existingConfig) { + const { rpcEndpoints, defaultRpcEndpointIndex } = existingConfig; + return rpcEndpoints[defaultRpcEndpointIndex].networkClientId; + } + + // Network is missing — find it in PopularList and add it + const popularEntry = PopularList.find( + (network) => toHex(network.chainId as string) === hexChainId, + ); + + if (!popularEntry) { + throw new Error( + `Network not found in PopularList for chain ID ${caipChainId}`, + ); + } + + const { NetworkController } = Engine.context; + + const addedNetwork = await NetworkController.addNetwork({ + chainId: hexChainId, + blockExplorerUrls: popularEntry.rpcPrefs?.blockExplorerUrl + ? [popularEntry.rpcPrefs.blockExplorerUrl] + : [], + defaultRpcEndpointIndex: 0, + defaultBlockExplorerUrlIndex: popularEntry.rpcPrefs?.blockExplorerUrl + ? 0 + : undefined, + name: popularEntry.nickname, + nativeCurrency: popularEntry.ticker, + rpcEndpoints: [ + { + url: popularEntry.rpcUrl, + failoverUrls: popularEntry.failoverRpcUrls, + name: popularEntry.nickname, + type: RpcEndpointType.Custom, + }, + ], + }); + + // Enable the newly added network in the network filter + enableNetwork(caipChainId as CaipChainId); + + const networkClientId = + addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] + ?.networkClientId; + + if (!networkClientId) { + throw new Error( + `Failed to get networkClientId after adding network for ${caipChainId}`, + ); + } + + return networkClientId; + }, + [networkConfigurations, enableNetwork], + ); + + return { ensureNetworkExists }; +}; From 65a83ce01b9689062d3c6f77f677e80436fa7240 Mon Sep 17 00:00:00 2001 From: hunty Date: Wed, 25 Mar 2026 13:23:15 -0500 Subject: [PATCH 05/15] fix: fixes the text wrapping in the predict outcome CTA button (#27832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Previously, long outcome names in the footer CTA buttons on the sports prediction market details screen could wrap awkwardly and break the button layout. This PR updates those CTA buttons to switch to a stacked title-and-price layout for longer labels and allows the buttons to grow vertically when needed, so the content stays readable, centered, and visually consistent. It also adds regression coverage for long outcome names. ## **Changelog** CHANGELOG entry: Fixed a bug where long sports prediction outcome names could break the action button layout on market details. ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: sports prediction market CTA layout Scenario: user views a market with long outcome names Given user is on an open sports prediction market details screen with long team names And the About tab is selected When the footer action buttons are shown Then each outcome name wraps cleanly within its button And each button grows tall enough to fit the wrapped text And the price remains visible and centered ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-23 at 4 41 02 PM ### **After** Screenshot 2026-03-23 at 4 40 53 PM ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only change to footer action button rendering plus test updates; main risk is minor layout regressions or missing token/title edge cases in the new label logic. > > **Overview** > Fixes footer CTA button layout for sports Predict markets by switching to a *stacked title + price* label when outcome names exceed a length threshold, and allowing the buttons to grow vertically (min height/padding) so long text wraps cleanly. > > Updates `PredictMarketDetails` tests to match the new button label format, makes button selection resilient by parsing the trailing `¢` price instead of exact label strings, and adds a regression test asserting long outcome names render with visible title and price. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cf74e3b856076fa19262269f0e3d917dd7c61f7e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictMarketDetails.test.tsx | 54 ++++++-- .../PredictMarketDetailsActions.tsx | 118 +++++++++++++----- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 1c9ecc17f00..b6b9731536e 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -687,10 +687,13 @@ const getActionButtons = () => .getAllByTestId('button') .filter((button) => getActionButtonText(button).includes('¢')); +const getActionButtonPrice = (button: ReactTestInstance) => { + const match = getActionButtonText(button).match(/(\d+(?:\.\d+)?)¢$/); + return match ? Number(match[1]) : null; +}; + const findActionButtonByPrice = (price: number) => - getActionButtons().find( - (button) => getActionButtonText(button) === `•${price}¢`, - ); + getActionButtons().find((button) => getActionButtonPrice(button) === price); describe('PredictMarketDetails', () => { afterEach(() => { @@ -1221,7 +1224,9 @@ describe('PredictMarketDetails', () => { const buttonLabels = actionButtons.map(getActionButtonText); expect(actionButtons).toHaveLength(2); - expect(buttonLabels).toEqual(expect.arrayContaining(['•65¢', '•35¢'])); + expect(buttonLabels).toEqual( + expect.arrayContaining(['Yes•65¢', 'No•35¢']), + ); }); it('calculates percentage correctly from market price', () => { @@ -1245,7 +1250,9 @@ describe('PredictMarketDetails', () => { const actionButtons = getActionButtons(); const buttonLabels = actionButtons.map(getActionButtonText); - expect(buttonLabels).toEqual(expect.arrayContaining(['•75¢', '•25¢'])); + expect(buttonLabels).toEqual( + expect.arrayContaining(['Yes•75¢', 'No•25¢']), + ); }); }); @@ -1298,7 +1305,7 @@ describe('PredictMarketDetails', () => { const actionButtons = getActionButtons(); const buttonLabels = actionButtons.map(getActionButtonText); - expect(buttonLabels).toContain('•65¢'); + expect(buttonLabels).toContain('Yes•65¢'); }); it('handles missing price data gracefully', () => { @@ -1323,7 +1330,9 @@ describe('PredictMarketDetails', () => { const actionButtons = getActionButtons(); const buttonLabels = actionButtons.map(getActionButtonText); - expect(buttonLabels).toEqual(expect.arrayContaining(['•0¢', '•100¢'])); + expect(buttonLabels).toEqual( + expect.arrayContaining(['Yes•0¢', 'No•100¢']), + ); }); }); @@ -1499,6 +1508,31 @@ describe('PredictMarketDetails', () => { }, ); }); + + it('renders stacked footer labels for long outcome names', () => { + const singleOutcomeMarket = createMockMarket({ + title: 'College basketball winner', + status: 'open', + outcomes: [ + { + id: 'outcome-1', + title: 'Winner', + tokens: [ + { id: 'token-1', title: 'Howard Bison', price: 0.98 }, + { id: 'token-2', title: 'Coppin State Eagles', price: 0.02 }, + ], + volume: 1000000, + }, + ], + }); + + setupPredictMarketDetailsTest(singleOutcomeMarket); + + expect(screen.getByText('Howard Bison')).toBeOnTheScreen(); + expect(screen.getByText('98¢')).toBeOnTheScreen(); + expect(screen.getByText('Coppin State Eagles')).toBeOnTheScreen(); + expect(screen.getByText('2¢')).toBeOnTheScreen(); + }); }); describe('Pull to Refresh', () => { @@ -1690,7 +1724,7 @@ describe('PredictMarketDetails', () => { const actionButtons = getActionButtons(); const buttonLabels = actionButtons.map(getActionButtonText); - expect(buttonLabels).toContain('•65¢'); + expect(buttonLabels).toContain('Yes•65¢'); }); it('renders action buttons only for single outcome markets', () => { @@ -1714,7 +1748,9 @@ describe('PredictMarketDetails', () => { const actionButtons = getActionButtons(); const buttonLabels = actionButtons.map(getActionButtonText); - expect(buttonLabels).toEqual(expect.arrayContaining(['•65¢', '•35¢'])); + expect(buttonLabels).toEqual( + expect.arrayContaining(['Yes•65¢', 'No•35¢']), + ); }); }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx index 4a7bc6d246c..21a5b9f6fe6 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx @@ -23,6 +23,12 @@ import { type PredictOutcomeToken, } from '../../../../types'; +const LONG_OUTCOME_LABEL_THRESHOLD = 12; +const TALL_ACTION_BUTTON_MIN_HEIGHT = 48; + +const shouldUseStackedActionButtonLabel = (title?: string) => + Boolean(title && title.length > LONG_OUTCOME_LABEL_THRESHOLD); + export interface PredictMarketDetailsActionsProps { isClaimablePositionsLoading: boolean; hasPositivePnl: boolean; @@ -53,6 +59,42 @@ const PredictMarketDetailsActions = memo( }: PredictMarketDetailsActionsProps) => { const tw = useTailwind(); + const renderActionButtonLabel = ({ + title, + price, + color, + useStackedLabels, + }: { + title: string; + price: number; + color: TextColor; + useStackedLabels: boolean; + }) => { + if (useStackedLabels) { + return ( + + + {title} + + + {price}¢ + + + ); + } + + return ( + + {title} • {price}¢ + + ); + }; + return ( <> {(() => { @@ -72,6 +114,27 @@ const PredictMarketDetailsActions = memo( ) { // use openOutcomes for real-time (CLOB) prices const firstOpenOutcome = openOutcomes[0]; + const yesToken = + firstOpenOutcome?.tokens?.[0] ?? + market?.outcomes?.[0]?.tokens?.[0]; + const noToken = + firstOpenOutcome?.tokens?.[1] ?? + market?.outcomes?.[0]?.tokens?.[1]; + const yesTitle = yesToken?.title ?? ''; + const noTitle = noToken?.title ?? ''; + const useStackedLabels = + shouldUseStackedActionButtonLabel(yesTitle) || + shouldUseStackedActionButtonLabel(noTitle); + const getActionButtonStyle = (backgroundClassName: string) => + tw.style( + 'flex-1', + backgroundClassName, + useStackedLabels && { + height: 'auto', + minHeight: TALL_ACTION_BUTTON_MIN_HEIGHT, + paddingVertical: 8, + }, + ); return ( - {firstOpenOutcome?.tokens[0].title} • {yesPercentage}¢ - - } - onPress={() => - onBuyPress( - firstOpenOutcome?.tokens[0] ?? - market?.outcomes[0].tokens[0], - ) - } + style={getActionButtonStyle('bg-success-muted')} + label={renderActionButtonLabel({ + title: yesTitle, + price: yesPercentage, + color: TextColor.SuccessDefault, + useStackedLabels, + })} + onPress={() => { + if (yesToken) { + onBuyPress(yesToken); + } + }} /> ); diff --git a/app/components/Views/SDK/SDKSessionModal/SDKSessionModal.tsx b/app/components/Views/SDK/SDKSessionModal/SDKSessionModal.tsx index 3683311520a..08fb19f62f7 100644 --- a/app/components/Views/SDK/SDKSessionModal/SDKSessionModal.tsx +++ b/app/components/Views/SDK/SDKSessionModal/SDKSessionModal.tsx @@ -8,9 +8,7 @@ import { StyleSheet, View } from 'react-native'; import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { AvatarVariant } from '../../../../component-library/components/Avatars/Avatar'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import Cell, { CellVariant, } from '../../../../component-library/components/Cells/Cell'; @@ -169,7 +167,7 @@ const SDKSessionModal = ({ route }: SDKSEssionMoodalProps) => { > ))} ); diff --git a/app/components/Views/SDK/SDKSessionsManager/SDKSessionAccountListItem.tsx b/app/components/Views/SDK/SDKSessionsManager/SDKSessionAccountListItem.tsx index 49496b9f7f1..65af7a16fe9 100644 --- a/app/components/Views/SDK/SDKSessionsManager/SDKSessionAccountListItem.tsx +++ b/app/components/Views/SDK/SDKSessionsManager/SDKSessionAccountListItem.tsx @@ -11,9 +11,7 @@ import Badge, { BadgeVariant, } from '../../../../component-library/components/Badges/Badge'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import Text from '../../../../component-library/components/Texts/Text'; import { ConnectionProps } from '../../../../core/SDKConnect/Connection'; import { useTheme } from '../../../../util/theme'; @@ -100,10 +98,11 @@ export const SDKSessionAccountListItem = ({ {sessionName} ); }; diff --git a/app/components/Views/SDK/SDKSessionsManager/SDKSessionItem.tsx b/app/components/Views/SDK/SDKSessionsManager/SDKSessionItem.tsx index 6a6d07a073c..a50ff9ebf0d 100644 --- a/app/components/Views/SDK/SDKSessionsManager/SDKSessionItem.tsx +++ b/app/components/Views/SDK/SDKSessionsManager/SDKSessionItem.tsx @@ -12,10 +12,11 @@ import Badge, { BadgeVariant, } from '../../../../component-library/components/Badges/Badge'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonBaseSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; @@ -145,12 +146,13 @@ export const SDKSessionItem = ({ ); }; diff --git a/app/components/Views/SDK/SDKSessionsManager/SDKSessionsManager.tsx b/app/components/Views/SDK/SDKSessionsManager/SDKSessionsManager.tsx index 97cc391121a..bd52cb0ba02 100644 --- a/app/components/Views/SDK/SDKSessionsManager/SDKSessionsManager.tsx +++ b/app/components/Views/SDK/SDKSessionsManager/SDKSessionsManager.tsx @@ -8,9 +8,7 @@ import { useTheme } from '../../../../util/theme'; import type { ThemeColors, ThemeTypography } from '@metamask/design-tokens'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import Icon, { IconName, IconSize, @@ -122,13 +120,14 @@ const SDKSessionsManager = (props: SDKSessionsManagerProps) => { ), From 01706bd603e0ea977b5d86f1e5af456d0293a58b Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Wed, 25 Mar 2026 19:49:03 +0100 Subject: [PATCH 08/15] feat: migrate Button (confirmations scope) (#27805) ## **Description** Replace deprecated `Button` usage with DSRN package. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/d022ebd3-23b0-4f82-9684-c3d99810eca3 ### **After** https://github.com/user-attachments/assets/3fcd6842-1a84-4f3f-b9fb-21fbcf2608a9 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate risk because it swaps the underlying `Button` implementation across multiple confirmation keyboards/modals, which could subtly change disabled/press behavior and layout in transaction-critical screens. > > **Overview** > Migrates confirmation-flow buttons from the deprecated component-library `Button` to `@metamask/design-system-react-native`, updating APIs (e.g., `label` to children, `disabled` to `isDisabled`, `width` to `isFullWidth`, and new `ButtonVariant` enums) across keyboards, developer options, transaction retry, and gas/alert/spending-cap modals. > > Updates associated tests and snapshots to match the new rendered structure/props, including switching assertions to `toBeDisabled()` / `toBeEnabled()`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 845cb7370156bc0b19e9b353e23f778b66ae9fa6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 416 +++++++++++++----- .../transaction-details-retry.tsx | 14 +- .../deposit-keyboard/deposit-keyboard.tsx | 27 +- .../confirmations-developer-options.tsx | 17 +- .../edit-amount-keyboard.tsx | 18 +- .../custom-amount-info/custom-amount-info.tsx | 30 +- .../musd-max-conversion-info.test.tsx | 8 +- .../musd-max-conversion-info.tsx | 19 +- .../advanced-eip1559-modal.tsx | 14 +- .../advanced-gas-price-modal.tsx | 14 +- .../confirm-alert-modal.tsx | 31 +- .../edit-spending-cap-modal.tsx | 21 +- .../amount-keyboard/amount-keyboard.test.tsx | 4 +- .../amount-keyboard/amount-keyboard.tsx | 23 +- 14 files changed, 435 insertions(+), 221 deletions(-) diff --git a/app/components/Views/Settings/DeveloperOptions/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/DeveloperOptions/__snapshots__/index.test.tsx.snap index 9961a45d4dc..6c4fd275599 100644 --- a/app/components/Views/Settings/DeveloperOptions/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/DeveloperOptions/__snapshots__/index.test.tsx.snap @@ -672,45 +672,95 @@ exports[`DeveloperOptions renders correctly 1`] = ` > Trigger a Predict deposit confirmation. - Deposit - + Trigger a Predict claim confirmation. - Claim - + Trigger a Predict withdraw confirmation. - Withdraw - + Trigger a Perps withdraw confirmation. - Withdraw - + ); } diff --git a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx index 7812c9a8c47..e3836e110c9 100644 --- a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx +++ b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx @@ -2,9 +2,7 @@ import React, { memo, useCallback, useMemo } from 'react'; import KeypadComponent, { KeypadChangeData } from '../../../../Base/Keypad'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './deposit-keyboard.styles'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { Box } from '../../../../UI/Box/Box'; import { FlexDirection, JustifyContent } from '../../../../UI/Box/box.types'; import { strings } from '../../../../../../locales/i18n'; @@ -100,32 +98,35 @@ export const DepositKeyboard = memo( {alertMessage && ( )} {!alertMessage && !hasInput && buttons.map(({ label, value: buttonValue }) => ( ))} {!alertMessage && hasInput && ( )} ); } diff --git a/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx b/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx index df4db063434..73053110f81 100644 --- a/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx +++ b/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx @@ -7,9 +7,7 @@ import KeypadComponent, { } from '../../../../Base/Keypad'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './edit-amount-keyboard.styles'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { Box } from '../../../../UI/Box/Box'; import { FlexDirection, JustifyContent } from '../../../../UI/Box/box.types'; import { strings } from '../../../../../../locales/i18n'; @@ -72,19 +70,21 @@ export function EditAmountKeyboard({ ))} {!hideDoneButton && onDonePress && ( )} )} diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index ae881ed64c8..a0c8694209e 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -47,11 +47,11 @@ import { strings } from '../../../../../../../locales/i18n'; import { hasTransactionType } from '../../../utils/transaction'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { TransactionType } from '@metamask/transaction-controller'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { useAlerts } from '../../../context/alert-system-context'; import { useTransactionConfirm } from '../../../hooks/transactions/useTransactionConfirm'; import EngineService from '../../../../../../core/EngineService'; @@ -263,12 +263,13 @@ function BuySection() { )} ); } @@ -287,13 +288,14 @@ function ConfirmButton({ ); } diff --git a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.test.tsx b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.test.tsx index 6a58597c3ba..f52056221c0 100644 --- a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.test.tsx +++ b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.test.tsx @@ -214,7 +214,7 @@ describe('MusdMaxConversionInfo', () => { const confirmButton = screen.getByTestId( MusdMaxConversionInfoTestIds.CONFIRM_BUTTON, ); - expect(confirmButton.props.disabled).toBe(true); + expect(confirmButton).toBeDisabled(); }); it('disables confirm button when quotes are loading', () => { @@ -225,7 +225,7 @@ describe('MusdMaxConversionInfo', () => { const confirmButton = screen.getByTestId( MusdMaxConversionInfoTestIds.CONFIRM_BUTTON, ); - expect(confirmButton.props.disabled).toBe(true); + expect(confirmButton).toBeDisabled(); }); it('disables confirm button when alerts contain isBlocking entry', () => { @@ -246,7 +246,7 @@ describe('MusdMaxConversionInfo', () => { const confirmButton = screen.getByTestId( MusdMaxConversionInfoTestIds.CONFIRM_BUTTON, ); - expect(confirmButton.props.disabled).toBe(true); + expect(confirmButton).toBeDisabled(); }); it('enables confirm button when not loading and no blocking alerts', () => { @@ -255,7 +255,7 @@ describe('MusdMaxConversionInfo', () => { const confirmButton = screen.getByTestId( MusdMaxConversionInfoTestIds.CONFIRM_BUTTON, ); - expect(confirmButton.props.disabled).toBe(false); + expect(confirmButton).toBeEnabled(); }); }); }); diff --git a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx index 159b919aa22..79d89ac176d 100644 --- a/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx +++ b/app/components/Views/confirmations/components/info/musd-max-conversion-info/musd-max-conversion-info.tsx @@ -3,11 +3,11 @@ import { Image, View } from 'react-native'; import { useStyles } from '../../../../../hooks/useStyles'; import { useParams } from '../../../../../../util/navigation/navUtils'; import { strings } from '../../../../../../../locales/i18n'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData'; import { useTransactionConfirm } from '../../../hooks/transactions/useTransactionConfirm'; @@ -103,14 +103,15 @@ export const MusdMaxConversionInfo = () => { ); diff --git a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx index 317621a48f1..f0b8e2a14cb 100644 --- a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx @@ -8,10 +8,11 @@ import { import { pickBy } from 'lodash'; import { useStyles } from '../../../../../../component-library/hooks'; -import Button, { - ButtonVariants, +import { + Button, ButtonSize, -} from '../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; import { updateTransactionGasFees } from '../../../../../../util/transaction-controller'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; @@ -134,12 +135,13 @@ export const AdvancedEIP1559Modal = ({ ); diff --git a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx index d82ecb0e9a0..07c3810ad9e 100644 --- a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx @@ -5,10 +5,11 @@ import { pickBy } from 'lodash'; import { TransactionMeta } from '@metamask/transaction-controller'; import { useStyles } from '../../../../../../component-library/hooks'; -import Button, { - ButtonVariants, +import { + Button, ButtonSize, -} from '../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; import { updateTransactionGasFees } from '../../../../../../util/transaction-controller'; import { GasModalHeader } from '../../../components/gas/gas-modal-header'; @@ -106,13 +107,14 @@ export const AdvancedGasPriceModal = ({ ); diff --git a/app/components/Views/confirmations/components/modals/confirm-alert-modal/confirm-alert-modal.tsx b/app/components/Views/confirmations/components/modals/confirm-alert-modal/confirm-alert-modal.tsx index caeba012a77..f2e789697ff 100755 --- a/app/components/Views/confirmations/components/modals/confirm-alert-modal/confirm-alert-modal.tsx +++ b/app/components/Views/confirmations/components/modals/confirm-alert-modal/confirm-alert-modal.tsx @@ -2,9 +2,14 @@ import React, { useCallback, useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useTheme } from '../../../../../../util/theme'; import BottomModal from '../../../components/UI/bottom-modal'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, + ButtonVariant, + IconName as DesignSystemIconName, +} from '@metamask/design-system-react-native'; +import { + ButtonSize as ButtonLinkSize, ButtonWidthTypes, } from '../../../../../../component-library/components/Buttons/Button'; import Checkbox from '../../../../../../component-library/components/Checkbox'; @@ -98,7 +103,7 @@ const ConfirmAlertModal: React.FC = ({ label={strings('alert_system.confirm_modal.review_alerts')} startIconName={IconName.SecuritySearch} width={ButtonWidthTypes.Auto} - size={ButtonSize.Lg} + size={ButtonLinkSize.Lg} labelTextVariant={TextVariant.BodyMD} /> )} @@ -119,26 +124,28 @@ const ConfirmAlertModal: React.FC = ({ diff --git a/app/components/Views/confirmations/components/modals/edit-spending-cap-modal/edit-spending-cap-modal.tsx b/app/components/Views/confirmations/components/modals/edit-spending-cap-modal/edit-spending-cap-modal.tsx index e02eeb8679e..dd9d635bad1 100644 --- a/app/components/Views/confirmations/components/modals/edit-spending-cap-modal/edit-spending-cap-modal.tsx +++ b/app/components/Views/confirmations/components/modals/edit-spending-cap-modal/edit-spending-cap-modal.tsx @@ -3,10 +3,11 @@ import { View } from 'react-native'; import { ApproveComponentIDs } from '../../../ConfirmationView.testIds'; import { useStyles } from '../../../../../../component-library/hooks'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; import Text, { TextVariant, @@ -91,18 +92,18 @@ export const EditSpendingCapModal = ({ diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx index 2586b1e5b4e..352affbed54 100644 --- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx @@ -159,9 +159,7 @@ describe('Amount', () => { } as unknown as ReturnType); const { getByRole } = renderComponent(undefined, ''); - expect( - getByRole('button', { name: 'Next' }).props.style.backgroundColor, - ).toEqual(mockTheme.colors.text.muted); + expect(getByRole('button', { name: 'Next' })).toBeDisabled(); }); it('call updateValue with MaxMode true when Max button is pressed', () => { diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx index 6993109ba82..ca54bd4a31c 100644 --- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx @@ -2,11 +2,11 @@ import React, { useCallback } from 'react'; import { strings } from '../../../../../../../../locales/i18n'; import Routes from '../../../../../../../constants/navigation/Routes'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import { useParams } from '../../../../../../../util/navigation/navUtils.ts'; import { useStyles } from '../../../../../../hooks/useStyles'; import { AssetType, TokenStandard } from '../../../../types/token'; @@ -135,17 +135,16 @@ export const AmountKeyboard = ({ additionalRow={ amount.length > 0 || isNFT ? ( ) : undefined } enableEmptyValueString From 002d91ac2a09420c712b1fdadca0a6aa6d3326cb Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:49:07 +0800 Subject: [PATCH 09/15] feat: show legacy ios login warning prompt cp-7.71.0 (#27875) ## **Description** Add warning prompt for ios <17.4 for google login Supports the fix for: https://github.com/MetaMask/MetaMask-planning/issues/7148 Part 1/ 4 - #27741 Part 2/ 4 - #27848 Part 3/ 4 - #27850 (deferred to 7.72.0) Part 4/ 4 - #27875 ## **Changelog** CHANGELOG entry: Add warning prompt for ios <17.4 for google login ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** For < iOS 17.4 https://github.com/user-attachments/assets/f6f3a031-82cc-486d-af5f-e6e1bbc7ed10 For >= iOS 17.4 https://github.com/user-attachments/assets/2cdc0bf3-d59b-4858-be81-baae5e0a4dd2 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Modifies the onboarding social login path by inserting a conditional pre-login warning and new navigation helper, which could affect Google login flow timing/navigation on iOS devices. Changes are localized but touch user authentication entrypoints and analytics tracking. > > **Overview** > Adds an **iOS < 17.4 warning gate** before starting Google OAuth during onboarding (both create and import flows), showing a non-interactable `SuccessErrorSheet` that must be acknowledged before proceeding. > > Introduces `Device.comparePlatformVersionTo()` (using `compare-versions`) and a reusable `navigateToSuccessErrorSheetPromise()` helper to await sheet dismissal, plus a new MetaMetrics event (`WALLET_GOOGLE_IOS_WARNING_VIEWED`) and localized warning copy. > > Updates onboarding tests to mock the new device helper/navigation and to assert the warning sheet + tracking fire before continuing with Google login. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3b43b83cf7c608c88da4b01bfb67603d840d1582. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Onboarding/index.test.tsx | 211 +++++++++++++++++- app/components/Views/Onboarding/index.tsx | 41 ++++ .../Views/SuccessErrorSheet/utils.test.ts | 113 ++++++++++ .../Views/SuccessErrorSheet/utils.ts | 37 +++ app/core/Analytics/MetaMetrics.events.ts | 4 + app/util/device/index.js | 16 ++ locales/languages/en.json | 8 +- 7 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.test.ts create mode 100644 app/components/Views/SuccessErrorSheet/utils.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 771c308e15b..8f59eef7433 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -71,6 +71,7 @@ import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; +import { IconName } from '../../../component-library/components/Icons/Icon'; import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; @@ -92,9 +93,22 @@ jest.mock('../../../util/test/utils', () => ({ import { fetch as netInfoFetch } from '@react-native-community/netinfo'; const mockNetInfoFetch = netInfoFetch as jest.Mock; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockGoBack = jest.fn(); // Helper to flush all pending promises const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const IOS_GOOGLE_WARNING_TITLE = strings('error_sheet.ios_need_update_title'); +const IOS_GOOGLE_WARNING_BUTTON = strings('error_sheet.ios_need_update_button'); + +const getIosGoogleWarningSheetCall = () => + mockNavigate.mock.calls.find( + ([route, params]) => + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + params?.params?.title === IOS_GOOGLE_WARNING_TITLE, + ); const mockInitialState = { engine: { @@ -127,13 +141,22 @@ const mockInitialStateWithExistingUserAndPassword = { }, }; -jest.mock('../../../util/device', () => ({ - isLargeDevice: jest.fn(), - isIphoneX: jest.fn(), - isAndroid: jest.fn(), - isIos: jest.fn(), - isMediumDevice: jest.fn(), -})); +jest.mock('../../../util/device', () => { + const mockDevice = { + isLargeDevice: jest.fn(), + isIphoneX: jest.fn(), + isAndroid: jest.fn(), + isIos: jest.fn(), + isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), + }; + + return { + __esModule: true, + default: mockDevice, + ...mockDevice, + }; +}); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ @@ -276,13 +299,12 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ }, })); -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); const mockNav = { navigate: mockNavigate, replace: mockReplace, reset: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, dispatch: jest.fn((action) => { if (action.type === 'REPLACE') { mockReplace(action.payload.name, action.payload.params); @@ -956,10 +978,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + (Device.isIos as jest.Mock).mockReturnValue(false); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); }); @@ -1263,6 +1288,174 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(true); + await flushPromises(); + await flushPromises(); + }); + + // Verify the warning sheet was shown with the iOS not-supported message. + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toEqual([ + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + }), + }), + ]); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('shows iOS version warning for Google login on iOS < 17.4 during import wallet flow', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = getByTestId( + OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(importWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(false); + await flushPromises(); + await flushPromises(); + }); + + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toBeDefined(); + expect(warningSheetCall?.[1].params).toEqual( + expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }), + ); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.ImportedGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + true, + ); + }); + it('navigates to AccountAlreadyExists for existing user in create wallet flow', async () => { mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockResolvedValue({ diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index f2d715f0a34..3c105f2a087 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,6 +110,11 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -770,6 +775,41 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + provider === AuthConnection.Google && + Device.isIos() && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + const description = () => ( + <> + + {strings(`error_sheet.ios_need_update_description`)} + + {strings(`error_sheet.ios_need_update_description_version`)} + + {strings(`error_sheet.ios_need_update_description_end`)} + + + {strings(`error_sheet.ios_need_update_description2`)} + + + ); + + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + iconColor: IconColor.Warning, + title: strings(`error_sheet.ios_need_update_title`), + description: description(), + primaryButtonLabel: strings(`error_sheet.ios_need_update_button`), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }); + track(MetaMetricsEvents.WALLET_GOOGLE_IOS_WARNING_VIEWED, { + account_type: accountType, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { @@ -799,6 +839,7 @@ const Onboarding = () => { handleExistingUser(action); }, [ + tw, navigation, metrics, track, diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..a0b29825c74 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,113 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from './utils'; + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationProp; + +const baseParams = { + type: 'error' as const, + title: 'Error Title', + description: 'Error description', +}; + +describe('navigateToSuccessErrorSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards params to the success error sheet route', () => { + const onClose = jest.fn(); + const onPrimaryButtonPress = jest.fn(); + const params = { + ...baseParams, + type: 'success' as const, + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center' as const, + primaryButtonLabel: 'OK', + }; + + navigateToSuccessErrorSheet(mockNavigation, params); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves and invokes the original onPrimaryButtonPress callback', async () => { + const onPrimaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves and invokes the original onSecondaryButtonPress callback', async () => { + const onSecondaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onSecondaryButtonPress).toHaveBeenCalledTimes(1); + }); + + it('resolves and invokes the original onClose callback', async () => { + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose, + }), + ).resolves.toBeUndefined(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..e9c655fa924 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,37 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { SuccessErrorSheetParams } from './interface'; +import Routes from '../../../constants/navigation/Routes'; + +export const navigateToSuccessErrorSheet = ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + ...params, + }, + }); +}; + +export const navigateToSuccessErrorSheetPromise = async ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => + new Promise((resolve) => { + navigateToSuccessErrorSheet(navigation, { + ...params, + onPrimaryButtonPress: () => { + params.onPrimaryButtonPress?.(); + resolve(); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(); + }, + onClose: () => { + params.onClose?.(); + resolve(); + }, + }); + }); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index e25ce47b64d..62d1689518d 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -147,6 +147,7 @@ enum EVENT_NAME { WALLET_CREATION_ATTEMPTED = 'Wallet Creation Attempted', WALLET_CREATED = 'Wallet Created', WALLET_SETUP_FAILURE = 'Wallet Setup Failure', + WALLET_GOOGLE_IOS_WARNING_VIEWED = 'Wallet Google Ios Warning Viewed', WALLET_CREATION_ERROR_SCREEN_VIEWED = 'Wallet Creation Error Screen Viewed', WALLET_CREATION_ERROR_RETRY_CLICKED = 'Wallet Creation Error Retry Clicked', WALLET_CREATION_ERROR_REPORT_SENT = 'Wallet Creation Error Report Sent', @@ -862,6 +863,9 @@ const events = { WALLET_CREATION_ATTEMPTED: generateOpt(EVENT_NAME.WALLET_CREATION_ATTEMPTED), WALLET_CREATED: generateOpt(EVENT_NAME.WALLET_CREATED), WALLET_SETUP_FAILURE: generateOpt(EVENT_NAME.WALLET_SETUP_FAILURE), + WALLET_GOOGLE_IOS_WARNING_VIEWED: generateOpt( + EVENT_NAME.WALLET_GOOGLE_IOS_WARNING_VIEWED, + ), WALLET_CREATION_ERROR_SCREEN_VIEWED: generateOpt( EVENT_NAME.WALLET_CREATION_ERROR_SCREEN_VIEWED, ), diff --git a/app/util/device/index.js b/app/util/device/index.js index df04b68a24d..c3329ec2161 100644 --- a/app/util/device/index.js +++ b/app/util/device/index.js @@ -2,6 +2,7 @@ import { Dimensions, Platform } from 'react-native'; import { hasNotch, getApiLevel } from 'react-native-device-info'; +import compareVersions from 'compare-versions'; export default class Device { static getDeviceWidth() { @@ -12,6 +13,21 @@ export default class Device { return Dimensions.get('window').height; } + /** + * Compares this device's React Native {@link Platform.Version} to `referenceVersion` + * using the shared `compare-versions` package after normalizing both values to strings. + * + * @param {string|number} referenceVersion - Version to compare against (e.g. `"17.4"`). + * @returns {number} `1` if current > reference, `-1` if current < reference, `0` if equal. + * @remarks On iOS, `Platform.Version` is usually a string (`"17.3.1"`). On Android it is + * typically the API level as a number (for example `34`). Both values are coerced to strings + * before comparison so the helper remains safe across platforms while preserving component-wise + * numeric comparison semantics. + */ + static comparePlatformVersionTo(referenceVersion) { + return compareVersions(String(Platform.Version), String(referenceVersion)); + } + static isIos() { return Platform.OS === 'ios'; } diff --git a/locales/languages/en.json b/locales/languages/en.json index 72fc3d4bf53..f294acd91bc 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6810,7 +6810,13 @@ "oauth_error_button": "Try again", "no_internet_connection_title": "Unable to connect", "no_internet_connection_description": "Your internet connection is unstable. Check your connection and try again.", - "no_internet_connection_button": "Try again" + "no_internet_connection_button": "Try again", + "ios_need_update_title": "iOS update required", + "ios_need_update_description": "MetaMask Google Sign-In will soon require ", + "ios_need_update_description_version": "iOS 17.4 or later", + "ios_need_update_description_end": ". You can continue using Google Sign-In on this device for now, but it will no longer be supported in an upcoming update.", + "ios_need_update_description2": "You can still access your wallet using the same Google account on a supported device or the MetaMask extension. We strongly recommend backing up your Secret Recovery Phrase to ensure uninterrupted access.", + "ios_need_update_button": "Continue" }, "password_hint": { "title": "Password hint", From dfdfbcefcb6558a8cbf9681240d4519d591c6a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 25 Mar 2026 20:27:12 +0100 Subject: [PATCH 10/15] chore: visual update and animation market insights (#27914) ## **Description** Screenshot 2026-03-25 at 16 08 01 https://github.com/user-attachments/assets/1c9dead2-ab01-47e9-ad20-498f3a74e008 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adds new Reanimated/SVG border animation and a polling-based `useViewportTracking` hook, which may impact UI performance or behave differently across layouts if measurement/visibility is off. > > **Overview** > Updates the Market Insights entry card visuals by switching to a new rounded, muted-background card layout and replacing the prior header/arrow treatment with an inline AI icon plus footer text. > > Introduces an `AnimatedGradientBorder` (SVG + Reanimated) that sweeps a gradient stroke around the card when it becomes fully visible, driven by new `useViewportTracking` viewport detection. > > Adds targeted tests for the new trace behavior, layout/dimension updates, and the viewport-tracking hook; also excludes the legacy `MarketInsightsEntryCardOriginal.tsx` and the animation component from coverage/sonar, and moves the entry card placement earlier in `AssetOverviewContent`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f96c206e1f40fcf73658ca360df3e3e2ca6777dd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../AnimatedGradientBorder.constants.ts | 29 +++ .../AnimatedGradientBorder.tsx | 202 ++++++++++++++++++ .../MarketInsightsEntryCard.test.tsx | 140 +++++++++++- .../MarketInsightsEntryCard.tsx | 127 ++++++----- .../MarketInsightsEntryCardOriginal.tsx | 105 +++++++++ .../hooks/useViewportTracking.test.ts | 196 +++++++++++++++++ .../hooks/useViewportTracking.ts | 76 +++++++ .../components/AssetOverviewContent.tsx | 26 +-- jest.config.js | 2 + sonar-project.properties | 2 +- 10 files changed, 828 insertions(+), 77 deletions(-) create mode 100644 app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.constants.ts create mode 100644 app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.tsx create mode 100644 app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCardOriginal.tsx create mode 100644 app/components/UI/MarketInsights/hooks/useViewportTracking.test.ts create mode 100644 app/components/UI/MarketInsights/hooks/useViewportTracking.ts diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.constants.ts b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.constants.ts new file mode 100644 index 00000000000..32dbdb43185 --- /dev/null +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.constants.ts @@ -0,0 +1,29 @@ +/** Duration of the sweep traveling around the border in ms */ +export const BORDER_SWEEP_DURATION_MS = 1000; + +/** Border radius matching Tailwind's rounded-2xl (16px) */ +export const BORDER_RADIUS = 16; + +/** Stroke width of the sharp main border line */ +export const BORDER_STROKE_WIDTH = 3; + +/** Fraction of the perimeter visible as the sweep segment + * How long the trail is + */ +export const BORDER_TRAIL_FRACTION = 0.4; + +/** Fraction of progress used for the fade-in at the start of the sweep + * How long the fade-in is + */ +export const BORDER_FADE_IN_FRACTION = 0.8; + +/** Fraction of progress used for the fade-out at the end of the sweep + * How long the fade-out is + */ +export const BORDER_FADE_OUT_FRACTION = 0.8; + +/** Gradient stop colors: pink/purple → orange */ +export const BORDER_GRADIENT_COLORS = ['#D075FF', '#FF5C16'] as const; + +/** Fraction of the card that must be visible on-screen to trigger the animation (1 = fully visible) */ +export const VISIBILITY_THRESHOLD = 1; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.tsx new file mode 100644 index 00000000000..f5737515d1d --- /dev/null +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useRef } from 'react'; +import { StyleSheet } from 'react-native'; +import Svg, { Defs, LinearGradient, Stop, Path } from 'react-native-svg'; +import Animated, { + Easing, + useAnimatedProps, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, + cancelAnimation, +} from 'react-native-reanimated'; +import { + BORDER_FADE_IN_FRACTION, + BORDER_FADE_OUT_FRACTION, + BORDER_GRADIENT_COLORS, + BORDER_RADIUS, + BORDER_STROKE_WIDTH, + BORDER_SWEEP_DURATION_MS, + BORDER_TRAIL_FRACTION, +} from './AnimatedGradientBorder.constants'; + +const AnimatedPath = Animated.createAnimatedComponent(Path); + +const GRADIENT_ID = 'borderGradient'; + +const styles = StyleSheet.create({ + svg: { + position: 'absolute', + top: 0, + left: 0, + }, +}); + +/** + * Rounded rectangle path inset by half the stroke width so the stroke's + * outer edge aligns with the card border. Starts at the bottom-left corner, + * tracing clockwise: left↑ → top→ → right↓ → bottom← → back to start. + * Not closed with Z so the dash pattern begins exactly at the start point. + */ +function buildRoundedRectPath( + width: number, + height: number, + r: number, + strokeWidth: number, +): string { + const hw = strokeWidth / 2; + const er = r - hw; + return [ + `M ${hw} ${height - hw - er}`, + `V ${hw + er}`, + `A ${er} ${er} 0 0 1 ${hw + er} ${hw}`, + `H ${width - hw - er}`, + `A ${er} ${er} 0 0 1 ${width - hw} ${hw + er}`, + `V ${height - hw - er}`, + `A ${er} ${er} 0 0 1 ${width - hw - er} ${height - hw}`, + `H ${hw + er}`, + `A ${er} ${er} 0 0 1 ${hw} ${height - hw - er}`, + ].join(' '); +} + +function calcPerimeter( + width: number, + height: number, + r: number, + strokeWidth: number, +): number { + const hw = strokeWidth / 2; + const er = r - hw; + const insetW = width - 2 * hw; + const insetH = height - 2 * hw; + return 2 * (insetW - 2 * er) + 2 * (insetH - 2 * er) + 2 * Math.PI * er; +} + +interface SweepPathProps { + pathData: string; + perimeter: number; + progress: Animated.SharedValue; + strokeWidth: number; + /** Extra multiplier applied on top of the lifecycle fade */ + opacityScale: number; +} + +const SweepPath: React.FC = ({ + pathData, + perimeter, + progress, + strokeWidth, + opacityScale, +}) => { + const trailLength = perimeter * BORDER_TRAIL_FRACTION; + const gap = perimeter - trailLength; + + const animatedProps = useAnimatedProps(() => { + 'worklet'; + const p = progress.value; + const startShift = perimeter * BORDER_FADE_IN_FRACTION * 0.5; + const dashOffset = perimeter * (1 - p) + startShift; + + const fadeIn = Math.min(p / BORDER_FADE_IN_FRACTION, 1); + const fadeOut = Math.min((1 - p) / BORDER_FADE_OUT_FRACTION, 1); + const lifecycle = Math.min(fadeIn, fadeOut); + + return { + strokeDashoffset: dashOffset, + strokeOpacity: lifecycle * opacityScale, + }; + }); + + return ( + + ); +}; + +interface AnimatedGradientBorderProps { + dimensions: { width: number; height: number } | null; + /** When true the sweep animation fires once. */ + shouldAnimate: boolean; +} + +/** + * Animated border that sweeps clockwise around a rounded card once, + * fading in at the start and fading out at the end. + * A wider translucent glow layer underneath simulates a 10px layer blur. + */ +const AnimatedGradientBorder: React.FC = ({ + dimensions, + shouldAnimate, +}) => { + const progress = useSharedValue(0); + const hasAnimated = useRef(false); + + useEffect(() => { + if (!dimensions || !shouldAnimate || hasAnimated.current) return; + hasAnimated.current = true; + + progress.value = withRepeat( + withSequence( + withTiming(1, { + duration: BORDER_SWEEP_DURATION_MS, + easing: Easing.linear, + }), + withDelay(1500, withTiming(0, { duration: 0 })), + ), + 2, + ); + + return () => { + cancelAnimation(progress); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dimensions, shouldAnimate]); + + if (!dimensions) { + return null; + } + + const { width, height } = dimensions; + const perimeter = calcPerimeter( + width, + height, + BORDER_RADIUS, + BORDER_STROKE_WIDTH, + ); + const pathData = buildRoundedRectPath( + width, + height, + BORDER_RADIUS, + BORDER_STROKE_WIDTH, + ); + + return ( + + + + + + + + + + + ); +}; + +export { AnimatedGradientBorder }; +export type { AnimatedGradientBorderProps }; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx index 9dd4140aebd..a4d1a08cd53 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.test.tsx @@ -1,23 +1,47 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; +import { fireEvent, act } from '@testing-library/react-native'; import type { CaipAssetType } from '@metamask/utils'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { endTrace, TraceName } from '../../../../../util/trace'; import MarketInsightsEntryCard from './MarketInsightsEntryCard'; +jest.mock('../../../../../util/trace', () => ({ + endTrace: jest.fn(), + TraceName: { MarketInsightsEntryCardLoad: 'MarketInsightsEntryCardLoad' }, +})); + +jest.mock('../../hooks/useViewportTracking', () => ({ + useViewportTracking: jest.fn(() => ({ + ref: { current: null }, + onLayout: jest.fn(), + })), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MockAnimatedGradientBorder = jest.fn(() => null); +jest.mock('./AnimatedGradientBorder', () => ({ + AnimatedGradientBorder: (props: Record) => + MockAnimatedGradientBorder(props), +})); + +const mockReport = { + headline: 'ETH rallies on ETF optimism', + summary: 'ETF optimism and whale accumulation are driving momentum.', + trends: [{ title: 'ETF optimism' }, { title: 'whale accumulation' }], + sources: [{ name: 'CoinDesk', type: 'news', url: 'coindesk.com' }], +}; + describe('MarketInsightsEntryCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders summary text and handles press', () => { const mockPress = jest.fn(); - const report = { - headline: 'ETH rallies on ETF optimism', - summary: 'ETF optimism and whale accumulation are driving momentum.', - trends: [{ title: 'ETF optimism' }, { title: 'whale accumulation' }], - sources: [{ name: 'CoinDesk', type: 'news', url: 'coindesk.com' }], - }; - const { getByTestId, getByText } = renderWithProvider( { fireEvent.press(getByTestId('market-insights-entry-card')); expect(mockPress).toHaveBeenCalledTimes(1); }); + + it('calls endTrace when caip19Id is provided', () => { + renderWithProvider( + , + ); + + expect(endTrace).toHaveBeenCalledWith({ + name: TraceName.MarketInsightsEntryCardLoad, + id: 'eip155:1/erc20:0xtest', + }); + }); + + it('does not call endTrace when caip19Id is not provided', () => { + renderWithProvider( + , + ); + + expect(endTrace).not.toHaveBeenCalled(); + }); + + it('renders the footer disclaimer and timeAgo', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText(/5h ago/)).toBeOnTheScreen(); + }); + + it('updates card dimensions on layout and skips redundant updates', async () => { + MockAnimatedGradientBorder.mockClear(); + + const { getByTestId } = renderWithProvider( + , + ); + + const pressable = getByTestId('market-insights-entry-card'); + const innerBox = pressable.children[0] as unknown as { + props: { onLayout?: (e: unknown) => void }; + }; + + const getDimensions = () => { + const calls = MockAnimatedGradientBorder.mock.calls; + const lastCall = calls[calls.length - 1]; + return (lastCall[0] as Record).dimensions; + }; + + // Initial render has null dimensions + expect(getDimensions()).toBeNull(); + + // First layout sets dimensions + const renderCountBefore = MockAnimatedGradientBorder.mock.calls.length; + await act(() => { + innerBox.props.onLayout?.({ + nativeEvent: { layout: { width: 350, height: 200 } }, + }); + }); + expect(getDimensions()).toEqual({ width: 350, height: 200 }); + + // Same dimensions should not trigger a re-render + const renderCountAfterFirst = MockAnimatedGradientBorder.mock.calls.length; + await act(() => { + innerBox.props.onLayout?.({ + nativeEvent: { layout: { width: 350, height: 200 } }, + }); + }); + expect(MockAnimatedGradientBorder.mock.calls.length).toBe( + renderCountAfterFirst, + ); + + // Different dimensions should update + await act(() => { + innerBox.props.onLayout?.({ + nativeEvent: { layout: { width: 400, height: 250 } }, + }); + }); + expect(getDimensions()).toEqual({ width: 400, height: 250 }); + }); }); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index 500a1c5771d..192d7bdf326 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -1,27 +1,37 @@ -import React, { useEffect } from 'react'; -import { Pressable, View } from 'react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, Text, TextVariant, TextColor, - Icon, - IconName, - IconSize, IconColor, - ButtonIcon, - ButtonIconSize, BoxFlexDirection, BoxAlignItems, } from '@metamask/design-system-react-native'; +import AiSVG from '../../../../../component-library/components/Icons/Icon/assets/ai.svg'; import { strings } from '../../../../../../locales/i18n'; import type { MarketInsightsEntryCardProps } from './MarketInsightsEntryCard.types'; import { endTrace, TraceName } from '../../../../../util/trace'; +import { AnimatedGradientBorder } from './AnimatedGradientBorder'; +import { VISIBILITY_THRESHOLD } from './AnimatedGradientBorder.constants'; +import { useViewportTracking } from '../../hooks/useViewportTracking'; -const SparkleIcon: React.FC = () => ( - -); +const SPARKLE_SIZE = 20; + +const SparkleIcon: React.FC = () => { + const tw = useTailwind(); + const { color } = tw.style(IconColor.IconAlternative); + return ( + + ); +}; /** * MarketInsightsEntryCard is the entry point card shown on the token details page. @@ -35,11 +45,18 @@ const MarketInsightsEntryCard: React.FC = ({ testID, }) => { const tw = useTailwind(); + const [cardDimensions, setCardDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const [shouldAnimate, setShouldAnimate] = useState(false); + const { ref: cardRef, onLayout: onVisibilityLayout } = useViewportTracking( + () => setShouldAnimate(true), + VISIBILITY_THRESHOLD, + ); useEffect(() => { - // End the trace started by the parent (AssetOverviewContent) to measure - // how long it takes for the entry card to mount after navigation. - // caip19Id is only provided when the parent started a matching trace. if (caip19Id) { endTrace({ name: TraceName.MarketInsightsEntryCardLoad, @@ -48,61 +65,63 @@ const MarketInsightsEntryCard: React.FC = ({ } }, [caip19Id]); + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setCardDimensions((prev) => { + if (prev && prev.width === width && prev.height === height) { + return prev; + } + return { width, height }; + }); + }, + [], + ); + return ( tw.style('px-4 mt-2 mb-4', pressed && 'opacity-80') } testID={testID} > - + + + + + {report.summary} + + - - {strings('market_insights.title')} - - - - - - - - - {report.summary} - - + - - - {strings('market_insights.footer_disclaimer')} - - - {'•'} - - - {timeAgo} - - + {strings('market_insights.footer_disclaimer')} + {' • '} + {timeAgo} + diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCardOriginal.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCardOriginal.tsx new file mode 100644 index 00000000000..5f37e6337ad --- /dev/null +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCardOriginal.tsx @@ -0,0 +1,105 @@ +import React, { useEffect } from 'react'; +import { Pressable, View } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + ButtonIcon, + ButtonIconSize, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import type { MarketInsightsEntryCardProps } from './MarketInsightsEntryCard.types'; +import { endTrace, TraceName } from '../../../../../util/trace'; + +const SparkleIcon: React.FC = () => ( + +); + +/** + * MarketInsightsEntryCard is the entry point card shown on the token details page. + * Tapping navigates to the full Market Insights view. + */ +const MarketInsightsEntryCardOriginal: React.FC< + MarketInsightsEntryCardProps +> = ({ report, timeAgo, onPress, caip19Id, testID }) => { + const tw = useTailwind(); + + useEffect(() => { + if (caip19Id) { + endTrace({ + name: TraceName.MarketInsightsEntryCardLoad, + id: caip19Id, + }); + } + }, [caip19Id]); + + return ( + + tw.style('px-4 mt-2 mb-4', pressed && 'opacity-80') + } + testID={testID} + > + + + + {strings('market_insights.title')} + + + + + + + + + {report.summary} + + + + + {strings('market_insights.footer_disclaimer')} + + + {'•'} + + + {timeAgo} + + + + + + ); +}; + +export default MarketInsightsEntryCardOriginal; diff --git a/app/components/UI/MarketInsights/hooks/useViewportTracking.test.ts b/app/components/UI/MarketInsights/hooks/useViewportTracking.test.ts new file mode 100644 index 00000000000..98a38251e8c --- /dev/null +++ b/app/components/UI/MarketInsights/hooks/useViewportTracking.test.ts @@ -0,0 +1,196 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { Dimensions } from 'react-native'; +import { useViewportTracking } from './useViewportTracking'; + +jest.useFakeTimers(); + +const SCREEN_HEIGHT = Dimensions.get('window').height; + +describe('useViewportTracking', () => { + afterEach(() => { + jest.clearAllTimers(); + }); + + it('fires callback when component is fully visible on layout', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, 100); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('does not fire callback when component is off screen', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, SCREEN_HEIGHT + 500); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('does not fire when less than 50% of the component is visible', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, SCREEN_HEIGHT - 40); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('fires when at least 50% of the component is visible', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, SCREEN_HEIGHT - 60); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('fires callback when component scrolls into view via polling', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + let pageY = SCREEN_HEIGHT + 500; + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, pageY); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).not.toHaveBeenCalled(); + + pageY = 100; + act(() => { + jest.advanceTimersByTime(250); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('fires callback only once', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, 100); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + it('does not fire callback when measured height is zero', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 0, 0, 100); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('uses custom areaThreshold when provided', () => { + const onVisible = jest.fn(); + const { result } = renderHook(() => useViewportTracking(onVisible, 0.9)); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, SCREEN_HEIGHT - 60); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + // 60% visible but threshold is 90%, should not fire + expect(onVisible).not.toHaveBeenCalled(); + }); + + it('cleans up interval on unmount', () => { + const onVisible = jest.fn(); + const { result, unmount } = renderHook(() => + useViewportTracking(onVisible), + ); + + const mockMeasure = jest.fn((cb: (...args: number[]) => void) => { + cb(0, 0, 300, 100, 0, SCREEN_HEIGHT + 500); + }); + (result.current.ref as { current: unknown }).current = { + measure: mockMeasure, + }; + + act(() => { + result.current.onLayout(); + }); + + unmount(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/MarketInsights/hooks/useViewportTracking.ts b/app/components/UI/MarketInsights/hooks/useViewportTracking.ts new file mode 100644 index 00000000000..90909891836 --- /dev/null +++ b/app/components/UI/MarketInsights/hooks/useViewportTracking.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Dimensions, View } from 'react-native'; + +const POLL_INTERVAL_MS = 250; +const DEFAULT_AREA_THRESHOLD = 0.5; + +/** + * Hook that detects when a component first scrolls into the viewport. + * Attaches the returned ref to a non-collapsable native view and calls + * `onVisible` exactly once when the required fraction is visible on screen. + * + * @param onVisible - Callback fired once when visibility threshold is met. + * @param areaThreshold - Fraction of the component height that must be visible. + * @returns A ref and onLayout callback to attach to the tracked view. + */ +export const useViewportTracking = ( + onVisible: () => void, + areaThreshold = DEFAULT_AREA_THRESHOLD, +) => { + const ref = useRef(null); + const hasFired = useRef(false); + const intervalRef = useRef | null>(null); + const onVisibleRef = useRef(onVisible); + + useEffect(() => { + onVisibleRef.current = onVisible; + }, [onVisible]); + + const checkVisibility = useCallback(() => { + if (hasFired.current || !ref.current) { + return; + } + + ref.current.measure((_x, _y, _width, height, _pageX, pageY) => { + if (height === 0 || hasFired.current) { + return; + } + + const screenHeight = Dimensions.get('window').height; + const visibleTop = Math.max(pageY, 0); + const visibleBottom = Math.min(pageY + height, screenHeight); + const visibleFraction = (visibleBottom - visibleTop) / height; + + if (visibleFraction >= areaThreshold) { + hasFired.current = true; + + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + onVisibleRef.current(); + } + }); + }, [areaThreshold]); + + const onLayout = useCallback(() => { + checkVisibility(); + + if (!hasFired.current && !intervalRef.current) { + intervalRef.current = setInterval(checkVisibility, POLL_INTERVAL_MS); + } + }, [checkVisibility]); + + useEffect( + () => () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, + [], + ); + + return { ref, onLayout }; +}; diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 9d2fcbedfe1..7aa4fe889ff 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -836,6 +836,19 @@ const AssetOverviewContent: React.FC = ({ }} /> )} + {isMarketInsightsEnabled && + marketInsightsReport && + marketInsightsCaip19Id ? ( + + + + ) : null} { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && @@ -922,19 +935,6 @@ const AssetOverviewContent: React.FC = ({ /> )} - {isMarketInsightsEnabled && - marketInsightsReport && - marketInsightsCaip19Id ? ( - - - - ) : null} {showPerpsSection && !perpsPosition && marketData && ( /app/util/testUtils/', '/app/core/InpageBridgeWeb3.js', '/app/features/SampleFeature/e2e/', + '/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCardOriginal.tsx', + '/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/AnimatedGradientBorder.tsx', ], testPathIgnorePatterns: [ '.*/tests/(smoke|regression)/.*\\.spec\\.(ts|js)$', diff --git a/sonar-project.properties b/sonar-project.properties index 8c3a01a0b67..1c49d647529 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -15,7 +15,7 @@ sonar.exclusions=**.stories.**, e2e/**, tests/**, wdio/**, app/components/UI/Per sonar.test.inclusions=**.test.** # Excluded project files from coverage. -sonar.coverage.exclusions=**util/test**, **/__mocks__/**, **NetworkConnectMultiSelector.tsx, **TransactionNotification/index.js, **BrowserTab.tsx, **RPCMethodMiddleware.ts, **PermissionsSummary.tsx, **useSwitchNetworks.ts, **ethereum-chain-utils.js, **app/selectors/selectedNetworkController.ts, **useAddressBalance.ts, **AccountPermissions.tsx, **AddressFrom.tsx, **TransactionDetails**, **TransactionElement**, **networkController.ts, **util/networks/index.js, **AccountFromToInfoCard.tsx, **Engine/Engine.ts, **TransactionReview/index.js, **smart-publish-hook.ts, **AccountFromToInfoCard.types.tsx, **BrowserTab.tsx, **NetworkSelector.tsx, app/components/UI/Perps/utils/e2eBridgePerps.ts +sonar.coverage.exclusions=**util/test**, **/__mocks__/**, **NetworkConnectMultiSelector.tsx, **TransactionNotification/index.js, **BrowserTab.tsx, **RPCMethodMiddleware.ts, **PermissionsSummary.tsx, **useSwitchNetworks.ts, **ethereum-chain-utils.js, **app/selectors/selectedNetworkController.ts, **useAddressBalance.ts, **AccountPermissions.tsx, **AddressFrom.tsx, **TransactionDetails**, **TransactionElement**, **networkController.ts, **util/networks/index.js, **AccountFromToInfoCard.tsx, **Engine/Engine.ts, **TransactionReview/index.js, **smart-publish-hook.ts, **AccountFromToInfoCard.types.tsx, **BrowserTab.tsx, **NetworkSelector.tsx, app/components/UI/Perps/utils/e2eBridgePerps.ts, **/MarketInsightsEntryCardOriginal.tsx, **/AnimatedGradientBorder.tsx # Excluded project files for duplication sonar.cpd.exclusions=**SampleFeature/e2e/**, app/util/test/testSetupView.js, app/util/test/testSetup.js From d4cdad36f1bd84cffb32d72f95427a1aaa25313a Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Wed, 25 Mar 2026 20:34:36 +0100 Subject: [PATCH 11/15] feat: migrate Button (card scope) (#27752) ## **Description** Replace deprecated `Button` usage with DSRN package. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/8597813f-5f65-4225-9660-c8b939a8675d ### **After** https://github.com/user-attachments/assets/37d71232-f8d1-497a-8a5c-3effed6338c7 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches many card/onboarding screens to swap a core UI component; while mostly prop/markup changes, subtle differences in disabled/loading/accessibility behavior could affect user flows and tests. > > **Overview** > Migrates card-related screens/components from the deprecated component-library `Button` to the design-system `@metamask/design-system-react-native` `Button` (e.g., auth, home, cashback, checkout/onboarding, spending limit, message box, and dev options). > > Updates callsites to the new API (`variant` enums, `isLoading`, `isDisabled`, `isFullWidth`, and children-based labels) and adjusts tests/snapshots accordingly (including assertions using `toBeDisabled`/`toBeEnabled` and checking `accessibilityState.busy`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 66e6d97df12417d2c3044b3d4cec65cb6f509b00. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../CardAuthentication.test.tsx | 5 +- .../CardAuthentication/CardAuthentication.tsx | 31 +- .../CardAuthentication.test.tsx.snap | 99 +++-- .../UI/Card/Views/CardHome/CardHome.test.tsx | 8 +- .../UI/Card/Views/CardHome/CardHome.tsx | 59 +-- .../__snapshots__/CardHome.test.tsx.snap | 402 +++++++++++++----- .../UI/Card/Views/Cashback/Cashback.tsx | 19 +- .../ChooseYourCard/ChooseYourCard.test.tsx | 25 ++ .../Views/ChooseYourCard/ChooseYourCard.tsx | 23 +- .../OrderCompleted/OrderCompleted.test.tsx | 25 ++ .../Views/OrderCompleted/OrderCompleted.tsx | 17 +- .../Views/ReviewOrder/ReviewOrder.test.tsx | 25 ++ .../UI/Card/Views/ReviewOrder/ReviewOrder.tsx | 19 +- .../SpendingLimit/SpendingLimit.test.tsx | 43 ++ .../Views/SpendingLimit/SpendingLimit.tsx | 46 +- .../CardDeveloperOptionsSection.tsx | 23 +- .../CardMessageBox/CardMessageBox.tsx | 23 +- .../components/Onboarding/Complete.test.tsx | 39 +- .../Card/components/Onboarding/Complete.tsx | 21 +- .../Onboarding/ConfirmEmail.test.tsx | 38 ++ .../components/Onboarding/ConfirmEmail.tsx | 23 +- .../Onboarding/ConfirmPhoneNumber.test.tsx | 34 +- .../Onboarding/ConfirmPhoneNumber.tsx | 23 +- .../components/Onboarding/KYCFailed.test.tsx | 48 ++- .../Card/components/Onboarding/KYCFailed.tsx | 23 +- .../components/Onboarding/KYCPending.test.tsx | 48 ++- .../Card/components/Onboarding/KYCPending.tsx | 23 +- .../Onboarding/PersonalDetails.test.tsx | 37 ++ .../components/Onboarding/PersonalDetails.tsx | 19 +- .../Onboarding/PhysicalAddress.test.tsx | 31 +- .../components/Onboarding/PhysicalAddress.tsx | 19 +- .../Onboarding/SetPhoneNumber.test.tsx | 62 +++ .../components/Onboarding/SetPhoneNumber.tsx | 19 +- .../components/Onboarding/SignUp.test.tsx | 16 +- .../UI/Card/components/Onboarding/SignUp.tsx | 19 +- .../Onboarding/VerifyIdentity.test.tsx | 27 ++ .../components/Onboarding/VerifyIdentity.tsx | 19 +- .../__snapshots__/index.test.tsx.snap | 104 +++-- 38 files changed, 1152 insertions(+), 432 deletions(-) diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx index d14b006296e..efaf4469b44 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.test.tsx @@ -412,7 +412,8 @@ describe('CardAuthentication Component', () => { fireEvent.press(loginButton); await waitFor(() => { - expect(loginButton).toHaveProp('loading', true); + expect(loginButton).toBeDisabled(); + expect(loginButton.props.accessibilityState.busy).toBe(true); }); if (resolveLogin) { @@ -420,7 +421,7 @@ describe('CardAuthentication Component', () => { } await waitFor(() => { - expect(loginButton).toHaveProp('loading', false); + expect(loginButton.props.accessibilityState.busy).toBeFalsy(); }); }); }); diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx index bad86776e08..3e4f765083e 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx @@ -10,14 +10,11 @@ import { Icon, IconName, IconSize, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; - -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { useTheme } from '../../../../../util/theme'; import useCardProviderAuthentication from '../../hooks/useCardProviderAuthentication'; import { CardAuthenticationSelectors } from './CardAuthentication.testIds'; @@ -472,17 +469,18 @@ const CardAuthentication = () => { step === 'otp' ? ( <> { )} navigation.navigate(Routes.CARD.ONBOARDING.ROOT)} > diff --git a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap index f7d01c1e2c9..a2f83f32ef6 100644 --- a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap +++ b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap @@ -910,46 +910,91 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l ] } > - Log in - + diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 9adbd4fd34f..38e390b7ddb 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -1805,7 +1805,7 @@ describe('CardHome Component', () => { ); expect(addFundsButton).toBeTruthy(); // Button should have disabled styling applied - expect(addFundsButton.props.disabled).toBe(true); + expect(addFundsButton).toBeDisabled(); }); it('enables add funds button when swap is enabled for priority token', () => { @@ -1820,7 +1820,7 @@ describe('CardHome Component', () => { CardHomeSelectors.ADD_FUNDS_BUTTON, ); expect(addFundsButton).toBeTruthy(); - expect(addFundsButton.props.disabled).toBe(false); + expect(addFundsButton).toBeEnabled(); }); it('applies disabled styling when swap is not enabled', () => { @@ -1837,7 +1837,7 @@ describe('CardHome Component', () => { CardHomeSelectors.ADD_FUNDS_BUTTON, ); expect(addFundsButton).toBeTruthy(); - expect(addFundsButton.props.disabled).toBe(true); + expect(addFundsButton).toBeDisabled(); }); it('does not disable button when swap is enabled for priority token', async () => { @@ -1851,7 +1851,7 @@ describe('CardHome Component', () => { const addFundsButton = screen.getByTestId( CardHomeSelectors.ADD_FUNDS_BUTTON, ); - expect(addFundsButton.props.disabled).toBe(false); + expect(addFundsButton).toBeEnabled(); mockTrackEvent.mockClear(); fireEvent.press(addFundsButton); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index a9904a051a3..a4f022a2ef0 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -17,7 +17,14 @@ import { Switch, TouchableOpacity, } from 'react-native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Icon, { IconName, @@ -40,11 +47,6 @@ import { TextVariant as ComponentTextVariant } from '../../../../../component-li import Engine from '../../../../../core/Engine'; import { useTheme } from '../../../../../util/theme'; import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; import { useNavigateToCardPage } from '../../hooks/useNavigateToCardPage'; import { @@ -1017,13 +1019,14 @@ const CardHome = () => { if (!isBaanxLoginEnabled) { return ( ); } @@ -1038,44 +1041,47 @@ const CardHome = () => { return ( ); } return ( ); }, [ @@ -1277,15 +1283,16 @@ const CardHome = () => { {retries < 3 && !isAuthenticationError(cardError) && ( )} diff --git a/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap b/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap index c0851aab78c..5bdc87f63ed 100644 --- a/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap +++ b/app/components/UI/Card/Views/CardHome/__snapshots__/CardHome.test.tsx.snap @@ -944,83 +944,182 @@ exports[`CardHome Component renders correctly and matches snapshot 1`] = ` ] } > - Add funds - - + card.card_home.change_asset - + @@ -2309,83 +2408,182 @@ exports[`CardHome Component renders correctly with privacy mode enabled 1`] = ` ] } > - Add funds - - + card.card_home.change_asset - + diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx index 4b1ccf0b42a..4e875041e34 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx @@ -6,16 +6,14 @@ import { TextVariant, TextColor, Skeleton, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { ToastContext, ToastVariants, @@ -253,15 +251,16 @@ const Cashback: React.FC = () => { ); diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx index 84e7b8be547..ca7693aad7b 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx @@ -132,6 +132,8 @@ jest.mock('@metamask/design-system-react-native', () => { const React = jest.requireActual('react'); const { View, Text: RNText } = jest.requireActual('react-native'); + const { TouchableOpacity } = jest.requireActual('react-native'); + return { Box: ({ children, @@ -143,6 +145,19 @@ jest.mock('@metamask/design-system-react-native', () => { ...props }: React.PropsWithChildren>) => React.createElement(RNText, props, children), + Button: ({ + children, + onPress, + label, + isDisabled, + disabled, + ...props + }: React.PropsWithChildren>) => + React.createElement( + TouchableOpacity, + { onPress, disabled: disabled || isDisabled, ...props }, + React.createElement(RNText, {}, children || label), + ), TextVariant: { HeadingLg: 'HeadingLg', HeadingMd: 'HeadingMd', @@ -154,6 +169,16 @@ jest.mock('@metamask/design-system-react-native', () => { Medium: 'Medium', Bold: 'Bold', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx index 96e51c63162..4b5b1965d23 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx @@ -24,13 +24,11 @@ import { Text, TextVariant, FontWeight, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Icon, { IconName, IconSize, @@ -528,17 +526,16 @@ const ChooseYourCard = () => { {activeIndex === 0 && !isUpgradeFlow && ( { const React = jest.requireActual('react'); const { View, Text: RNText } = jest.requireActual('react-native'); + const { TouchableOpacity } = jest.requireActual('react-native'); + return { Box: ({ children, @@ -102,6 +104,19 @@ jest.mock('@metamask/design-system-react-native', () => { ...props }: React.PropsWithChildren>) => React.createElement(RNText, props, children), + Button: ({ + children, + onPress, + label, + isDisabled, + disabled, + ...props + }: React.PropsWithChildren>) => + React.createElement( + TouchableOpacity, + { onPress, disabled: disabled || isDisabled, ...props }, + React.createElement(RNText, {}, children || label), + ), TextVariant: { HeadingLg: 'HeadingLg', BodyMd: 'BodyMd', @@ -109,6 +124,16 @@ jest.mock('@metamask/design-system-react-native', () => { FontWeight: { Regular: 'Regular', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); diff --git a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx index ecfa2f0de66..850c4457003 100644 --- a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx +++ b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx @@ -8,13 +8,11 @@ import { Text, TextVariant, FontWeight, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -120,13 +118,14 @@ const OrderCompleted: React.FC = () => { diff --git a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.test.tsx b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.test.tsx index e2f6c7c859b..2e3af5e2486 100644 --- a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.test.tsx +++ b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.test.tsx @@ -107,6 +107,8 @@ jest.mock('@metamask/design-system-react-native', () => { const React = jest.requireActual('react'); const { View, Text: RNText } = jest.requireActual('react-native'); + const { TouchableOpacity } = jest.requireActual('react-native'); + return { Box: ({ children, @@ -118,6 +120,19 @@ jest.mock('@metamask/design-system-react-native', () => { ...props }: React.PropsWithChildren>) => React.createElement(RNText, props, children), + Button: ({ + children, + onPress, + label, + isDisabled, + disabled, + ...props + }: React.PropsWithChildren>) => + React.createElement( + TouchableOpacity, + { onPress, disabled: disabled || isDisabled, ...props }, + React.createElement(RNText, {}, children || label), + ), TextVariant: { HeadingLg: 'HeadingLg', HeadingSm: 'HeadingSm', @@ -129,6 +144,16 @@ jest.mock('@metamask/design-system-react-native', () => { Medium: 'Medium', Bold: 'Bold', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); diff --git a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx index 379062f72e4..54d6cbc7cbc 100644 --- a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx +++ b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx @@ -8,13 +8,11 @@ import { Text, TextVariant, FontWeight, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -295,14 +293,15 @@ const ReviewOrder = () => { )} diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx index a041927dbe8..1963f20dad0 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx @@ -184,6 +184,49 @@ jest.mock('../../components/AssetSelectionBottomSheet', () => ({ ]), })); +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { + View, + Text, + TouchableOpacity, + ActivityIndicator: RNActivityIndicator, + } = jest.requireActual('react-native'); + + return { + ...actual, + Box: ({ + children, + testID, + ...props + }: React.PropsWithChildren>) => + ReactActual.createElement(View, { testID, ...props }, children), + Text: ({ + children, + testID, + ...props + }: React.PropsWithChildren>) => + ReactActual.createElement(Text, { testID, ...props }, children), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + isLoading, + ...props + }: React.PropsWithChildren>) => + ReactActual.createElement( + TouchableOpacity, + { testID, onPress, disabled: isDisabled || isLoading, ...props }, + isLoading + ? ReactActual.createElement(RNActivityIndicator, {}) + : ReactActual.createElement(Text, {}, children || label), + ), + }; +}); + import React from 'react'; import { ActivityIndicator } from 'react-native'; import { fireEvent, screen, waitFor } from '@testing-library/react-native'; diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 59f0c7ad896..60ccfed244d 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -12,15 +12,13 @@ import { IconName, IconSize, IconColor, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { CardTokenAllowance, DelegationSettingsResponse, @@ -207,19 +205,21 @@ const SpendingLimit: React.FC = ({ route }) => { @@ -330,24 +330,26 @@ const SpendingLimit: React.FC = ({ route }) => { diff --git a/app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx b/app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx index 3ecdf2bfe93..c1406be9f65 100644 --- a/app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx +++ b/app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx @@ -1,17 +1,17 @@ import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box } from '@metamask/design-system-react-native'; +import { + Box, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { resetOnboardingState } from '../../../../../core/redux/slices/card'; import Text, { TextVariant, TextColor, } from '../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; const CardDeveloperOptionsSection = () => { @@ -41,15 +41,14 @@ const CardDeveloperOptionsSection = () => { )} ); }; diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx index 1586d7f1dee..eca5a97d949 100644 --- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx +++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx @@ -1,12 +1,11 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; import { FontWeight, - Text, TextVariant, + Text, + Button, + ButtonVariant, } from '@metamask/design-system-react-native'; import Icon, { IconName, @@ -107,20 +106,22 @@ const CardMessageBox = ({ > {onDismiss && ( )} {config.confirmButtonLabel && onConfirm ? ( ) : null} diff --git a/app/components/UI/Card/components/Onboarding/Complete.test.tsx b/app/components/UI/Card/components/Onboarding/Complete.test.tsx index 056c503f3d4..d573b606148 100644 --- a/app/components/UI/Card/components/Onboarding/Complete.test.tsx +++ b/app/components/UI/Card/components/Onboarding/Complete.test.tsx @@ -115,6 +115,30 @@ jest.mock('@metamask/design-system-react-native', () => { Medium: '500', Bold: '700', }, + Button: ({ + children, + testID, + onPress, + isDisabled, + ...props + }: React.PropsWithChildren>) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return React.createElement( + TouchableOpacity, + { testID, onPress, disabled: isDisabled, ...props }, + React.createElement(Text, {}, children), + ); + }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); @@ -286,9 +310,8 @@ describe('Complete Component', () => { }); it('displays the correct button text', () => { - const { getByTestId } = render(); - const buttonLabel = getByTestId('button-label'); - expect(buttonLabel.props.children).toBe('Continue'); + const { getByText } = render(); + expect(getByText('Continue')).toBeTruthy(); }); it('is not disabled', () => { @@ -447,10 +470,9 @@ describe('Complete Component', () => { }); it('renders translated button label', () => { - const { getByTestId } = render(); + const { getByText } = render(); - const buttonLabel = getByTestId('button-label'); - expect(buttonLabel.props.children).toBe('Continue'); + expect(getByText('Continue')).toBeTruthy(); }); }); @@ -480,10 +502,9 @@ describe('Complete Component', () => { }); it('renders button with correct label', () => { - const { getByTestId } = render(); + const { getByText } = render(); - const buttonLabel = getByTestId('button-label'); - expect(buttonLabel.props.children).toBe('Continue'); + expect(getByText('Continue')).toBeTruthy(); }); }); diff --git a/app/components/UI/Card/components/Onboarding/Complete.tsx b/app/components/UI/Card/components/Onboarding/Complete.tsx index 08248fe0d50..00ff772238f 100644 --- a/app/components/UI/Card/components/Onboarding/Complete.tsx +++ b/app/components/UI/Card/components/Onboarding/Complete.tsx @@ -8,11 +8,6 @@ import { } from '@react-navigation/native'; import OnboardingStep from './OnboardingStep'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Routes from '../../../../../constants/navigation/Routes'; import { resetOnboardingState } from '../../../../../core/redux/slices/card'; import { useDispatch } from 'react-redux'; @@ -28,6 +23,9 @@ import { FontWeight, Text, TextVariant, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -140,15 +138,16 @@ const Complete = () => { const renderActions = () => ( ); return ( diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx index 993cfae872d..af239f01415 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx @@ -96,9 +96,47 @@ jest.mock('@metamask/design-system-react-native', () => { }, children, ), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + disabled, + ...props + }: { + children?: React.ReactNode; + testID?: string; + onPress?: () => void; + label?: string; + isDisabled?: boolean; + disabled?: boolean; + [key: string]: unknown; + }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return React.createElement( + TouchableOpacity, + { testID, onPress, disabled: disabled || isDisabled, ...props }, + React.createElement( + Text, + { testID: 'button-label' }, + children || label, + ), + ); + }, TextVariant: { BodyLg: 'BodyLg', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx index 8d58d1356d9..14ba9e3a258 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useState, useEffect } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import Button, { +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -320,15 +322,16 @@ const ConfirmEmail = () => { const renderActions = () => ( { TextVariant: { BodyLg: 'BodyLg', }, + Button: ({ + children, + testID, + onPress, + isDisabled, + ...props + }: { + children?: React.ReactNode; + testID?: string; + onPress?: () => void; + isDisabled?: boolean; + [key: string]: unknown; + }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return React.createElement( + TouchableOpacity, + { testID, onPress, disabled: isDisabled, ...props }, + React.createElement(Text, {}, children), + ); + }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); @@ -443,7 +473,7 @@ describe('ConfirmPhoneNumber Component', () => { describe('Continue Button', () => { it('should render continue button', () => { const store = createTestStore(); - const { getByTestId } = render( + const { getByTestId, getByText } = render( , @@ -451,7 +481,7 @@ describe('ConfirmPhoneNumber Component', () => { const button = getByTestId('confirm-phone-number-continue-button'); expect(button).toBeTruthy(); - expect(getByTestId('button-label')).toHaveTextContent('Continue'); + expect(getByText('Continue')).toBeTruthy(); }); it('should be disabled when confirmation code is empty', () => { diff --git a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx index 61017efc141..faa04bb31b4 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx @@ -1,12 +1,14 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigation } from '@react-navigation/native'; import { Platform, TextInputProps } from 'react-native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import Button, { +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -308,15 +310,16 @@ const ConfirmPhoneNumber = () => { const renderActions = () => ( ); return ( diff --git a/app/components/UI/Card/components/Onboarding/KYCFailed.test.tsx b/app/components/UI/Card/components/Onboarding/KYCFailed.test.tsx index 900a594cf7d..ffdf559e4b6 100644 --- a/app/components/UI/Card/components/Onboarding/KYCFailed.test.tsx +++ b/app/components/UI/Card/components/Onboarding/KYCFailed.test.tsx @@ -56,10 +56,43 @@ jest.mock('@metamask/design-system-react-native', () => { children?: React.ReactNode; testID?: string; }) => ReactActual.createElement(Text, { testID, ...props }, children), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + disabled, + ...props + }: { + children?: React.ReactNode; + testID?: string; + onPress?: () => void; + label?: string; + isDisabled?: boolean; + disabled?: boolean; + }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return ReactActual.createElement( + TouchableOpacity, + { testID, onPress, disabled: disabled || isDisabled, ...props }, + ReactActual.createElement(Text, {}, children || label), + ); + }, TextVariant: { HeadingLg: 'HeadingLg', BodyMd: 'BodyMd', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); @@ -290,12 +323,11 @@ describe('KYCFailed Component', () => { }); it('displays the correct button label', () => { - const { getByTestId } = render(); + const { getByTestId, getByText } = render(); - const buttonLabel = getByTestId('kyc-failed-close-button-label'); - - expect(buttonLabel).toBeTruthy(); - expect(buttonLabel.props.children).toBe('Back to home'); + const button = getByTestId('kyc-failed-close-button'); + expect(button).toBeTruthy(); + expect(getByText('Back to home')).toBeTruthy(); }); it('navigates to wallet home when close button is pressed', () => { @@ -407,11 +439,9 @@ describe('KYCFailed Component', () => { }); it('uses correct i18n key for close button', () => { - const { getByTestId } = render(); - - const buttonLabel = getByTestId('kyc-failed-close-button-label'); + const { getByText } = render(); - expect(buttonLabel.props.children).toBe('Back to home'); + expect(getByText('Back to home')).toBeTruthy(); }); }); diff --git a/app/components/UI/Card/components/Onboarding/KYCFailed.tsx b/app/components/UI/Card/components/Onboarding/KYCFailed.tsx index cfe55e0d98a..9077c3c69cd 100644 --- a/app/components/UI/Card/components/Onboarding/KYCFailed.tsx +++ b/app/components/UI/Card/components/Onboarding/KYCFailed.tsx @@ -12,11 +12,6 @@ import { useDispatch } from 'react-redux'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import ButtonIcon, { ButtonIconSizes, } from '../../../../../component-library/components/Buttons/ButtonIcon'; @@ -26,7 +21,14 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardScreens } from '../../util/metrics'; import MM_CARD_ONBOARDING_FAILED from '../../../../../images/mm-card-onboarding-failed.png'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { brandColor } from '@metamask/design-tokens'; import { colors as importedColors } from '../../../../../styles/common'; import { resetOnboardingState } from '../../../../../core/redux/slices/card'; @@ -154,13 +156,14 @@ const KYCFailed = () => { > diff --git a/app/components/UI/Card/components/Onboarding/KYCPending.test.tsx b/app/components/UI/Card/components/Onboarding/KYCPending.test.tsx index 49a69661644..4493fe17814 100644 --- a/app/components/UI/Card/components/Onboarding/KYCPending.test.tsx +++ b/app/components/UI/Card/components/Onboarding/KYCPending.test.tsx @@ -42,10 +42,43 @@ jest.mock('@metamask/design-system-react-native', () => { children?: React.ReactNode; testID?: string; }) => ReactActual.createElement(Text, { testID, ...props }, children), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + disabled, + ...props + }: { + children?: React.ReactNode; + testID?: string; + onPress?: () => void; + label?: string; + isDisabled?: boolean; + disabled?: boolean; + }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return ReactActual.createElement( + TouchableOpacity, + { testID, onPress, disabled: disabled || isDisabled, ...props }, + ReactActual.createElement(Text, {}, children || label), + ); + }, TextVariant: { HeadingLg: 'HeadingLg', BodyMd: 'BodyMd', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, }; }); @@ -285,12 +318,11 @@ describe('KYCPending Component', () => { }); it('displays the correct button label', () => { - const { getByTestId } = render(); - - const buttonLabel = getByTestId('kyc-pending-got-it-button-label'); + const { getByTestId, getByText } = render(); - expect(buttonLabel).toBeTruthy(); - expect(buttonLabel.props.children).toBe('Got it'); + const button = getByTestId('kyc-pending-got-it-button'); + expect(button).toBeTruthy(); + expect(getByText('Got it')).toBeTruthy(); }); it('navigates to wallet home when Got it button is pressed', () => { @@ -388,11 +420,9 @@ describe('KYCPending Component', () => { }); it('uses correct i18n key for Got it button', () => { - const { getByTestId } = render(); - - const buttonLabel = getByTestId('kyc-pending-got-it-button-label'); + const { getByText } = render(); - expect(buttonLabel.props.children).toBe('Got it'); + expect(getByText('Got it')).toBeTruthy(); }); }); diff --git a/app/components/UI/Card/components/Onboarding/KYCPending.tsx b/app/components/UI/Card/components/Onboarding/KYCPending.tsx index 8b27b973fd2..2eba18074da 100644 --- a/app/components/UI/Card/components/Onboarding/KYCPending.tsx +++ b/app/components/UI/Card/components/Onboarding/KYCPending.tsx @@ -4,11 +4,6 @@ import { useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import ButtonIcon, { ButtonIconSizes, } from '../../../../../component-library/components/Buttons/ButtonIcon'; @@ -18,7 +13,14 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardScreens } from '../../util/metrics'; import WaitingKYCImage from '../../../../../images/waiting-kyc-card.png'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { colors as importedColors } from '../../../../../styles/common'; // Threshold for small screen adjustments @@ -115,13 +117,14 @@ const KYCPending = () => { {strings('card.card_onboarding.kyc_pending.footer_text')} diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx index 92b827ced6d..f20ba1573cf 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx @@ -56,6 +56,20 @@ jest.mock('@metamask/design-system-react-native', () => { Lg: 'lg', }; + const { TouchableOpacity } = jest.requireActual('react-native'); + + const ButtonVariant = { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }; + + const ButtonSize = { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }; + return { Box: ({ children, @@ -83,9 +97,32 @@ jest.mock('@metamask/design-system-react-native', () => { children: React.ReactNode; testID?: string; }) => React.createElement(Text, { testID, ...props }, children), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + disabled, + ...props + }: { + children?: React.ReactNode; + testID?: string; + onPress?: () => void; + label?: string; + isDisabled?: boolean; + disabled?: boolean; + }) => + React.createElement( + TouchableOpacity, + { testID, onPress, disabled: disabled || isDisabled, ...props }, + React.createElement(Text, {}, children || label), + ), TextVariant, IconName, IconSize, + ButtonVariant, + ButtonSize, }; }); diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx index fe37e20b4bd..2a3d571b29e 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx @@ -5,12 +5,10 @@ import { Label, Text, TextVariant, -} from '@metamask/design-system-react-native'; -import Button, { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -448,15 +446,16 @@ const PersonalDetails = () => { )} ); diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx index 4994ea3cd87..20114611a4e 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx @@ -155,15 +155,41 @@ jest.mock('@metamask/design-system-react-native', () => { }: React.PropsWithChildren>) => React.createElement(RNText, props, children); + const { TouchableOpacity } = jest.requireActual('react-native'); + + const Button = ({ + children, + testID, + onPress, + isDisabled, + ...props + }: React.PropsWithChildren>) => + React.createElement( + TouchableOpacity, + { testID, onPress, disabled: isDisabled, ...props }, + React.createElement(RNText, {}, children), + ); + return { Box, Label, Text, Icon, + Button, TextVariant: { BodySm: 'BodySm', BodyMd: 'BodyMd', }, + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, IconName: { ArrowDown: 'arrow-down', }, @@ -1786,7 +1812,7 @@ describe('PhysicalAddress Component', () => { }); it('displays translated text correctly', () => { - const { getByTestId } = render( + const { getByTestId, getByText } = render( , @@ -1794,13 +1820,12 @@ describe('PhysicalAddress Component', () => { const title = getByTestId('onboarding-step-title'); const description = getByTestId('onboarding-step-description'); - const buttonText = getByTestId('button-text'); expect(title.props.children).toBe('Physical Address'); expect(description.props.children).toBe( 'Enter your physical address information', ); - expect(buttonText.props.children).toBe('Continue'); + expect(getByText('Continue')).toBeTruthy(); }); }); diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx index 1376f9dd570..b5e0bd84f4b 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx @@ -11,12 +11,10 @@ import { Label, Text, TextVariant, -} from '@metamask/design-system-react-native'; -import Button, { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -804,15 +802,16 @@ const PhysicalAddress = () => { ) : null} ); diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx index 83a012d51ec..953c79fe050 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx @@ -55,6 +55,68 @@ jest.mock('./RegionSelectorModal', () => ({ import { useCardSDK } from '../../sdk'; +jest.mock('@metamask/design-system-react-native', () => { + const React = jest.requireActual('react'); + const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); + + return { + Box: ({ + children, + testID, + ...props + }: React.PropsWithChildren>) => + React.createElement(View, { testID, ...props }, children), + Text: ({ + children, + testID, + ...props + }: React.PropsWithChildren>) => + React.createElement(Text, { testID, ...props }, children), + Button: ({ + children, + testID, + onPress, + label, + isDisabled, + ...props + }: React.PropsWithChildren>) => + React.createElement( + TouchableOpacity, + { testID, onPress, disabled: isDisabled, ...props }, + React.createElement(Text, {}, children || label), + ), + ButtonVariant: { + Primary: 'Primary', + Secondary: 'Secondary', + Link: 'Link', + }, + ButtonSize: { + Sm: 'Sm', + Md: 'Md', + Lg: 'Lg', + }, + TextVariant: { + BodySm: 'BodySm', + BodyMd: 'BodyMd', + HeadingMd: 'HeadingMd', + }, + Icon: ({ ...props }: Record) => + React.createElement(View, props), + IconName: { + ArrowDown: 'arrow-down', + }, + IconSize: { + Sm: 'sm', + Md: 'md', + }, + Label: ({ + children, + ...props + }: React.PropsWithChildren>) => + React.createElement(Text, props, children), + }; +}); + // Mock OnboardingStep jest.mock('./OnboardingStep', () => { const React = jest.requireActual('react'); diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx index 68bd8541732..bf7779ff5f5 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx @@ -11,12 +11,10 @@ import { Label, Text, TextVariant, -} from '@metamask/design-system-react-native'; -import Button, { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -297,15 +295,16 @@ const SetPhoneNumber = () => { const renderActions = () => ( { ); const continueButton = getByTestId('signup-continue-button'); - expect(continueButton.props.disabled).toBe(true); + expect(continueButton).toBeDisabled(); }); it('does not show error messages initially', () => { @@ -460,7 +460,7 @@ describe('SignUp Component', () => { expect(queryByText('United Kingdom')).toBeNull(); // Continue button must remain disabled — no eligible country was selected - expect(getByTestId('signup-continue-button').props.disabled).toBe(true); + expect(getByTestId('signup-continue-button')).toBeDisabled(); expect(storeWithGB.getState().card.userCardLocation).toBe( 'international', ); @@ -533,7 +533,7 @@ describe('SignUp Component', () => { // Now check if the continue button is enabled await waitFor( () => { - expect(continueButton.props.disabled).toBe(false); + expect(continueButton).toBeEnabled(); }, { timeout: 3000 }, ); @@ -559,7 +559,7 @@ describe('SignUp Component', () => { }); await waitFor(() => { - expect(continueButton.props.disabled).toBe(true); + expect(continueButton).toBeDisabled(); }); }); @@ -590,7 +590,7 @@ describe('SignUp Component', () => { }); await waitFor(() => { - expect(continueButton.props.disabled).toBe(true); + expect(continueButton).toBeDisabled(); }); }); @@ -612,7 +612,7 @@ describe('SignUp Component', () => { }); await waitFor(() => { - expect(continueButton.props.disabled).toBe(true); + expect(continueButton).toBeDisabled(); }); }); }); @@ -637,7 +637,7 @@ describe('SignUp Component', () => { }); await waitFor(() => { - expect(continueButton.props.disabled).toBe(false); + expect(continueButton).toBeEnabled(); }); await act(async () => { @@ -666,7 +666,7 @@ describe('SignUp Component', () => { }); await waitFor(() => { - expect(continueButton.props.disabled).toBe(false); + expect(continueButton).toBeEnabled(); }); await act(async () => { diff --git a/app/components/UI/Card/components/Onboarding/SignUp.tsx b/app/components/UI/Card/components/Onboarding/SignUp.tsx index 66a55b2fe04..8413207e385 100644 --- a/app/components/UI/Card/components/Onboarding/SignUp.tsx +++ b/app/components/UI/Card/components/Onboarding/SignUp.tsx @@ -15,12 +15,10 @@ import { IconSize, IconName, Label, -} from '@metamask/design-system-react-native'; -import Button, { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import TextField from '../../../../../component-library/components/Form/TextField'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; @@ -350,15 +348,16 @@ const SignUp = () => { const renderActions = () => ( <> navigation.navigate(Routes.CARD.AUTHENTICATION)} > diff --git a/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx b/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx index 36ec6b1e498..848ad00b87c 100644 --- a/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx +++ b/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx @@ -144,6 +144,33 @@ jest.mock('@metamask/design-system-react-native', () => { IconColor: { IconAlternative: 'IconAlternative', }, + Button: ({ + children, + label, + onPress, + isDisabled, + isFullWidth, + ...props + }: React.PropsWithChildren>) => { + const { TouchableOpacity, Text: RNText } = + jest.requireActual('react-native'); + return React.createElement( + TouchableOpacity, + { + ...props, + testID: 'verify-identity-continue-button', + onPress, + disabled: isDisabled, + }, + React.createElement(RNText, null, label || children), + ); + }, + ButtonVariant: { + Primary: 'Primary', + }, + ButtonSize: { + Lg: 'Lg', + }, }; }); diff --git a/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx b/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx index 63314b3ce43..1d94548e8c1 100644 --- a/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx +++ b/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx @@ -12,12 +12,10 @@ import { IconSize, Text, TextVariant, -} from '@metamask/design-system-react-native'; -import Button, { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import Routes from '../../../../../constants/navigation/Routes'; import useStartVerification from '../../hooks/useStartVerification'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -191,15 +189,16 @@ const VerifyIdentity = () => { const renderActions = () => ( ); return ( Reset Card onboarding state to start the onboarding flow from the beginning. - Reset Onboarding State - + From 8077384b213655f5e01f9cf624f88196b3364c2c Mon Sep 17 00:00:00 2001 From: jiexi Date: Wed, 25 Mar 2026 14:18:37 -0700 Subject: [PATCH 12/15] fix: Fix redux store event handler leak in WCv2 (#27932) ## **Description** Fixes an issue where after a WCv2 connection could still send chainChanged events to the WC relay when the connection was removed. This was happening regardless of if the connection was removed on the wallet side or signaled removed from the WC side. This PR fixes that by properly unsubscribing from the store. ## **Changelog** CHANGELOG entry: null Not user facing. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WAPI-1355 ## **Manual testing steps** 1. Open app in iOS expo build 2. Open js debugger 3. add breakpoint to onStoreChange in WalletConnect2Session.ts 4. Connect a new WC session via [demo app](https://react-app.walletconnect.com/) 5. You should see onStoreChange fire 6. Visit settings, experimental, and disconnect this session (long tap) 7. You should not see onStoreChange fire anymore 8. reconnect to demo dapp 9. You should see onStoreChange fire 10. disconnect from the demo dapp 11. you should not see onStoreChange fire anymore Visit https://react-app.walletconnect.com/ in the native browser and smoke test ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches WalletConnect v2 session lifecycle/cleanup, so mistakes could leave sessions partially disconnected or change disconnect timing. Logic is straightforward and covered by updated unit tests, limiting risk. > > **Overview** > Fixes a WalletConnect v2 listener leak by storing the Redux `store.subscribe` unsubscribe in `WalletConnect2Session` and invoking it during `removeListeners()`. > > Updates `WC2Manager.removeAll()` to **tear down every `WalletConnect2Session` via `removeListeners()` before clearing the sessions map**, preventing orphaned subscriptions/bridges when `session_delete` fires after local state is cleared. Tests were extended to assert the unsubscribe is called and `removeAll()` invokes `removeListeners()` for each session. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7633be54c99f400635d667c7e36fe4c5e3e5e965. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../WalletConnect/WalletConnect2Session.test.ts | 5 +++++ app/core/WalletConnect/WalletConnect2Session.ts | 5 ++++- app/core/WalletConnect/WalletConnectV2.test.ts | 17 +++++++++++++++++ app/core/WalletConnect/WalletConnectV2.ts | 12 +++++++++--- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/core/WalletConnect/WalletConnect2Session.test.ts b/app/core/WalletConnect/WalletConnect2Session.test.ts index b1aad8c38aa..3950cbe951e 100644 --- a/app/core/WalletConnect/WalletConnect2Session.test.ts +++ b/app/core/WalletConnect/WalletConnect2Session.test.ts @@ -219,6 +219,7 @@ describe('WalletConnect2Session', () => { let mockClient: IWalletKit; let mockSession: SessionTypes.Struct; let mockNavigation: NavigationContainerRef; + let mockStoreUnsubscribe: jest.Mock; const testChainId = '0x89'; const testNetworkClientId = `test-network-${parseInt(testChainId, 16)}`; @@ -296,6 +297,9 @@ describe('WalletConnect2Session', () => { }, }); + mockStoreUnsubscribe = jest.fn(); + (store.subscribe as jest.Mock).mockReturnValue(mockStoreUnsubscribe); + session = new WalletConnect2Session({ web3Wallet: mockClient, session: mockSession, @@ -438,6 +442,7 @@ describe('WalletConnect2Session', () => { await session.removeListeners(); + expect(mockStoreUnsubscribe).toHaveBeenCalled(); expect(mockOnDisconnect).toHaveBeenCalled(); }); diff --git a/app/core/WalletConnect/WalletConnect2Session.ts b/app/core/WalletConnect/WalletConnect2Session.ts index 5466d009dc5..2a8a155af84 100644 --- a/app/core/WalletConnect/WalletConnect2Session.ts +++ b/app/core/WalletConnect/WalletConnect2Session.ts @@ -72,6 +72,7 @@ class WalletConnect2Session { private lastChainId: Hex; private isHandlingChainChange = false; private _isHandlingRequest = false; + private storeUnsubscribe: (() => void) | null = null; public session: SessionTypes.Struct; @@ -164,7 +165,7 @@ class WalletConnect2Session { this.checkPendingRequests(); this.lastChainId = this.getCurrentChainId(); // Subscribe to store changes to detect chain switches - store.subscribe(this.onStoreChange.bind(this)); + this.storeUnsubscribe = store.subscribe(this.onStoreChange.bind(this)); } /** @@ -791,6 +792,8 @@ class WalletConnect2Session { }; removeListeners = async () => { + this.storeUnsubscribe?.(); + this.storeUnsubscribe = null; this.backgroundBridge.onDisconnect(); }; diff --git a/app/core/WalletConnect/WalletConnectV2.test.ts b/app/core/WalletConnect/WalletConnectV2.test.ts index e2ca4919cc1..8f4bfba5c50 100644 --- a/app/core/WalletConnect/WalletConnectV2.test.ts +++ b/app/core/WalletConnect/WalletConnectV2.test.ts @@ -735,6 +735,23 @@ describe('WC2Manager', () => { JSON.stringify({}), ); }); + + it('calls removeListeners on each WalletConnect2Session before clearing local sessions', async () => { + const removeListenersA = jest.fn().mockResolvedValue(undefined); + const removeListenersB = jest.fn().mockResolvedValue(undefined); + (manager as unknown as { sessions: Record }).sessions = { + 'topic-a': { removeListeners: removeListenersA }, + 'topic-b': { removeListeners: removeListenersB }, + }; + + await manager.removeAll(); + + expect(removeListenersA).toHaveBeenCalledTimes(1); + expect(removeListenersB).toHaveBeenCalledTimes(1); + expect( + (manager as unknown as { sessions: Record }).sessions, + ).toEqual({}); + }); }); describe('WC2Manager isWalletConnect', () => { diff --git a/app/core/WalletConnect/WalletConnectV2.ts b/app/core/WalletConnect/WalletConnectV2.ts index a5b3e88546e..ff4494fe637 100644 --- a/app/core/WalletConnect/WalletConnectV2.ts +++ b/app/core/WalletConnect/WalletConnectV2.ts @@ -382,6 +382,15 @@ export class WC2Manager { public async removeAll() { this.deeplinkSessions = {}; + // Tear down WalletConnect2Session (Redux subscription, BackgroundBridge) + // before clearing the map. Otherwise session_delete may run after this.sessions + // is empty and skip removeListeners(), leaking listeners and relay churn. + const wcSessions = Object.values(this.sessions); + await Promise.allSettled( + wcSessions.map((wcSession) => wcSession.removeListeners()), + ); + this.sessions = {}; + const actives = this.web3Wallet.getActiveSessions() || {}; Object.values(actives).forEach(async (session) => { this.web3Wallet @@ -394,9 +403,6 @@ export class WC2Manager { }); }); - // Clear local sessions - this.sessions = {}; - await StorageWrapper.setItem( AppConstants.WALLET_CONNECT.DEEPLINK_SESSIONS, JSON.stringify(this.deeplinkSessions), From bcf7f718251b128cce80d50951c919290d12db91 Mon Sep 17 00:00:00 2001 From: Christian Montoya Date: Wed, 25 Mar 2026 17:33:14 -0400 Subject: [PATCH 13/15] feat: Update musd calculator (#27949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR provides some minor fixes and improvements to the mUSD calculator: - Guard against / set default value for a variable - Use swap navigation instead of deeplink for reduced friction - Move the disclaimer to the bottom of the page content ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: n/a ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - iPhone 16e -
2026-03-25 at 16 09 28 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk because it changes the mUSD swap action from a deeplink to in-app swap navigation and adjusts rewards state rehydration/selectors to tolerate missing campaign status data, which could affect routing and campaign UI behavior if misconfigured. > > **Overview** > Updates the mUSD calculator header to use a new localized `rewards.musd.page_title` string and tweaks layout by moving the disclaimer to the bottom with updated styling. > > Replaces the Swap button’s deeplink (`link.metamask.io/swap`) with in-app swap navigation via `useSwapBridgeNavigation`, preconfiguring ETH→mUSD tokens, and updates tests accordingly. > > Hardens Rewards redux state handling by defaulting `campaignParticipantStatuses` to `{}` on rehydrate and making related selectors null-safe, preventing crashes when the field is missing from persisted payloads. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fc1814e8e2f69ff7f74886c91f33650f194d53de. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Rewards/Views/MusdCalculatorView.tsx | 2 +- .../MusdCalculatorTab.test.tsx | 18 +++--- .../MusdCalculatorTab/MusdCalculatorTab.tsx | 59 ++++++++++++++----- app/reducers/rewards/index.ts | 2 +- app/reducers/rewards/selectors.ts | 4 +- locales/languages/de.json | 1 + locales/languages/el.json | 1 + locales/languages/en.json | 1 + locales/languages/es.json | 1 + locales/languages/fr.json | 1 + locales/languages/hi.json | 1 + locales/languages/id.json | 1 + locales/languages/ja.json | 1 + locales/languages/ko.json | 1 + locales/languages/pt.json | 1 + locales/languages/ru.json | 1 + locales/languages/tl.json | 1 + locales/languages/tr.json | 1 + locales/languages/vi.json | 1 + locales/languages/zh.json | 1 + 20 files changed, 73 insertions(+), 27 deletions(-) diff --git a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx index 5e0b0f81b94..6e30168089f 100644 --- a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx +++ b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx @@ -18,7 +18,7 @@ const MusdCalculatorView: React.FC = () => { style={tw.style('flex-1 bg-default')} > navigation.goBack()} /> diff --git a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx index 83871368d6a..9f1527d2203 100644 --- a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; import MusdCalculatorTab from './MusdCalculatorTab'; +const mockGoToSwaps = jest.fn(); + jest.mock('react-redux', () => ({ useSelector: jest.fn(() => 'usd'), + useDispatch: jest.fn(() => jest.fn()), })); jest.mock('../../../../../../../locales/i18n', () => ({ @@ -14,6 +17,11 @@ jest.mock('../../../../../../core/DeeplinkManager', () => ({ handleDeeplink: jest.fn(), })); +jest.mock('../../../../Bridge/hooks/useSwapBridgeNavigation', () => ({ + useSwapBridgeNavigation: () => ({ goToSwaps: mockGoToSwaps }), + SwapBridgeNavigationLocation: { Rewards: 'Rewards' }, +})); + jest.mock('../../../../../../util/theme', () => { const { mockTheme } = jest.requireActual('../../../../../../util/theme'); return { @@ -54,17 +62,11 @@ describe('MusdCalculatorTab', () => { }); }); - it('calls handleDeeplink with swap URL when Swap button is pressed', () => { - const { handleDeeplink } = jest.requireMock( - '../../../../../../core/DeeplinkManager', - ); - + it('navigates to swap screen when Swap button is pressed', () => { const { getByText } = render(); fireEvent.press(getByText('rewards.musd.swap_button')); - expect(handleDeeplink).toHaveBeenCalledWith({ - uri: expect.stringContaining('link.metamask.io/swap'), - }); + expect(mockGoToSwaps).toHaveBeenCalled(); }); it('updates input value when amount changes', () => { diff --git a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx index 0e7ffe98c4b..eed24d9240a 100644 --- a/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx @@ -2,10 +2,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { ScrollView } from 'react-native'; import { Box, - Icon, - IconName, - IconSize, Text, + TextColor, TextVariant, Button, ButtonVariant, @@ -18,17 +16,40 @@ import { KeyValueRowStubs } from '../../../../../../component-library/components import { handleDeeplink } from '../../../../../../core/DeeplinkManager'; import useFiatFormatter from '../../../../SimulationDetails/FiatDisplay/useFiatFormatter'; import { BigNumber } from 'bignumber.js'; +import { EthScope } from '@metamask/keyring-api'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { + SwapBridgeNavigationLocation, + useSwapBridgeNavigation, +} from '../../../../Bridge/hooks/useSwapBridgeNavigation'; +import { BridgeToken } from '../../../../Bridge/types'; +import { + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS, +} from '../../../../Earn/constants/musd'; +import { getNativeSourceToken } from '../../../../Bridge/utils/tokenUtils'; const ANNUAL_BONUS_RATE = 0.03; const BUY_MUSD_URL = 'https://link.metamask.io/buy?address=0xaca92e438df0b2401ff60da7e4337b687a2435da&amount=100&chainid=1&sig_params=address%2Camount%2Cchainid%2Cutm_source&utm_source=rewards&sig=SdHOoh_QvT1bs8B6g-qCyLH5mUEczYzeOfAv9SNRm4CKjR6uBnUp4e1-Vcojb39fWWScBrui2GLftNlJKQlrAQ'; -const SWAP_MUSD_URL = - 'https://link.metamask.io/swap?from=eip155%3A1%2Fslip44%3A60&sig_params=from%2Cto%2Cutm_source&to=eip155%3A1%2Ferc20%3A0xacA92E438df0B2401fF60dA7E4337B687a2435DA&utm_source=rewards&sig=mCsMuZB-omwEg9WvGOwA8nuc8NvXj7uFfC_Pn-6Nmwlce2GF356tbZbHIgzYHWhmLb4kvUKMTpj4eb0yUrle1Q'; + +const MUSD_DEST_TOKEN: BridgeToken = { + address: MUSD_TOKEN_ADDRESS, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + chainId: '0x1', +}; const MusdCalculatorTab: React.FC = () => { const tw = useTailwind(); const [musdAmount, setMusdAmount] = useState('1000'); + const ethSourceToken = useMemo( + () => getNativeSourceToken(EthScope.Mainnet), + [], + ); + const musdCalculations = useMemo(() => { const amount = parseFloat(musdAmount) || 0; const annualizedBonus = amount * ANNUAL_BONUS_RATE; @@ -59,9 +80,16 @@ const MusdCalculatorTab: React.FC = () => { handleDeeplink({ uri: BUY_MUSD_URL }); }, []); + const { goToSwaps } = useSwapBridgeNavigation({ + location: SwapBridgeNavigationLocation.Rewards, + sourcePage: Routes.REWARDS_DASHBOARD, + sourceToken: ethSourceToken, + destToken: MUSD_DEST_TOKEN, + }); + const handleSwapMusd = useCallback(() => { - handleDeeplink({ uri: SWAP_MUSD_URL }); - }, []); + goToSwaps(); + }, [goToSwaps]); return ( { - {/* Disclaimer */} - - - - {strings('rewards.musd.disclaimer')} - - - {/* Action Buttons */} + + {/* Disclaimer */} + + {strings('rewards.musd.disclaimer')} + ); }; diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 17eeaef24a0..ea41e754443 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -688,7 +688,7 @@ const rewardsSlice = createSlice({ unlockedRewards: action.payload.rewards.unlockedRewards, campaigns: action.payload.rewards.campaigns, campaignParticipantStatuses: - action.payload.rewards.campaignParticipantStatuses, + action.payload.rewards.campaignParticipantStatuses ?? {}, ondoCampaignLeaderboardPositions: action.payload.rewards.ondoCampaignLeaderboardPositions ?? {}, hideUnlinkedAccountsBanner: diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index fc23ae09d95..d81ad9d286d 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -172,13 +172,13 @@ export const selectCampaignParticipantStatuses = (state: RootState) => export const selectCampaignParticipantStatusById = (campaignId: string | undefined) => (state: RootState) => campaignId - ? (state.rewards.campaignParticipantStatuses[campaignId] ?? null) + ? (state.rewards.campaignParticipantStatuses?.[campaignId] ?? null) : null; export const selectCampaignParticipantCount = (campaignId: string | undefined) => (state: RootState) => campaignId - ? (state.rewards.campaignParticipantStatuses[campaignId] + ? (state.rewards.campaignParticipantStatuses?.[campaignId] ?.participantCount ?? null) : null; diff --git a/locales/languages/de.json b/locales/languages/de.json index 9310e98c947..c831716c9ff 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Aktive Boosts", "season_1": "Saison 1", "musd": { + "page_title": "mUSD", "title": "mUSD-Bonus-Rechner", "description": "Finden Sie heraus, wie viel Sie durch die Konvertierung Ihrer Stablecoins in mUSD verdienen können.", "amount_label": "Konvertierter Betrag", diff --git a/locales/languages/el.json b/locales/languages/el.json index 9fc2fc4e2cc..f8360590771 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Ενεργές ενισχύσεις", "season_1": "Περίοδος 1", "musd": { + "page_title": "mUSD", "title": "Εκτίμηση του μπόνους σε mUSD", "description": "Δείτε πόσα μπορείτε να κερδίσετε μετατρέποντας τα stablecoins σας σε mUSD.", "amount_label": "Το ποσό μετατράπηκε", diff --git a/locales/languages/en.json b/locales/languages/en.json index f294acd91bc..83658c5794f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7878,6 +7878,7 @@ "active_boosts_title": "Active boosts", "season_1": "Season 1", "musd": { + "page_title": "mUSD", "title": "mUSD bonus calculator", "description": "See how much you could earn by converting your stablecoins to mUSD.", "amount_label": "Amount converted", diff --git a/locales/languages/es.json b/locales/languages/es.json index 3e30408f7e2..48587ae1c02 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Potenciadores activos", "season_1": "Temporada 1", "musd": { + "page_title": "mUSD", "title": "Calculadora del bono en mUSD", "description": "Descubre cuánto podrías ganar al convertir tus monedas estables a mUSD.", "amount_label": "Monto convertido", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 4771a1895c8..86c81c80e14 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Boosts actifs", "season_1": "Saison 1", "musd": { + "page_title": "mUSD", "title": "Calculateur du bonus en mUSD", "description": "Découvrez combien vous pourriez gagner en convertissant vos stablecoins en mUSD.", "amount_label": "Montant converti", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index c99f36605fa..bcc5ae1017e 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "सक्रिय बूस्ट्स", "season_1": "सीज़न 1", "musd": { + "page_title": "mUSD", "title": "mUSD बोनस कैलकुलेटर", "description": "देखें कि आप अपने स्टेबलकॉइन को mUSD में बदलकर कितना कमा सकते हैं।", "amount_label": "कन्वर्ट किया गया अमाउंट", diff --git a/locales/languages/id.json b/locales/languages/id.json index 0acfe4afd19..f6b193a5f85 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Peningkatan aktif", "season_1": "Musim 1", "musd": { + "page_title": "mUSD", "title": "kalkulator bonus mUSD", "description": "Lihat berapa banyak yang bisa diperoleh dengan mengonversi stablecoin Anda menjadi mUSD.", "amount_label": "Jumlah yang dikonversi", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index fdb8b7268e2..3ef8fcc43ed 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "有効なブースト", "season_1": "シーズン1", "musd": { + "page_title": "mUSD", "title": "mUSDボーナス計算ツール", "description": "ステーブルコインをmUSDに交換することで、どれだけの利益が得られるか確認しましょう。", "amount_label": "変換金額", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index cd541c1ed55..86259e2cd84 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "적용 중인 부스트", "season_1": "시즌 1", "musd": { + "page_title": "mUSD", "title": "mUSD 보너스 계산기", "description": "스테이블코인을 mUSD로 전환하면 얼마나 적립할 수 있는지 확인해 보세요.", "amount_label": "전환 금액", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 3a2d1147550..8c7f9cc0956 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Incrementos ativos", "season_1": "Temporada 1", "musd": { + "page_title": "mUSD", "title": "Calculadora de bônus mUSD", "description": "Veja quanto você pode ganhar convertendo suas stablecoins para mUSD.", "amount_label": "Valor convertido", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 5710fe027b2..cef95cbbccf 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Активные повышающие коэффициенты", "season_1": "Сезон 1", "musd": { + "page_title": "mUSD", "title": "Калькулятор бонусов mUSD", "description": "Посмотрите, сколько вы могли бы заработать, конвертировав ваши стейблкоины в mUSD.", "amount_label": "Сконвертированная сумма", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 9c064573120..b2b6acb0a64 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Mga active boost", "season_1": "Season 1", "musd": { + "page_title": "mUSD", "title": "bonus calculator ng mUSD", "description": "Tingnan kung magkano ang maaari mong kitain sa pamamagitan ng pag-convert sa mga stablecoin mo sa mUSD.", "amount_label": "Halagang na-convert", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 8295430842c..513ec976299 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Aktif yükseltmeler", "season_1": "Sezon 1", "musd": { + "page_title": "mUSD", "title": "mUSD bonus hesaplayıcı", "description": "Stabil kripto paralarınızı mUSD'ye dönüştürerek ne kadar kazanabileceğinize bakın.", "amount_label": "Dönüştürülen miktar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 31240aed98b..01e8c54d2ac 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "Tính năng tăng cường đang hoạt động", "season_1": "Mùa 1", "musd": { + "page_title": "mUSD", "title": "Máy tính thưởng mUSD", "description": "Xem bạn có thể nhận được bao nhiêu khi chuyển đổi đồng ổn định của mình sang mUSD.", "amount_label": "Số lượng đã chuyển đổi", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index dbadf6900b1..1afd49f8f4e 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -7872,6 +7872,7 @@ "active_boosts_title": "活跃加成", "season_1": "第 1 季", "musd": { + "page_title": "mUSD", "title": "mUSD 奖励计算器", "description": "看看您将稳定币兑换为 mUSD 能赚取多少收益。", "amount_label": "已兑换金额", From eb330c54af40070a3a2fcab1ff0ae467af88ba8e Mon Sep 17 00:00:00 2001 From: jiexi Date: Wed, 25 Mar 2026 15:38:12 -0700 Subject: [PATCH 14/15] fix: make WCv2 session restore sequential (#27950) ## **Description** Changes WCv2 session/connection restores to be sequential. Previously they were concurrent. The concurrency was problematic because it would cause a large spike in relay traffic for users with numerous WCv2 connections. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WAPI-1357 ## **Manual testing steps** 1. Change `SESSION_RESTORE_STAGGER_MS` to be something larger like 10000 2. Make an ios expo build 3. Using safari native browser, connect to https://react-app.walletconnect.com/ AND https://wagmi-app.vercel.app/ using WalletConnect 4. Swipe away MetaMask 5. On the dapp you connected to last, trigger a personal sign 6. Ignore the deeplink 7. Reopen MetaMask manually from the homescreen 8. Login 9. It should take at least your `SESSION_RESTORE_STAGGER_MS` delay before you see the approval appear Why this way instead of using the js debugger? Unfortunately restarting the app causes the debugger to disconnect, and so it isn't a reliable way to probe startup based flows. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes WCv2 startup session restoration from concurrent async updates to a serialized loop with an added delay, which can impact connection readiness timing and expose ordering/regression issues in session/account syncing. > > **Overview** > WCv2 startup now restores existing WalletConnect sessions **sequentially** via a new `restoreSessions()` flow, adding a small stagger (`SESSION_RESTORE_STAGGER_MS`) between `updateSession` calls to avoid relay-traffic spikes. > > Initialization (`WC2Manager.init`) now triggers this restore pass after constructing the manager, and tests were extended to assert serial execution, the per-session delay, and that sessions with internal/invalid URLs are skipped. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2c7c339fd0a24fbaffb3543760c66709ed5ea2c3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../WalletConnect/WalletConnectV2.test.ts | 146 ++++++++++++++++++ app/core/WalletConnect/WalletConnectV2.ts | 144 +++++++++-------- 2 files changed, 223 insertions(+), 67 deletions(-) diff --git a/app/core/WalletConnect/WalletConnectV2.test.ts b/app/core/WalletConnect/WalletConnectV2.test.ts index 8f4bfba5c50..4731f169c26 100644 --- a/app/core/WalletConnect/WalletConnectV2.test.ts +++ b/app/core/WalletConnect/WalletConnectV2.test.ts @@ -13,6 +13,8 @@ import { Core } from '@walletconnect/core'; import Routes from '../../constants/navigation/Routes'; import { store } from '../../store'; import { ActionType } from '../../actions/sdk'; +// eslint-disable-next-line import-x/no-namespace +import * as waitUtil from '../SDKConnect/utils/wait.util'; jest.mock('../AppConstants', () => ({ WALLET_CONNECT: { @@ -236,6 +238,11 @@ jest.mock('@walletconnect/core', () => ({ })), })); +jest.mock('../SDKConnect/utils/wait.util', () => ({ + wait: jest.fn().mockResolvedValue(undefined), + waitForKeychainUnlocked: jest.fn().mockResolvedValue(undefined), +})); + describe('WC2Manager', () => { let manager: WC2Manager; let mockApproveSession: jest.SpyInstance; @@ -442,6 +449,145 @@ describe('WC2Manager', () => { expect(secondInstance).toBe(firstInstance); expect(secondInstance).toBeInstanceOf(WC2Manager); }); + + describe('session restore', () => { + const SESSION_RESTORE_STAGGER_MS = 200; + + const makeSession = (index: number) => ({ + topic: `topic-${index}`, + pairingTopic: `pairing-${index}`, + peer: { + metadata: { + url: `https://dapp-${index}.example.com`, + name: `DApp ${index}`, + icons: [], + }, + }, + }); + + // The WalletKit mock always resolves to the same singleton mockClient. + // We access it through the manager created by the outer beforeEach so + // we can configure getActiveSessions before each fresh init() call. + let walletKitClient: IWalletKit; + + beforeEach(() => { + walletKitClient = (manager as unknown as { web3Wallet: IWalletKit }) + .web3Wallet; + (WC2Manager as any).instance = undefined; + (WC2Manager as any)._initialized = false; + jest.clearAllMocks(); + }); + + it('calls updateSession for each active session on startup', async () => { + const sessions = [makeSession(1), makeSession(2), makeSession(3)]; + (walletKitClient.getActiveSessions as jest.Mock).mockReturnValueOnce( + Object.fromEntries(sessions.map((s) => [s.topic, s])), + ); + + const mockUpdateSession = jest.fn().mockResolvedValue(undefined); + (WalletConnect2Session as jest.Mock).mockImplementation(() => ({ + updateSession: mockUpdateSession, + handleRequest: jest.fn(), + removeListeners: jest.fn(), + setDeeplink: jest.fn(), + isHandlingRequest: jest.fn().mockReturnValue(false), + getCurrentChainId: jest.fn().mockReturnValue('0x1'), + getAllowedChainIds: ['eip155:1'], + })); + + await WC2Manager.init({}); + + expect(mockUpdateSession).toHaveBeenCalledTimes(sessions.length); + }); + + it('does not start the next updateSession before the previous one resolves', async () => { + const sessions = [makeSession(1), makeSession(2), makeSession(3)]; + (walletKitClient.getActiveSessions as jest.Mock).mockReturnValueOnce( + Object.fromEntries(sessions.map((s) => [s.topic, s])), + ); + + let concurrentCallCount = 0; + let maxConcurrentCalls = 0; + const mockUpdateSession = jest.fn().mockImplementation(async () => { + concurrentCallCount++; + maxConcurrentCalls = Math.max( + maxConcurrentCalls, + concurrentCallCount, + ); + await Promise.resolve(); + concurrentCallCount--; + }); + + (WalletConnect2Session as jest.Mock).mockImplementation(() => ({ + updateSession: mockUpdateSession, + handleRequest: jest.fn(), + removeListeners: jest.fn(), + setDeeplink: jest.fn(), + isHandlingRequest: jest.fn().mockReturnValue(false), + getCurrentChainId: jest.fn().mockReturnValue('0x1'), + getAllowedChainIds: ['eip155:1'], + })); + + await WC2Manager.init({}); + + expect(maxConcurrentCalls).toBe(1); + }); + + it('inserts a delay between successive session restores', async () => { + const sessions = [makeSession(1), makeSession(2), makeSession(3)]; + (walletKitClient.getActiveSessions as jest.Mock).mockReturnValueOnce( + Object.fromEntries(sessions.map((s) => [s.topic, s])), + ); + + const waitSpy = jest.spyOn(waitUtil, 'wait'); + + await WC2Manager.init({}); + + const staggerCalls = waitSpy.mock.calls.filter( + ([ms]) => ms === SESSION_RESTORE_STAGGER_MS, + ); + // One stagger wait after each restored session + expect(staggerCalls).toHaveLength(sessions.length); + }); + + it('skips sessions with internal/invalid URLs without restoring them', async () => { + // ORIGIN_METAMASK ('metamask') is a member of INTERNAL_ORIGINS + const internalUrl = 'metamask'; + const sessions = [ + { + topic: 'internal-topic', + pairingTopic: 'internal-pairing', + peer: { + metadata: { url: internalUrl, name: 'Internal', icons: [] }, + }, + }, + makeSession(2), + ]; + (walletKitClient.getActiveSessions as jest.Mock).mockReturnValueOnce( + Object.fromEntries(sessions.map((s) => [s.topic, s])), + ); + + const mockUpdateSession = jest.fn().mockResolvedValue(undefined); + (WalletConnect2Session as jest.Mock).mockImplementation(() => ({ + updateSession: mockUpdateSession, + handleRequest: jest.fn(), + removeListeners: jest.fn(), + setDeeplink: jest.fn(), + isHandlingRequest: jest.fn().mockReturnValue(false), + getCurrentChainId: jest.fn().mockReturnValue('0x1'), + getAllowedChainIds: ['eip155:1'], + })); + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await WC2Manager.init({}); + + // Only the valid session is restored + expect(mockUpdateSession).toHaveBeenCalledTimes(1); + + consoleSpy.mockRestore(); + }); + }); }); describe('WC2Manager Sessions', () => { diff --git a/app/core/WalletConnect/WalletConnectV2.ts b/app/core/WalletConnect/WalletConnectV2.ts index ff4494fe637..85af55f627d 100644 --- a/app/core/WalletConnect/WalletConnectV2.ts +++ b/app/core/WalletConnect/WalletConnectV2.ts @@ -118,6 +118,22 @@ export class WC2Manager { delete this.sessions[event.topic]; }, ); + } + + // Milliseconds to wait between consecutive session restorations on startup. + private static readonly SESSION_RESTORE_STAGGER_MS = 200; + + /** + * Restores all active WalletConnect sessions one at a time. + * + * This method processes sessions serially with a small delay between + * each one so that relay traffic is smoothed out over time. + */ + private async restoreSessions(): Promise { + const activeSessions = this.getSessions(); + if (!activeSessions?.length) { + return; + } const accountsController = ( Engine.context as { @@ -137,83 +153,76 @@ export class WC2Manager { } ).PermissionController; - const activeSessions = this.getSessions(); + for (const session of activeSessions) { + if (INTERNAL_ORIGINS.includes(session.peer.metadata.url)) { + console.warn( + `WC2::init skipping session with invalid url: ${session.topic}`, + ); + continue; + } - if (activeSessions) { - activeSessions.forEach(async (session) => { - if (INTERNAL_ORIGINS.includes(session.peer.metadata.url)) { - console.warn( - `WC2::init skipping session with invalid url: ${session.topic}`, - ); - return; - } + const sessionKey = session.topic; + const pairingTopic = session.pairingTopic; + try { + const wcSession = new WalletConnect2Session({ + web3Wallet: this.web3Wallet, + channelId: pairingTopic, + navigation: this.navigation, + deeplink: + typeof this.deeplinkSessions[session.pairingTopic] !== 'undefined', + session, + }); + this.sessions[sessionKey] = wcSession; - const sessionKey = session.topic; - const pairingTopic = session.pairingTopic; - try { - const wcSession = new WalletConnect2Session({ - web3Wallet, - channelId: pairingTopic, - navigation: this.navigation, - deeplink: - typeof deeplinkSessions[session.pairingTopic] !== 'undefined', - session, - }); - this.sessions[sessionKey] = wcSession; - - // Find approvedAccounts for current sessions - DevLogger.log( - `WC2::init getPermittedAccounts for ${sessionKey} origin=${sessionKey}`, - JSON.stringify(permissionController.state, null, 2), - ); - const accountPermission = permissionController.getPermission( - pairingTopic, - 'eth_accounts', - ); + // Find approvedAccounts for current sessions + DevLogger.log( + `WC2::init getPermittedAccounts for ${sessionKey} origin=${sessionKey}`, + JSON.stringify(permissionController.state, null, 2), + ); + const accountPermission = permissionController.getPermission( + pairingTopic, + 'eth_accounts', + ); - DevLogger.log( - `WC2::init accountPermission`, - JSON.stringify(accountPermission, null, 2), - ); + DevLogger.log( + `WC2::init accountPermission`, + JSON.stringify(accountPermission, null, 2), + ); - let approvedAccounts = getPermittedAccounts(pairingTopic) ?? []; + let approvedAccounts = getPermittedAccounts(pairingTopic) ?? []; + + DevLogger.log( + `WC2::init approvedAccounts id ${accountPermission?.id}`, + approvedAccounts, + ); + if (approvedAccounts?.length === 0) { DevLogger.log( - `WC2::init approvedAccounts id ${accountPermission?.id}`, - approvedAccounts, + `WC2::init fallback to parsing accountPermission`, + accountPermission, ); + // FIXME: Why getPermitted accounts doesn't work??? + approvedAccounts = extractApprovedAccounts(accountPermission); + DevLogger.log(`WC2::init approvedAccounts`, approvedAccounts); + } - if (approvedAccounts?.length === 0) { - DevLogger.log( - `WC2::init fallback to parsing accountPermission`, - accountPermission, - ); - // FIXME: Why getPermitted accounts doesn't work??? - approvedAccounts = extractApprovedAccounts(accountPermission); - DevLogger.log(`WC2::init approvedAccounts`, approvedAccounts); - } + updatePermittedChains(pairingTopic, wcSession.getAllowedChainIds, true); - updatePermittedChains( - pairingTopic, - wcSession.getAllowedChainIds, - true, - ); + const chainId = wcSession.getCurrentChainId(); - const chainId = wcSession.getCurrentChainId(); - - const nChainId = parseInt(chainId, 16); - DevLogger.log( - `WC2::init updateSession session=${pairingTopic} chainId=${chainId} nChainId=${nChainId} selectedAddress=${selectedInternalAccountChecksummedAddress}`, - approvedAccounts, - ); - await this.sessions[sessionKey].updateSession({ - chainId: nChainId, - accounts: approvedAccounts, - }); - } catch (err) { - console.warn(`WC2::init can't update session ${sessionKey}`); - } - }); + const nChainId = parseInt(chainId, 16); + DevLogger.log( + `WC2::init updateSession session=${pairingTopic} chainId=${chainId} nChainId=${nChainId} selectedAddress=${selectedInternalAccountChecksummedAddress}`, + approvedAccounts, + ); + await this.sessions[sessionKey].updateSession({ + chainId: nChainId, + accounts: approvedAccounts, + }); + } catch (err) { + console.warn(`WC2::init can't update session ${sessionKey}`); + } + await wait(WC2Manager.SESSION_RESTORE_STAGGER_MS); } } @@ -317,6 +326,7 @@ export class WC2Manager { navigation, sessions, ); + await this.instance.restoreSessions(); } catch (error) { Logger.error(error as Error, `WC2@init() failed to create instance`); } From 98e65eab7543145cf6efb7b596bac4ec551dab31 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 25 Mar 2026 17:51:09 -0500 Subject: [PATCH 15/15] fix: use pairingTopic for permission revocation in removeSession() (#27945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `removeSession()` calls `permissionsController.revokeAllPermissions(session.topic)`, but permissions are stored under `session.pairingTopic` (used as `channelId` / `origin` when `requestPermissions` is called in `_handleSessionProposal` — lines 468, 581). Since `topic` and `pairingTopic` are always different values, the revocation was targeting a non-existent subject and silently failing (caught by the surrounding try/catch). Stale permission entries accumulated in the `PermissionController` for every disconnected WC session. This changes the revocation key to `session.pairingTopic` to match how permissions are created. ## Related issues Fixes: https://consensyssoftware.atlassian.net/browse/WAPI-1361 ## **Changelog** CHANGELOG entry: Fixed - Fixed removeSession() using session.topic instead of session.pairingTopic when revoking WalletConnect permissions, which caused stale permission entries to persist in the PermissionController ([#27945](https://github.com/MetaMask/metamask-mobile/pull/27945)) ## Manual testing steps 1. Connect a dapp via WalletConnect 2. Inspect `PermissionController.state.subjects` — the dapp's entry should be keyed by `pairingTopic` 3. Disconnect the session (long-press in Settings > Experimental > WC Sessions) 4. Inspect `PermissionController.state.subjects` — the entry should now be removed ## Pre-merge author checklist - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. --- > [!NOTE] > **Medium Risk** > Changes how WalletConnect v2 session teardown revokes permissions, which impacts permission state cleanup and could affect disconnect behavior if the wrong identifier is used elsewhere. Scope is small and covered by an updated unit test. > > **Overview** > Fixes `WC2Manager.removeSession()` to revoke permissions using `session.pairingTopic` (and logs that identifier) instead of `session.topic`, preventing stale `PermissionController` subjects from accumulating after disconnects. > > Updates the `WalletConnectV2` unit test to assert `revokeAllPermissions` is called with the pairing topic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8ba16c9609636ecade8c9d184cf4eba7c3281284. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/WalletConnect/WalletConnectV2.test.ts | 2 +- app/core/WalletConnect/WalletConnectV2.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/WalletConnect/WalletConnectV2.test.ts b/app/core/WalletConnect/WalletConnectV2.test.ts index 4731f169c26..d0dd8f26dce 100644 --- a/app/core/WalletConnect/WalletConnectV2.test.ts +++ b/app/core/WalletConnect/WalletConnectV2.test.ts @@ -864,7 +864,7 @@ describe('WC2Manager', () => { }, topic: 'test-topic', }); - expect(revokeAllPermissionsSpy).toHaveBeenCalledWith('test-topic'); + expect(revokeAllPermissionsSpy).toHaveBeenCalledWith('test-pairing'); }); }); diff --git a/app/core/WalletConnect/WalletConnectV2.ts b/app/core/WalletConnect/WalletConnectV2.ts index 85af55f627d..4f3fadaad4a 100644 --- a/app/core/WalletConnect/WalletConnectV2.ts +++ b/app/core/WalletConnect/WalletConnectV2.ts @@ -381,10 +381,10 @@ export class WC2Manager { } ).PermissionController; DevLogger.log( - `WC2::removeSession revokeAllPermissions for ${session.topic}`, + `WC2::removeSession revokeAllPermissions for ${session.pairingTopic}`, permissionsController.state, ); - permissionsController.revokeAllPermissions(session.topic); + permissionsController.revokeAllPermissions(session.pairingTopic); } catch (err) { DevLogger.log(`WC2::removeSession error while disconnecting`, err); }