From 3d9b76c3cf79ff8ac4e66c64ee01db0df513d5b2 Mon Sep 17 00:00:00 2001 From: strmci Date: Mon, 11 May 2026 10:28:15 +0200 Subject: [PATCH 1/2] frontend: move insurance into marketplace Route Bitsurance through the marketplace flow so tab navigation, account selection, and swap availability share one context. --- CHANGELOG.md | 1 + frontends/web/src/app.tsx | 5 - .../bottom-navigation/bottom-navigation.tsx | 2 +- .../src/components/bottom-navigation/utils.ts | 2 +- .../groupedaccountselector.test.tsx | 65 ++++++ .../groupedaccountselector.tsx | 3 +- .../icon/assets/icons/shield-light.svg | 4 - frontends/web/src/components/icon/icon.tsx | 2 - .../web/src/components/sidebar/sidebar.tsx | 17 +- .../src/contexts/BackNavigationContext.tsx | 11 +- frontends/web/src/locales/en/app.json | 5 +- frontends/web/src/routes/account/account.tsx | 2 +- .../routes/account/components/insuredtag.tsx | 8 +- .../web/src/routes/bitsurance/account.tsx | 58 +++-- .../routes/bitsurance/bitsurance-layout.tsx | 67 ++++++ .../web/src/routes/bitsurance/bitsurance.tsx | 163 +++++++------ .../web/src/routes/bitsurance/dashboard.tsx | 185 +++++++-------- .../src/routes/bitsurance/widget.module.css | 5 - .../web/src/routes/bitsurance/widget.tsx | 65 +++--- .../market/components/markettab.module.css | 10 + .../market/components/markettab.test.tsx | 21 +- .../routes/market/components/markettab.tsx | 15 +- .../web/src/routes/market/market-context.tsx | 58 +++++ frontends/web/src/routes/market/market.tsx | 215 ++++++++---------- .../src/routes/market/marketplace-layout.tsx | 59 +++++ .../market/use-marketplace-tab-navigation.ts | 24 ++ frontends/web/src/routes/market/utils.ts | 25 +- frontends/web/src/routes/router.tsx | 99 ++++++-- frontends/web/src/routes/settings/more.tsx | 13 +- 29 files changed, 761 insertions(+), 448 deletions(-) create mode 100644 frontends/web/src/components/groupedaccountselector/groupedaccountselector.test.tsx delete mode 100644 frontends/web/src/components/icon/assets/icons/shield-light.svg create mode 100644 frontends/web/src/routes/bitsurance/bitsurance-layout.tsx create mode 100644 frontends/web/src/routes/market/market-context.tsx create mode 100644 frontends/web/src/routes/market/marketplace-layout.tsx create mode 100644 frontends/web/src/routes/market/use-marketplace-tab-navigation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a46ef10ec9..b9a7e5ab39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add external block explorer links to used addresses - Settings search - Support macOS "Number Format" system setting +- Move insurance to Marketplace ## v4.51.1 - iOS: fix App Store submission by packaging Inter as TTF fonts diff --git a/frontends/web/src/app.tsx b/frontends/web/src/app.tsx index 488b9bfb34..1cab084bde 100644 --- a/frontends/web/src/app.tsx +++ b/frontends/web/src/app.tsx @@ -116,11 +116,6 @@ export const App = () => { navigate('/'); return; } - // if on the /bitsurance/ view and there are no accounts view route to / - if (accounts.length === 0 && currentURL.startsWith('/bitsurance/')) { - navigate('/'); - return; - } // if in no-accounts settings and has account go to manage-accounts if (accounts.length && currentURL === '/settings/no-accounts') { navigate('/settings/manage-accounts'); diff --git a/frontends/web/src/components/bottom-navigation/bottom-navigation.tsx b/frontends/web/src/components/bottom-navigation/bottom-navigation.tsx index 71f053c7a6..fe792d3edf 100644 --- a/frontends/web/src/components/bottom-navigation/bottom-navigation.tsx +++ b/frontends/web/src/components/bottom-navigation/bottom-navigation.tsx @@ -78,7 +78,7 @@ export const BottomNavigation = ({ diff --git a/frontends/web/src/components/bottom-navigation/utils.ts b/frontends/web/src/components/bottom-navigation/utils.ts index 8233ccfc7a..9b121c90e8 100644 --- a/frontends/web/src/components/bottom-navigation/utils.ts +++ b/frontends/web/src/components/bottom-navigation/utils.ts @@ -14,7 +14,7 @@ export const getBottomNavKey = (pathname: string): string => { if (pathname.startsWith('/market/')) { return 'market'; } - if (pathname.startsWith('/settings') || pathname.startsWith('/bitsurance/')) { + if (pathname.startsWith('/settings')) { return 'more'; } return 'other'; diff --git a/frontends/web/src/components/groupedaccountselector/groupedaccountselector.test.tsx b/frontends/web/src/components/groupedaccountselector/groupedaccountselector.test.tsx new file mode 100644 index 0000000000..49e53d78c9 --- /dev/null +++ b/frontends/web/src/components/groupedaccountselector/groupedaccountselector.test.tsx @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +import '../../../__mocks__/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { TAccountBase } from '@/api/account'; +import { GroupedAccountSelector } from './groupedaccountselector'; + +const account: TAccountBase = { + active: false, + code: 'btc-0', + coinCode: 'btc', + coinUnit: 'BTC', + isToken: false, + keystore: { + connected: false, + lastConnected: '', + name: 'BitBox', + rootFingerprint: 'root-fingerprint', + watchonly: false, + }, + name: 'Bitcoin account', +}; + +describe('components/groupedaccountselector', () => { + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(query => ({ + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: query, + onchange: null, + removeEventListener: vi.fn(), + })), + }); + }); + + it('disables proceed when the selected account code is not available', async () => { + render( + , + ); + + expect(await screen.findByRole('button', { name: 'buy.info.next' })).toBeDisabled(); + }); + + it('enables proceed when the selected account code is available', async () => { + render( + , + ); + + expect(await screen.findByRole('button', { name: 'buy.info.next' })).not.toBeDisabled(); + }); +}); diff --git a/frontends/web/src/components/groupedaccountselector/groupedaccountselector.tsx b/frontends/web/src/components/groupedaccountselector/groupedaccountselector.tsx index 3b800a62d5..0b9fdfbb32 100644 --- a/frontends/web/src/components/groupedaccountselector/groupedaccountselector.tsx +++ b/frontends/web/src/components/groupedaccountselector/groupedaccountselector.tsx @@ -148,6 +148,7 @@ export const GroupedAccountSelector = ({ ? { label: t('buy.info.selectLabel'), value: 'choose', disabled: true } : options.flatMap(o => o.options).find(opt => opt.value === selected) ); + const isProceedDisabled = disabled || !selectedOption || selectedOption.disabled; const renderOption = (option: TOption, isSelectedValue: boolean) => { const isStacked = stackedLayout && isSelectedValue; @@ -207,7 +208,7 @@ export const GroupedAccountSelector = ({ diff --git a/frontends/web/src/components/icon/assets/icons/shield-light.svg b/frontends/web/src/components/icon/assets/icons/shield-light.svg deleted file mode 100644 index b729b7dcab..0000000000 --- a/frontends/web/src/components/icon/assets/icons/shield-light.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontends/web/src/components/icon/icon.tsx b/frontends/web/src/components/icon/icon.tsx index 17f38c9ebf..b58e1e445c 100644 --- a/frontends/web/src/components/icon/icon.tsx +++ b/frontends/web/src/components/icon/icon.tsx @@ -82,7 +82,6 @@ import outlinedUnorderedListDarkSVG from './assets/icons/outlined-unordered-list import outlinedUnorderedListLightSVG from './assets/icons/outlined-unordered-list-light.svg'; import shieldSVG from './assets/icons/shield.svg'; import shieldDarkSVG from './assets/icons/shield-dark.svg'; -import shieldLightSVG from './assets/icons/shield-light.svg'; import syncSVG from './assets/icons/sync.svg'; import syncLightSVG from './assets/icons/sync-light.svg'; import selectedCheckLightSVG from './assets/icons/selected-check-light.svg'; @@ -198,7 +197,6 @@ export const QRCodeLight = (props: ImgProps) => ( (); export const Shield = (props: ImgProps) => (); export const ShieldDark = (props: ImgProps) => (); -export const ShieldLight = (props: ImgProps) => (); export const SpinnerRingDark = (props: ImgProps) => (); export const SpinnerRingLight = (props: ImgProps) => (); export const Sync = (props: ImgProps) => (); diff --git a/frontends/web/src/components/sidebar/sidebar.tsx b/frontends/web/src/components/sidebar/sidebar.tsx index ccdf820c20..e9a1a68e27 100644 --- a/frontends/web/src/components/sidebar/sidebar.tsx +++ b/frontends/web/src/components/sidebar/sidebar.tsx @@ -10,7 +10,7 @@ import { deregisterTest } from '@/api/keystores'; import { getVersion } from '@/api/bitbox02'; import { debug } from '@/utils/env'; import { AppLogoInverted, Logo } from '@/components/icon/logo'; -import { CloseXWhite, CogLight, Coins, Device, Eject, Linechart, RedDot, ShieldLight } from '@/components/icon'; +import { CloseXWhite, CogLight, Coins, Device, Eject, Linechart, RedDot } from '@/components/icon'; import { getAccountsByKeystore } from '@/routes/account/utils'; import { SkipForTesting } from '@/routes/device/components/skipfortesting'; import { AppContext } from '@/contexts/AppContext'; @@ -92,7 +92,7 @@ const Sidebar = ({ }; const accountsByKeystore = getAccountsByKeystore(accounts); - const userInSpecificAccountMarketPage = (pathname.startsWith('/market')); + const inMarketSection = pathname.startsWith('/market'); return (
@@ -150,7 +150,7 @@ const Sidebar = ({ <>
isActive || userInSpecificAccountMarketPage ? style.sidebarActive : ''} + className={({ isActive }) => isActive || inMarketSection ? style.sidebarActive : ''} to="/market/select">
@@ -167,17 +167,6 @@ const Sidebar = ({
-
- isActive ? style.sidebarActive : ''} - to="/bitsurance/bitsurance" - > -
- -
- {t('sidebar.insurance')} -
-
) : null } diff --git a/frontends/web/src/contexts/BackNavigationContext.tsx b/frontends/web/src/contexts/BackNavigationContext.tsx index f8a6859c38..f384752a18 100644 --- a/frontends/web/src/contexts/BackNavigationContext.tsx +++ b/frontends/web/src/contexts/BackNavigationContext.tsx @@ -35,8 +35,7 @@ const getTabId = (pathname: string): TabId => { } if (pathname.startsWith('/manage-backups/') || pathname.startsWith('/settings') - || pathname.startsWith('/add-account') - || pathname.startsWith('/bitsurance/')) { + || pathname.startsWith('/add-account')) { return 'settings'; } return 'unknown'; @@ -71,8 +70,12 @@ const implicitBackRules: ImplicitBackRule[] = [ previousPattern: '/account/:code', })), { - currentPattern: '/bitsurance/bitsurance', - previousPattern: '/settings/more', + currentPattern: '/market/bitsurance/:code', + previousPattern: '/market/select/:code', + }, + { + currentPattern: '/market/bitsurance/dashboard/:code', + previousPattern: '/market/select/:code', }, ]; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 8215567fd6..390ac64f31 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -410,8 +410,7 @@ "bitsuranceAccount": { "errorNoXpub": "Error: Was not able to get xpub from account.", "noAccount": "There are no accounts that can be insured.", - "select": "Select account", - "title": "Insurance" + "select": "Select account" }, "blink": { "button": "Blink" @@ -1024,6 +1023,7 @@ "enable": "Enable", "enabled_false": "Disabled", "enabled_true": "Enabled", + "insure": "Insure", "max": "Max", "new": "New", "noOptionOnIos": "Not available on iOS", @@ -2001,7 +2001,6 @@ "setup": "Setup device", "sidebar": { "device": "Manage device", - "insurance": "Insurance", "leave": "Leave", "settings": "Settings" }, diff --git a/frontends/web/src/routes/account/account.tsx b/frontends/web/src/routes/account/account.tsx index 4c7521e8dc..bfa1360fbc 100644 --- a/frontends/web/src/routes/account/account.tsx +++ b/frontends/web/src/routes/account/account.tsx @@ -239,7 +239,7 @@ const RemountAccount = ({
{account.name}{insured && ()}}> + title={

{account.name}{insured && ()}

}> { +type TInsuredProps = { + code: string; +}; + +export const Insured = ({ code }: TInsuredProps) => { const { t } = useTranslation(); return ( - +
{t('account.insured')} diff --git a/frontends/web/src/routes/bitsurance/account.tsx b/frontends/web/src/routes/bitsurance/account.tsx index af337f166c..cfdf46953c 100644 --- a/frontends/web/src/routes/bitsurance/account.tsx +++ b/frontends/web/src/routes/bitsurance/account.tsx @@ -4,9 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { TAccount } from '@/api/account'; -import { BitsuranceGuide } from './guide'; import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; -import { GuidedContent, GuideWrapper, Header, Main } from '@/components/layout'; import { Spinner } from '@/components/spinner/Spinner'; import { View, ViewContent } from '@/components/view/view'; import { bitsuranceLookup } from '@/api/bitsurance'; @@ -20,14 +18,13 @@ type TProps = { export const BitsuranceAccount = ({ code, accounts }: TProps) => { const navigate = useNavigate(); - const [selected, setSelected] = useState(code); const [disabled, setDisabled] = useState(false); const [btcAccounts, setBtcAccounts] = useState(); const { t } = useTranslation(); - const handleChangeAccount = (selected: string) => { - setSelected(selected); + const handleChangeAccount = (accountCode: string) => { + navigate(`/market/bitsurance/account/${accountCode}`, { replace: true }); }; const detect = useCallback(async () => { @@ -63,7 +60,7 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { return; } // replace current history item when redirecting so that the user can go back - navigate(`/bitsurance/widget/${account.code}`, { replace: true }); + navigate(`/market/bitsurance/widget/${account.code}`, { replace: true }); }); } }, [btcAccounts, navigate]); @@ -71,7 +68,7 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { const handleProceed = async () => { setDisabled(true); try { - const account = btcAccounts?.find(acc => acc.code === selected); + const account = btcAccounts?.find(acc => acc.code === code); if (account === undefined) { return; } @@ -82,7 +79,7 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { } finally { setDisabled(false); } - navigate(`/bitsurance/widget/${selected}`); + navigate(`/market/bitsurance/widget/${code}`); }; if (btcAccounts === undefined) { @@ -90,29 +87,26 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { } return ( -
- - -
{t('bitsuranceAccount.title')}} /> - - - { btcAccounts.length === 0 ? ( -
{t('bitsuranceAccount.noAccount')}
- ) : ( - - )} -
-
- - - -
+ + + { btcAccounts.length === 0 ? ( +
{t('bitsuranceAccount.noAccount')}
+ ) : ( + + )} +
+
); }; diff --git a/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx b/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx new file mode 100644 index 0000000000..711d56dac3 --- /dev/null +++ b/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactNode } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { AccountCode, TAccount } from '@/api/account'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbutton'; +import { MarketTab } from '@/routes/market/components/markettab'; +import { useMarketContext } from '@/routes/market/market-context'; +import { useMarketplaceTabNavigation } from '@/routes/market/use-marketplace-tab-navigation'; +import { getFallbackMarketAccountCode } from '@/routes/market/utils'; +import { BitsuranceGuide } from './guide'; + +type TProps = { + accounts: TAccount[]; + children: ReactNode; + code: AccountCode; +}; + +export const BitsuranceLayout = ({ accounts, children, code }: TProps) => { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const { showSwap } = useMarketContext(); + + const selectedAccountCode = code || getFallbackMarketAccountCode(accounts); + const isBitsuranceDashboard = !!matchPath({ path: '/market/bitsurance/dashboard/:code', end: true }, pathname); + + const handleChangeTab = useMarketplaceTabNavigation(accounts, selectedAccountCode); + + return ( +
+ + +
{t('generic.buySell')}}> + {isBitsuranceDashboard && ( + + )} +
+ + {children} +
+ +
+
+ ); +}; + +export const BitsuranceWidgetLayout = ({ children }: { children: ReactNode }) => { + const { t } = useTranslation(); + + return ( +
+ + +
{t('generic.buySell')}} /> + {children} + + + +
+ ); +}; diff --git a/frontends/web/src/routes/bitsurance/bitsurance.tsx b/frontends/web/src/routes/bitsurance/bitsurance.tsx index a1e33a3000..aeeb67c35b 100644 --- a/frontends/web/src/routes/bitsurance/bitsurance.tsx +++ b/frontends/web/src/routes/bitsurance/bitsurance.tsx @@ -3,61 +3,62 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { TAccount } from '@/api/account'; +import { AccountCode, TAccount } from '@/api/account'; import { bitsuranceLookup } from '@/api/bitsurance'; import { alertUser } from '@/components/alert/Alert'; import { A } from '@/components/anchor/anchor'; import { Button } from '@/components/forms'; import { Checked, Sync, SyncLight } from '@/components/icon'; -import { Column, ColumnButtons, GuidedContent, GuideWrapper, Header, Main, ResponsiveGrid } from '@/components/layout'; +import { Column, ColumnButtons, ResponsiveGrid } from '@/components/layout'; import { View, ViewContent } from '@/components/view/view'; import { useDarkmode } from '@/hooks/darkmode'; -import { BitsuranceGuide } from './guide'; import { i18n } from '@/i18n/i18n'; import style from './bitsurance.module.css'; type TProps = { accounts: TAccount[]; + code: AccountCode; }; -export const Bitsurance = ({ accounts }: TProps) => { +export const Bitsurance = ({ accounts, code }: TProps) => { const navigate = useNavigate(); const { t } = useTranslation(); const { isDarkMode } = useDarkmode(); const [insuredAccounts, setInsuredAccounts] = useState([]); - const [redirecting, setRedirecting] = useState(true); const [scanDone, setScanDone] = useState(false); const [scanLoading, setScanLoading] = useState(false); const amount = '100.000€'; + const hasBitsuranceAccount = accounts.some(({ bitsuranceStatus }) => bitsuranceStatus); useEffect(() => { - if (accounts.some(({ bitsuranceStatus }) => bitsuranceStatus)) { + if (code && hasBitsuranceAccount) { // replace current history item when redirecting so that the user can go back - navigate('/bitsurance/dashboard', { replace: true }); - } else { - setRedirecting(false); + navigate(`/market/bitsurance/dashboard/${code}`, { replace: true }); } return () => setInsuredAccounts([]); - }, [accounts, navigate]); + }, [code, hasBitsuranceAccount, navigate]); const detect = async (redirectToDashboard: boolean) => { setScanLoading(true); setScanDone(false); setInsuredAccounts([]); - const response = await bitsuranceLookup(); - if (!response.success) { - alertUser(response.errorMessage); - return; - } - const insuredAccountsCodes = response.bitsuranceAccounts.map(account => account.status ? account.code : null); - const insured = accounts.filter(({ code }) => insuredAccountsCodes.includes(code)); - setInsuredAccounts(insured); - setScanDone(true); - setScanLoading(false); - if (insured.length && redirectToDashboard) { - navigate('/bitsurance/dashboard'); + try { + const response = await bitsuranceLookup(); + if (!response.success) { + alertUser(response.errorMessage); + return; + } + const insuredAccountsCodes = response.bitsuranceAccounts.map(account => account.status ? account.code : null); + const insured = accounts.filter(({ code }) => insuredAccountsCodes.includes(code)); + setInsuredAccounts(insured); + setScanDone(true); + if (insured.length && redirectToDashboard) { + navigate(`/market/bitsurance/dashboard/${code}`); + } + } finally { + setScanLoading(false); } }; @@ -74,75 +75,67 @@ export const Bitsurance = ({ accounts }: TProps) => { // we force a detection to verify if there is any new insured account // before proceeding to the next step. await detect(false); - navigate('/bitsurance/account'); + navigate(`/market/bitsurance/account/${code}`); }; - if (redirecting) { + if (hasBitsuranceAccount) { return null; } return ( -
- - -
{t('sidebar.insurance')}} /> - - -

{t('bitsurance.intro.text1', { amount })}

-
- - -

- {t('bitsurance.insure.title')} -

-

{t('bitsurance.insure.text')}

-
    -
  • {t('bitsurance.insure.listItem1')}
  • -
  • {t('bitsurance.insure.listItem2')}
  • -
  • {t('bitsurance.insure.listItem3')}
  • -
-

- {t('bitsurance.insure.text2')} {' '} - {t('bitsurance.intro.link')}. -

-

- {t('bitsurance.insure.text3')} -

- - - -
- -

- {t('bitsurance.detect.title')} -

-

{t('bitsurance.detect.text')}

- {!insuredAccounts.length && scanDone && ( -

- {t('bitsurance.detect.notInsured')} -

- )} - - - + + +

{t('bitsurance.intro.text1', { amount })}

+
+ + +

+ {t('bitsurance.insure.title')} +

+

{t('bitsurance.insure.text')}

+
    +
  • {t('bitsurance.insure.listItem1')}
  • +
  • {t('bitsurance.insure.listItem2')}
  • +
  • {t('bitsurance.insure.listItem3')}
  • +
+

+ {t('bitsurance.insure.text2')} {' '} + {t('bitsurance.intro.link')}. +

+

+ {t('bitsurance.insure.text3')} +

+ + + +
+ +

+ {t('bitsurance.detect.title')} +

+

{t('bitsurance.detect.text')}

+ {!insuredAccounts.length && scanDone && ( +

+ {t('bitsurance.detect.notInsured')} +

+ )} + + + -
-
-
-
-
- - - -
+ + +
+ + ); }; diff --git a/frontends/web/src/routes/bitsurance/dashboard.tsx b/frontends/web/src/routes/bitsurance/dashboard.tsx index 3fcaea1997..a1ffae6fa6 100644 --- a/frontends/web/src/routes/bitsurance/dashboard.tsx +++ b/frontends/web/src/routes/bitsurance/dashboard.tsx @@ -9,20 +9,18 @@ import { useMountedRef } from '@/hooks/mount'; import { TAccountsByKeystore, getAccountsByKeystore, isAmbiguousName } from '@/routes/account/utils'; import { Button } from '@/components/forms'; import { alertUser } from '@/components/alert/Alert'; -import { GuideWrapper, GuidedContent, Header, Main } from '@/components/layout'; import { View, ViewContent } from '@/components/view/view'; import { A } from '@/components/anchor/anchor'; import { AmountWithUnit } from '@/components/amount/amount-with-unit'; import { Balances } from '@/routes/account/summary/accountssummary'; import { Skeleton } from '@/components/skeleton/skeleton'; -import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbutton'; import { ExternalLink, GreenDot, OrangeDot, RedDot, YellowDot } from '@/components/icon'; import { HorizontallyCenteredSpinner } from '@/components/spinner/SpinnerAnimation'; -import { BitsuranceGuide } from './guide'; import style from './dashboard.module.css'; type TProps = { accounts: TAccount[]; + code: AccountCode; }; type TAccountStatusIconProps = { @@ -49,7 +47,7 @@ const AccountStatusIcon = ({ status }: TAccountStatusIconProps) => { } }; -export const BitsuranceDashboard = ({ accounts }: TProps) => { +export const BitsuranceDashboard = ({ accounts, code }: TProps) => { const navigate = useNavigate(); const { t } = useTranslation(); const mounted = useMountedRef(); @@ -106,102 +104,95 @@ export const BitsuranceDashboard = ({ accounts }: TProps) => { }, [accountsByKeystore, mounted]); return ( - - -
-
{t('sidebar.insurance')}} > - -
- - - -
-

- {t('bitsurance.dashboard.title')} + + + +

+

+ {t('bitsurance.dashboard.title')} +

+ +
+ +
+ {accountsByKeystore?.length && insurances ? accountsByKeystore.map(({ accounts, keystore }) => ( + anyAccountInsured({ accounts, keystore }) && ( +
+

{keystore.name} + { isAmbiguousName(keystore.name, accountsByKeystore) ? ( + // Disambiguate accounts group by adding the fingerprint. + // The most common case where this would happen is when adding accounts from the + // same seed using different passphrases. + ({keystore.rootFingerprint}) + ) : null }

- -
- -
- {accountsByKeystore?.length && insurances ? accountsByKeystore.map(({ accounts, keystore }) => ( - anyAccountInsured({ accounts, keystore }) && ( -
-

{keystore.name} - { isAmbiguousName(keystore.name, accountsByKeystore) ? ( - // Disambiguate accounts group by adding the fingerprint. - // The most common case where this would happen is when adding accounts from the - // same seed using different passphrases. - ({keystore.rootFingerprint}) - ) : null } -

-
- {accounts?.length ? accounts.map(account => { - const balance = balances && balances[account.code]; - const insurance = insurances[account.code]; - return insurance ? ( -
-
-

- {accounts.filter(ac => ac.code === account.code).map(ac => ac.name)} -

- - { balance ? ( - - ) : } - -
- -
-

- {t('bitsurance.dashboard.coverage')} -

-

- {insurance.details.maxCoverageFormatted} - {' '} - {insurance.details.currency} -

-
- -
-
- -

- {t('bitsurance.dashboard.' + insurance.status)} -

-
- -
- - - {t('bitsurance.dashboard.supportLink')} - -
-
-
- +
+ {accounts?.length ? accounts.map(account => { + const balance = balances && balances[account.code]; + const insurance = insurances[account.code]; + return insurance ? ( +
+
+

+ {accounts.filter(ac => ac.code === account.code).map(ac => ac.name)} +

+ + { balance ? ( + + ) : } + +
+ +
+

+ {t('bitsurance.dashboard.coverage')} +

+

+ {insurance.details.maxCoverageFormatted} + {' '} + {insurance.details.currency} +

+
+ +
+
+ +

+ {t('bitsurance.dashboard.' + insurance.status)} +

+
+ +
+ + + {t('bitsurance.dashboard.supportLink')} +
- ) : null; - }) : } +
+
+
-
- ) - )) : } + ) : null; + }) : } +
- - -
-
- -
+ ) + )) : } +
+ + ); }; diff --git a/frontends/web/src/routes/bitsurance/widget.module.css b/frontends/web/src/routes/bitsurance/widget.module.css index d216a64345..a5dddf1c90 100644 --- a/frontends/web/src/routes/bitsurance/widget.module.css +++ b/frontends/web/src/routes/bitsurance/widget.module.css @@ -14,8 +14,3 @@ position: relative; z-index: 3000; } - -.header { - position: relative; - z-index: 2200; -} diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index c0dbf46bbd..3ce6e06fe6 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -5,15 +5,12 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType } from 'request-address'; import { useConfig } from '@/contexts/ConfigProvider'; -import { ScriptType, signBTCMessageUnusedAddress } from '@/api/account'; -import { getInfo } from '@/api/account'; -import { Header } from '@/components/layout'; +import { getInfo, signBTCMessageUnusedAddress, type ScriptType } from '@/api/account'; import { Spinner } from '@/components/spinner/Spinner'; import { BitsuranceTerms } from '@/components/terms/bitsurance-terms'; import { useLoad } from '@/hooks/api'; import { UseDisableBackButton } from '@/hooks/backbutton'; import { alertUser } from '@/components/alert/Alert'; -import { BitsuranceGuide } from './guide'; import { getBitsuranceURL } from '@/api/bitsurance'; import { convertScriptType } from '@/utils/request-addess'; import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe'; @@ -113,7 +110,7 @@ export const BitsuranceWidget = ({ code }: TProps) => { try { let message = JSON.parse(m.data); if (message?.type === 'showInsuranceDashboard') { - navigate('/bitsurance/dashboard'); + navigate(`/market/bitsurance/dashboard/${code}`); return; } @@ -135,38 +132,32 @@ export const BitsuranceWidget = ({ code }: TProps) => { }; return ( -
-
-
-
{t('bitsuranceAccount.title')}} /> -
-
- { !agreedTerms ? ( - setAgreedTerms(true)} - /> - ) : ( -
- - {!iframeLoaded && } - -
- )} -
+ <> +
+ { !agreedTerms ? ( + setAgreedTerms(true)} + /> + ) : ( +
+ + {!iframeLoaded && } + +
+ )}
- -
+ ); }; diff --git a/frontends/web/src/routes/market/components/markettab.module.css b/frontends/web/src/routes/market/components/markettab.module.css index 23588a4f8a..dc90ee90b8 100644 --- a/frontends/web/src/routes/market/components/markettab.module.css +++ b/frontends/web/src/routes/market/components/markettab.module.css @@ -1,3 +1,13 @@ +.navigation { + box-sizing: border-box; + flex-grow: 0; + justify-content: center; + margin: 0 auto; + max-width: var(--content-width); + padding: 0 var(--space-default); + width: 100%; +} + .tabLabel { display: inline-block; position: relative; diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 75b73c12e2..821f0ac840 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -83,13 +83,28 @@ describe('routes/market/components/markettab', () => { />, ); - const swapButton = screen.getByText('generic.swap').closest('button'); - expect(swapButton).not.toBeNull(); - await user.click(swapButton as HTMLButtonElement); + await user.click(screen.getByRole('button', { name: /generic\.swap/ })); expect(onChangeTab).toHaveBeenCalledWith('swap'); }); + it('emits insure tab selection when insure is clicked', async () => { + const user = userEvent.setup(); + const onChangeTab = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /generic\.insure/ })); + + expect(onChangeTab).toHaveBeenCalledWith('insure'); + }); + it('shows the new badge on otc when enabled', async () => { mockUseConfig.mockReturnValue({ config: { diff --git a/frontends/web/src/routes/market/components/markettab.tsx b/frontends/web/src/routes/market/components/markettab.tsx index d088c67aa2..7027d5ed96 100644 --- a/frontends/web/src/routes/market/components/markettab.tsx +++ b/frontends/web/src/routes/market/components/markettab.tsx @@ -2,14 +2,15 @@ import { useTranslation } from 'react-i18next'; import { PillButton, PillButtonGroup } from '../../../components/pillbuttongroup/pillbuttongroup'; -import { TMarketAction } from '@/api/market'; +import type { TMarketAction } from '@/api/market'; import { NewBadge } from '@/components/new-badge/new-badge'; import style from './markettab.module.css'; +export type TMarketplaceTab = TMarketAction | 'insure'; type TProps = { - onChangeTab: (tab: TMarketAction) => void; - activeTab: TMarketAction; + onChangeTab: (tab: TMarketplaceTab) => void; + activeTab: TMarketplaceTab; showSwap: boolean; }; @@ -21,7 +22,7 @@ export const MarketTab = ({ }: TProps) => { const { t } = useTranslation(); return ( - + onChangeTab('buy')} @@ -71,6 +72,12 @@ export const MarketTab = ({ /> + onChangeTab('insure')} + > + {t('generic.insure')} + ); }; diff --git a/frontends/web/src/routes/market/market-context.tsx b/frontends/web/src/routes/market/market-context.tsx new file mode 100644 index 0000000000..4bba58f60f --- /dev/null +++ b/frontends/web/src/routes/market/market-context.tsx @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Dispatch, ReactNode, SetStateAction, createContext, useContext, useEffect, useState } from 'react'; +import type { TAccount } from '@/api/account'; +import { getSwapStatus } from '@/api/swap'; +import { useLoad } from '@/hooks/api'; +import type { TOption } from './components/countryselect'; + +type TMarketContext = { + regions: TOption[]; + selectedRegion: string; + setRegions: Dispatch>; + setSelectedRegion: Dispatch>; + showSwap?: boolean; +}; + +const MarketContext = createContext(null); + +type TProps = { + accounts: TAccount[]; + children: ReactNode; +}; + +export const MarketProvider = ({ + accounts, + children, +}: TProps) => { + const [regions, setRegions] = useState([]); + const [selectedRegion, setSelectedRegion] = useState(''); + const [showSwap, setShowSwap] = useState(); + const swapStatus = useLoad(getSwapStatus, [accounts]); + + useEffect(() => { + if (swapStatus?.available !== undefined) { + setShowSwap(swapStatus.available); + } + }, [swapStatus?.available]); + + return ( + + {children} + + ); +}; + +export const useMarketContext = () => { + const context = useContext(MarketContext); + if (context === null) { + throw new Error('useMarketContext must be used within MarketProvider'); + } + return context; +}; diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 7a080f22f4..9a5d5acee9 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -9,19 +9,15 @@ import { i18n } from '@/i18n/i18n'; import * as marketAPI from '@/api/market'; import { getSwapStatus } from '@/api/swap'; import { AccountCode, TAccount } from '@/api/account'; -import { GuidedContent, GuideWrapper, Header, Main } from '@/components/layout'; import { View, ViewContent } from '@/components/view/view'; -import { MarketGuide } from './guide'; -import { isBitcoinOnly } from '@/routes/account/utils'; import { useLoad } from '@/hooks/api'; import { useVendorTerms } from '@/hooks/vendor-iframe-terms'; import { getRegionNameFromLocale } from '@/i18n/utils'; -import { getVendorFormattedName } from './utils'; +import { getFallbackMarketAccountCode, getMarketActionFromSearchParams, getVendorFormattedName } from './utils'; import { Spinner } from '@/components/spinner/Spinner'; import { Dialog } from '@/components/dialog/dialog'; import { alertUser } from '@/components/alert/Alert'; import { InfoButton } from '@/components/infobutton/infobutton'; -import { MarketTab } from './components/markettab'; import { Deals } from './components/deals'; import { getNativeLocale } from '@/api/nativelocale'; import { useConfig } from '@/contexts/ConfigProvider'; @@ -30,6 +26,7 @@ import { getBTCDirectOTCLink, getPocketOTCLink, InfoContent, TInfoContentProps } import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; import { connectAnyKeystore, connectKeystore } from '@/api/keystores'; import { open } from '@/api/system'; +import { useMarketContext } from './market-context'; import style from './market.module.css'; type TProps = { @@ -44,22 +41,22 @@ export const Market = ({ const { t } = useTranslation(); const { config, setConfig } = useConfig(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); + const { + regions, + selectedRegion, + setRegions, + setSelectedRegion, + } = useMarketContext(); + const validRouteAccountCode = accounts.some(account => account.code === code) ? code : ''; - const [selectedAccount, setSelectedAccount] = useState(code); - const [selectedRegion, setSelectedRegion] = useState(''); - const [regions, setRegions] = useState([]); const [info, setInfo] = useState(); - const [supportedAccounts, setSupportedAccounts] = useState([]); - const [activeTab, setActiveTab] = useState('buy'); + const [supportedAccounts, setSupportedAccounts] = useState(accounts); + const activeTab = getMarketActionFromSearchParams(searchParams); + const selectedAccount = validRouteAccountCode || getFallbackMarketAccountCode(accounts); const regionCodes = useLoad(marketAPI.getMarketRegionCodes); const nativeLocale = useLoad(getNativeLocale); - const swapStatus = useLoad(getSwapStatus, [accounts]); - - const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); - - const title = t('generic.buySell'); const { agreedTerms: agreedBTCDirectOTCTerms, @@ -69,14 +66,18 @@ export const Market = ({ agreedTerms: agreedPocketOTCTerms, } = useVendorTerms(config?.frontend.skipPocketOTCDisclaimer ?? false); - // keep account list in sync and ensure a valid selected account. + // keep account list in sync. useEffect(() => { setSupportedAccounts(accounts); - if (!selectedAccount || !accounts.some(account => account.code === selectedAccount)) { - const accountOfConnectedKeystore = accounts.find(account => account.keystore.connected); - setSelectedAccount(accountOfConnectedKeystore?.code || accounts[0]?.code || ''); + }, [accounts]); + + // keep URLs normalized to include the selected account. + useEffect(() => { + if (validRouteAccountCode || !selectedAccount) { + return; } - }, [accounts, selectedAccount]); + navigate(`/market/select/${selectedAccount}?tab=${activeTab}`, { replace: true }); + }, [activeTab, navigate, selectedAccount, validRouteAccountCode]); // update region Select component when `regionList` or `config` gets populated. useEffect(() => { @@ -94,6 +95,7 @@ export const Market = ({ // if user had selected no region before, do not pre-select any. if (config.frontend.selectedExchangeRegion === '') { + setSelectedRegion(''); return; } @@ -108,8 +110,9 @@ export const Market = ({ //Region is available in the list const regionAvailable = !!(regionCodes.find(code => code === userRegion)); //Pre-selecting the region - setSelectedRegion(regionAvailable ? userRegion : ''); - }, [regionCodes, config, nativeLocale]); + const nextRegion = regionAvailable ? userRegion : ''; + setSelectedRegion(nextRegion); + }, [regionCodes, config, nativeLocale, setRegions, setSelectedRegion]); const buyDealsResponse = useLoad(selectedAccount ? () => marketAPI.getMarketDeals('buy', selectedAccount, selectedRegion) : null, [selectedAccount, selectedRegion]); const sellDealsResponse = useLoad(selectedAccount ? () => marketAPI.getMarketDeals('sell', selectedAccount, selectedRegion) : null, [selectedAccount, selectedRegion]); @@ -128,7 +131,7 @@ export const Market = ({ const handleAccountChange = async (accountCode: string) => { if (await promptConnectKeystore(accountCode)) { - setSelectedAccount(accountCode); + navigate(`/market/select/${accountCode}?tab=${activeTab}`, { replace: true }); } }; @@ -179,16 +182,6 @@ export const Market = ({ } }; - const handleChangeTab = (tab: marketAPI.TMarketAction) => { - setActiveTab(tab); - setSearchParams(`?tab=${tab}`); - }; - - useEffect(() => { - const tab = searchParams.get('tab') as marketAPI.TMarketAction | null ; - setActiveTab(tab || 'buy'); - }, [searchParams]); - const goToVendor = async (vendor: marketAPI.TVendorName) => { if (!vendor) { return; @@ -229,102 +222,84 @@ export const Market = ({ } }; - const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; - return ( -
- - - setInfo(undefined)} - open={!!info} - > - {info && ( - - )} - -
- {title} - - } /> - - -
- {regions.length ? ( + <> + setInfo(undefined)} + open={!!info} + > + {info && ( + + )} + + + +
+ {regions.length ? ( + <> + {activeTab !== 'swap' && ( <> - - {activeTab !== 'swap' && ( + + +
+ + setInfo({ + action: activeTab, + vendorName: 'region', + paymentFees: {} + })} /> +
+ + {activeTab !== 'otc' && ( <> -
- - setInfo({ - action: activeTab, - vendorName: 'region', - paymentFees: {} - })} />
- - {activeTab !== 'otc' && ( - <> - -
- -
- - )} )} - -
- {(activeTab === 'swap' || !!selectedAccount) && ( - - )} - -
- ) : } -
-
-
- - - -
+ )} + +
+ {(activeTab === 'swap' || !!selectedAccount) && ( + + )} + +
+ + ) : } +
+ + + ); }; diff --git a/frontends/web/src/routes/market/marketplace-layout.tsx b/frontends/web/src/routes/market/marketplace-layout.tsx new file mode 100644 index 0000000000..e67e8f3e52 --- /dev/null +++ b/frontends/web/src/routes/market/marketplace-layout.tsx @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { AccountCode, TAccount } from '@/api/account'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { isBitcoinOnly } from '@/routes/account/utils'; +import { MarketGuide } from './guide'; +import { MarketTab } from './components/markettab'; +import { useMarketContext } from './market-context'; +import { useMarketplaceTabNavigation } from './use-marketplace-tab-navigation'; +import { getFallbackMarketAccountCode, getMarketActionFromSearchParams } from './utils'; + +type TProps = { + accounts: TAccount[]; + children: ReactNode; + code: AccountCode; +}; + +const MarketplaceLayoutContent = ({ accounts, children, code }: TProps) => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const { showSwap } = useMarketContext(); + + const selectedAccountCode = code || getFallbackMarketAccountCode(accounts); + const activeTab = getMarketActionFromSearchParams(searchParams); + const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); + const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; + const handleChangeTab = useMarketplaceTabNavigation(accounts, selectedAccountCode); + + return ( +
+ + +
{t('generic.buySell')}} /> + + {children} + + + +
+ ); +}; + +export const MarketplaceLayout = ({ accounts, children, code }: TProps) => { + return ( + + {children} + + ); +}; diff --git a/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts b/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts new file mode 100644 index 0000000000..8678174e21 --- /dev/null +++ b/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useNavigate } from 'react-router-dom'; +import type { TAccount } from '@/api/account'; +import type { TMarketplaceTab } from './components/markettab'; + +export const useMarketplaceTabNavigation = ( + accounts: TAccount[], + selectedAccountCode: string, +) => { + const navigate = useNavigate(); + + return (tab: TMarketplaceTab) => { + if (tab === 'insure') { + const bitsurancePath = accounts.some(({ bitsuranceStatus }) => bitsuranceStatus) + ? `/market/bitsurance/dashboard${selectedAccountCode ? `/${selectedAccountCode}` : ''}` + : `/market/bitsurance${selectedAccountCode ? `/${selectedAccountCode}` : ''}`; + navigate(bitsurancePath); + return; + } + const marketSelectPath = selectedAccountCode ? `/market/select/${selectedAccountCode}` : '/market/select'; + navigate(`${marketSelectPath}?tab=${tab}`); + }; +}; diff --git a/frontends/web/src/routes/market/utils.ts b/frontends/web/src/routes/market/utils.ts index cc547789d2..b6ed0e55c7 100644 --- a/frontends/web/src/routes/market/utils.ts +++ b/frontends/web/src/routes/market/utils.ts @@ -1,6 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 -import type { TVendorName } from '@/api/market'; +import type { TMarketAction, TVendorName } from '@/api/market'; +import type { TAccount } from '@/api/account'; + +export const getFallbackMarketAccountCode = (accounts: TAccount[]) => { + return accounts.find(account => account.keystore.connected)?.code + || accounts[0]?.code + || ''; +}; + +export const getMarketActionFromSearchParams = ( + searchParams: URLSearchParams, +): TMarketAction => { + const tab = searchParams.get('tab'); + switch (tab) { + case 'buy': + case 'sell': + case 'spend': + case 'swap': + case 'otc': + return tab; + default: + return 'buy'; + } +}; /** * Gets formatted name for vendors. diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index 76304cf6e4..c3f43d4ffd 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -7,6 +7,8 @@ import { TDevices } from '@/api/devices'; import { AddAccount } from './account/add/add-account'; import { Moonpay } from './market/moonpay'; import { Market } from './market/market'; +import { MarketplaceLayout } from './market/marketplace-layout'; +import { MarketProvider } from './market/market-context'; import { Pocket } from './market/pocket'; import { BTCDirect } from './market/btcdirect'; import { BTCDirectOTC } from './market/btcdirect-otc'; @@ -38,6 +40,7 @@ import { Bitsurance } from './bitsurance/bitsurance'; import { BitsuranceAccount } from './bitsurance/account'; import { BitsuranceWidget } from './bitsurance/widget'; import { BitsuranceDashboard } from './bitsurance/dashboard'; +import { BitsuranceLayout, BitsuranceWidgetLayout } from './bitsurance/bitsurance-layout'; import { ConnectScreenWalletConnect } from './account/walletconnect/connect'; import { DashboardWalletConnect } from './account/walletconnect/dashboard'; import { AllAccounts } from '@/routes/accounts/all-accounts'; @@ -160,6 +163,57 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp code={''} /> ); + const BitsuranceWidgetRouteEl = ( + + {BitsuranceWidgetEl} + + ); + + const BitsuranceEl = ( + + ); + + const BitsuranceDashboardEl = ( + + ); + + const BitsuranceIntroRouteEl = ( + + + {BitsuranceEl} + + + ); + + const BitsuranceAccountRouteEl = ( + + + {BitsuranceAccountEl} + + + ); + + const BitsuranceDashboardRouteEl = ( + + + {BitsuranceDashboardEl} + + + ); + const AccDashboardWC = ( ); + const MarketSelectEl = ( + + + {MarketEl} + + + ); + + const BitsuranceRoutesEl = ( + + + + + + ); + + const MarketplaceRoutesEl = ( + + + + + + + + + ); + const PocketBuyEl = ( } /> + - - @@ -315,18 +398,6 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp - - }/> - - - - - - - - - }/> - diff --git a/frontends/web/src/routes/settings/more.tsx b/frontends/web/src/routes/settings/more.tsx index 5a427d7f41..47be5a5519 100644 --- a/frontends/web/src/routes/settings/more.tsx +++ b/frontends/web/src/routes/settings/more.tsx @@ -9,7 +9,7 @@ import { GlobalBanners } from '@/components/banners'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useOnlyVisitableOnMobile } from '@/hooks/onlyvisitableonmobile'; import { useDarkmode } from '@/hooks/darkmode'; -import { CogDark, CogLight, ShieldDark, ShieldLight } from '@/components/icon'; +import { CogDark, CogLight } from '@/components/icon'; import { TDevices } from '@/api/devices'; import { useLoad } from '@/hooks/api'; import { getVersion } from '@/api/bitbox02'; @@ -57,17 +57,6 @@ export const More = ({ devices }: Props) => { onClick={() => navigate('/settings')} canUpgrade={canUpgrade} /> - - {isDarkMode - ? - : } - {t('sidebar.insurance')} -
- } - onClick={() => navigate('/bitsurance/bitsurance')} - /> From f0d250e39bdc59c7bcd9642a68dabf660b566e87 Mon Sep 17 00:00:00 2001 From: thisconnect Date: Tue, 9 Jun 2026 11:55:37 +0200 Subject: [PATCH 2/2] frontend: simplify market routing state Drop unnecessary supported-accounts state. Simplify routing without the layout wrapper. Move market logic into MarketContext. Drop the one-use tab reader because the tab is controlled by app. Treat account code as one of the account codes. Update market tab tests. --- .../web/src/routes/bitsurance/account.tsx | 56 +++-- .../routes/bitsurance/bitsurance-layout.tsx | 67 ------ .../web/src/routes/bitsurance/bitsurance.tsx | 123 +++++----- .../web/src/routes/bitsurance/dashboard.tsx | 192 +++++++++------- .../web/src/routes/bitsurance/widget.tsx | 60 ++--- .../market/components/markettab.test.tsx | 53 +++-- .../routes/market/components/markettab.tsx | 29 ++- .../web/src/routes/market/market-context.tsx | 52 ++++- frontends/web/src/routes/market/market.tsx | 215 ++++++++---------- .../src/routes/market/marketplace-layout.tsx | 59 ----- .../market/use-marketplace-tab-navigation.ts | 24 -- frontends/web/src/routes/market/utils.ts | 18 +- frontends/web/src/routes/router.tsx | 91 ++------ 13 files changed, 467 insertions(+), 572 deletions(-) delete mode 100644 frontends/web/src/routes/bitsurance/bitsurance-layout.tsx delete mode 100644 frontends/web/src/routes/market/marketplace-layout.tsx delete mode 100644 frontends/web/src/routes/market/use-marketplace-tab-navigation.ts diff --git a/frontends/web/src/routes/bitsurance/account.tsx b/frontends/web/src/routes/bitsurance/account.tsx index cfdf46953c..c6227bd986 100644 --- a/frontends/web/src/routes/bitsurance/account.tsx +++ b/frontends/web/src/routes/bitsurance/account.tsx @@ -4,12 +4,15 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { TAccount } from '@/api/account'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { MarketTab } from '@/routes/market/components/markettab'; import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; import { Spinner } from '@/components/spinner/Spinner'; import { View, ViewContent } from '@/components/view/view'; import { bitsuranceLookup } from '@/api/bitsurance'; import { alertUser } from '@/components/alert/Alert'; import { connectKeystore } from '@/api/keystores'; +import { BitsuranceGuide } from './guide'; type TProps = { accounts: TAccount[]; @@ -87,26 +90,39 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { } return ( - - - { btcAccounts.length === 0 ? ( -
{t('bitsuranceAccount.noAccount')}
- ) : ( - + + +
{t('generic.buySell')}} /> + - )} - - + + + { btcAccounts.length === 0 ? ( +
{t('bitsuranceAccount.noAccount')}
+ ) : ( + + )} +
+
+ + + + ); }; diff --git a/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx b/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx deleted file mode 100644 index 711d56dac3..0000000000 --- a/frontends/web/src/routes/bitsurance/bitsurance-layout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactNode } from 'react'; -import { matchPath, useLocation } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import type { AccountCode, TAccount } from '@/api/account'; -import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; -import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbutton'; -import { MarketTab } from '@/routes/market/components/markettab'; -import { useMarketContext } from '@/routes/market/market-context'; -import { useMarketplaceTabNavigation } from '@/routes/market/use-marketplace-tab-navigation'; -import { getFallbackMarketAccountCode } from '@/routes/market/utils'; -import { BitsuranceGuide } from './guide'; - -type TProps = { - accounts: TAccount[]; - children: ReactNode; - code: AccountCode; -}; - -export const BitsuranceLayout = ({ accounts, children, code }: TProps) => { - const { t } = useTranslation(); - const { pathname } = useLocation(); - const { showSwap } = useMarketContext(); - - const selectedAccountCode = code || getFallbackMarketAccountCode(accounts); - const isBitsuranceDashboard = !!matchPath({ path: '/market/bitsurance/dashboard/:code', end: true }, pathname); - - const handleChangeTab = useMarketplaceTabNavigation(accounts, selectedAccountCode); - - return ( -
- - -
{t('generic.buySell')}}> - {isBitsuranceDashboard && ( - - )} -
- - {children} -
- -
-
- ); -}; - -export const BitsuranceWidgetLayout = ({ children }: { children: ReactNode }) => { - const { t } = useTranslation(); - - return ( -
- - -
{t('generic.buySell')}} /> - {children} - - - -
- ); -}; diff --git a/frontends/web/src/routes/bitsurance/bitsurance.tsx b/frontends/web/src/routes/bitsurance/bitsurance.tsx index aeeb67c35b..d28b3cc42c 100644 --- a/frontends/web/src/routes/bitsurance/bitsurance.tsx +++ b/frontends/web/src/routes/bitsurance/bitsurance.tsx @@ -5,6 +5,8 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AccountCode, TAccount } from '@/api/account'; import { bitsuranceLookup } from '@/api/bitsurance'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { MarketTab } from '@/routes/market/components/markettab'; import { alertUser } from '@/components/alert/Alert'; import { A } from '@/components/anchor/anchor'; import { Button } from '@/components/forms'; @@ -13,6 +15,7 @@ import { Column, ColumnButtons, ResponsiveGrid } from '@/components/layout'; import { View, ViewContent } from '@/components/view/view'; import { useDarkmode } from '@/hooks/darkmode'; import { i18n } from '@/i18n/i18n'; +import { BitsuranceGuide } from './guide'; import style from './bitsurance.module.css'; type TProps = { @@ -24,6 +27,7 @@ export const Bitsurance = ({ accounts, code }: TProps) => { const navigate = useNavigate(); const { t } = useTranslation(); const { isDarkMode } = useDarkmode(); + const [insuredAccounts, setInsuredAccounts] = useState([]); const [scanDone, setScanDone] = useState(false); const [scanLoading, setScanLoading] = useState(false); @@ -83,59 +87,72 @@ export const Bitsurance = ({ accounts, code }: TProps) => { } return ( - - -

{t('bitsurance.intro.text1', { amount })}

-
- - -

- {t('bitsurance.insure.title')} -

-

{t('bitsurance.insure.text')}

-
    -
  • {t('bitsurance.insure.listItem1')}
  • -
  • {t('bitsurance.insure.listItem2')}
  • -
  • {t('bitsurance.insure.listItem3')}
  • -
-

- {t('bitsurance.insure.text2')} {' '} - {t('bitsurance.intro.link')}. -

-

- {t('bitsurance.insure.text3')} -

- - - -
- -

- {t('bitsurance.detect.title')} -

-

{t('bitsurance.detect.text')}

- {!insuredAccounts.length && scanDone && ( -

- {t('bitsurance.detect.notInsured')} -

- )} - - - +
+ + +
{t('generic.buySell')}} /> + + + +

{t('bitsurance.intro.text1', { amount })}

+
+ + +

+ {t('bitsurance.insure.title')} +

+

{t('bitsurance.insure.text')}

+
    +
  • {t('bitsurance.insure.listItem1')}
  • +
  • {t('bitsurance.insure.listItem2')}
  • +
  • {t('bitsurance.insure.listItem3')}
  • +
+

+ {t('bitsurance.insure.text2')} {' '} + {t('bitsurance.intro.link')}. +

+

+ {t('bitsurance.insure.text3')} +

+ + + +
+ +

+ {t('bitsurance.detect.title')} +

+

{t('bitsurance.detect.text')}

+ {!insuredAccounts.length && scanDone && ( +

+ {t('bitsurance.detect.notInsured')} +

+ )} + + + -
-
-
-
-
+ + +
+
+
+ + + + ); }; diff --git a/frontends/web/src/routes/bitsurance/dashboard.tsx b/frontends/web/src/routes/bitsurance/dashboard.tsx index a1ffae6fa6..ba4e037307 100644 --- a/frontends/web/src/routes/bitsurance/dashboard.tsx +++ b/frontends/web/src/routes/bitsurance/dashboard.tsx @@ -7,6 +7,9 @@ import { AccountCode, TAccount, getBalance } from '@/api/account'; import { TAccountDetails, TDetailStatus, bitsuranceLookup } from '@/api/bitsurance'; import { useMountedRef } from '@/hooks/mount'; import { TAccountsByKeystore, getAccountsByKeystore, isAmbiguousName } from '@/routes/account/utils'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbutton'; +import { MarketTab } from '@/routes/market/components/markettab'; import { Button } from '@/components/forms'; import { alertUser } from '@/components/alert/Alert'; import { View, ViewContent } from '@/components/view/view'; @@ -16,6 +19,7 @@ import { Balances } from '@/routes/account/summary/accountssummary'; import { Skeleton } from '@/components/skeleton/skeleton'; import { ExternalLink, GreenDot, OrangeDot, RedDot, YellowDot } from '@/components/icon'; import { HorizontallyCenteredSpinner } from '@/components/spinner/SpinnerAnimation'; +import { BitsuranceGuide } from './guide'; import style from './dashboard.module.css'; type TProps = { @@ -50,6 +54,7 @@ const AccountStatusIcon = ({ status }: TAccountStatusIconProps) => { export const BitsuranceDashboard = ({ accounts, code }: TProps) => { const navigate = useNavigate(); const { t } = useTranslation(); + const mounted = useMountedRef(); const [balances, setBalances] = useState(); const [insurances, setInsurances] = useState(); @@ -104,95 +109,110 @@ export const BitsuranceDashboard = ({ accounts, code }: TProps) => { }, [accountsByKeystore, mounted]); return ( - - - -
-

- {t('bitsurance.dashboard.title')} -

- -
- -
- {accountsByKeystore?.length && insurances ? accountsByKeystore.map(({ accounts, keystore }) => ( - anyAccountInsured({ accounts, keystore }) && ( -
-

{keystore.name} - { isAmbiguousName(keystore.name, accountsByKeystore) ? ( - // Disambiguate accounts group by adding the fingerprint. - // The most common case where this would happen is when adding accounts from the - // same seed using different passphrases. - ({keystore.rootFingerprint}) - ) : null } + + +

+
{t('generic.buySell')}}> + +
+ + + + +
+

+ {t('bitsurance.dashboard.title')}

-
- {accounts?.length ? accounts.map(account => { - const balance = balances && balances[account.code]; - const insurance = insurances[account.code]; - return insurance ? ( -
-
-

- {accounts.filter(ac => ac.code === account.code).map(ac => ac.name)} -

- - { balance ? ( - - ) : } - -
- -
-

- {t('bitsurance.dashboard.coverage')} -

-

- {insurance.details.maxCoverageFormatted} - {' '} - {insurance.details.currency} -

-
- -
-
- -

- {t('bitsurance.dashboard.' + insurance.status)} -

-
- -
- - - {t('bitsurance.dashboard.supportLink')} - -
-
-
+ +
+
+ {accountsByKeystore?.length && insurances ? accountsByKeystore.map(({ accounts, keystore }) => ( + anyAccountInsured({ accounts, keystore }) && ( +
+

{keystore.name} + { isAmbiguousName(keystore.name, accountsByKeystore) ? ( + // Disambiguate accounts group by adding the fingerprint. + // The most common case where this would happen is when adding accounts from the + // same seed using different passphrases. + ({keystore.rootFingerprint}) + ) : null } +

+
+ {accounts?.length ? accounts.map(account => { + const balance = balances && balances[account.code]; + const insurance = insurances[account.code]; + return insurance ? ( +
+
+

+ {accounts.filter(ac => ac.code === account.code).map(ac => ac.name)} +

+ + { balance ? ( + + ) : } + +
+ +
+

+ {t('bitsurance.dashboard.coverage')} +

+

+ {insurance.details.maxCoverageFormatted} + {' '} + {insurance.details.currency} +

+
+ +
+
+ +

+ {t('bitsurance.dashboard.' + insurance.status)} +

+
+ +
+ + + {t('bitsurance.dashboard.supportLink')} + +
+
+
+ +
+ ) : null; + }) : }
- ) : null; - }) : } -
+
+ ) + )) : }
- ) - )) : } -
-
-
+ + +
+ + + ); }; diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index 3ce6e06fe6..78e525eadd 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType } from 'request-address'; import { useConfig } from '@/contexts/ConfigProvider'; import { getInfo, signBTCMessageUnusedAddress, type ScriptType } from '@/api/account'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; import { Spinner } from '@/components/spinner/Spinner'; import { BitsuranceTerms } from '@/components/terms/bitsurance-terms'; import { useLoad } from '@/hooks/api'; @@ -14,6 +15,7 @@ import { alertUser } from '@/components/alert/Alert'; import { getBitsuranceURL } from '@/api/bitsurance'; import { convertScriptType } from '@/utils/request-addess'; import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe'; +import { BitsuranceGuide } from './guide'; import style from './widget.module.css'; type TProps = { @@ -132,32 +134,38 @@ export const BitsuranceWidget = ({ code }: TProps) => { }; return ( - <> -
- { !agreedTerms ? ( - setAgreedTerms(true)} - /> - ) : ( -
- - {!iframeLoaded && } - +
+ + +
{t('generic.buySell')}} /> +
+ { !agreedTerms ? ( + setAgreedTerms(true)} + /> + ) : ( +
+ + {!iframeLoaded && } + +
+ )}
- )} -
- + + + + ); }; diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 821f0ac840..29dde25af5 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -8,6 +8,13 @@ import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MarketTab } from './markettab'; import { useConfig } from '@/contexts/ConfigProvider'; +import { useMarketContext } from '@/routes/market/market-context'; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ @@ -16,7 +23,17 @@ vi.mock('@/contexts/ConfigProvider', () => ({ })), })); +vi.mock('@/routes/market/market-context', () => ({ + useMarketContext: vi.fn(() => ({ + regions: [], + selectedRegion: '', + setSelectedRegion: vi.fn(), + showSwap: true, + })), +})); + const mockUseConfig = vi.mocked(useConfig); +const mockUseMarketContext = vi.mocked(useMarketContext); describe('routes/market/components/markettab', () => { beforeEach(() => { @@ -25,6 +42,12 @@ describe('routes/market/components/markettab', () => { config: { frontend: {}, backend: {} } as TConfig, setConfig: vi.fn(), }); + mockUseMarketContext.mockReturnValue({ + regions: [], + selectedRegion: '', + setSelectedRegion: vi.fn(), + showSwap: true, + }); }); it('shows the new badge on swap when enabled', async () => { @@ -40,9 +63,9 @@ describe('routes/market/components/markettab', () => { render( , ); @@ -62,47 +85,45 @@ describe('routes/market/components/markettab', () => { render( , ); expect(screen.queryByTestId('swap-new-badge')).not.toBeInTheDocument(); }); - it('emits swap tab selection when swap is clicked', async () => { + it('navigates to swap when swap is clicked', async () => { const user = userEvent.setup(); - const onChangeTab = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: /generic\.swap/ })); - expect(onChangeTab).toHaveBeenCalledWith('swap'); + expect(mockNavigate).toHaveBeenCalledWith('/market/select/code-345?tab=swap'); }); - it('emits insure tab selection when insure is clicked', async () => { + it('navigates to bitsurance when insure is clicked', async () => { const user = userEvent.setup(); - const onChangeTab = vi.fn(); render( , ); await user.click(screen.getByRole('button', { name: /generic\.insure/ })); - expect(onChangeTab).toHaveBeenCalledWith('insure'); + expect(mockNavigate).toHaveBeenCalledWith('/market/bitsurance/code-456'); }); it('shows the new badge on otc when enabled', async () => { @@ -118,9 +139,9 @@ describe('routes/market/components/markettab', () => { render( , ); diff --git a/frontends/web/src/routes/market/components/markettab.tsx b/frontends/web/src/routes/market/components/markettab.tsx index 7027d5ed96..81de98fabb 100644 --- a/frontends/web/src/routes/market/components/markettab.tsx +++ b/frontends/web/src/routes/market/components/markettab.tsx @@ -1,26 +1,43 @@ // SPDX-License-Identifier: Apache-2.0 +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { PillButton, PillButtonGroup } from '../../../components/pillbuttongroup/pillbuttongroup'; +import type { AccountCode, TAccount } from '@/api/account'; import type { TMarketAction } from '@/api/market'; +import { useMarketContext } from '@/routes/market/market-context'; +import { PillButton, PillButtonGroup } from '@/components/pillbuttongroup/pillbuttongroup'; import { NewBadge } from '@/components/new-badge/new-badge'; import style from './markettab.module.css'; export type TMarketplaceTab = TMarketAction | 'insure'; type TProps = { - onChangeTab: (tab: TMarketplaceTab) => void; + accounts: TAccount[]; activeTab: TMarketplaceTab; - showSwap: boolean; + code: AccountCode; }; - export const MarketTab = ({ - onChangeTab, + accounts, activeTab, - showSwap, + code, }: TProps) => { const { t } = useTranslation(); + const navigate = useNavigate(); + const { showSwap } = useMarketContext(); + + const onChangeTab = (tab: TMarketplaceTab) => { + if (tab === 'insure') { + navigate( + accounts.some(({ bitsuranceStatus }) => bitsuranceStatus) + ? `/market/bitsurance/dashboard/${code}` + : `/market/bitsurance/${code}` + ); + return; + } + navigate(`/market/select/${code}?tab=${tab}`); + }; + return ( >; setSelectedRegion: Dispatch>; showSwap?: boolean; }; @@ -25,10 +29,53 @@ export const MarketProvider = ({ accounts, children, }: TProps) => { + const { i18n } = useTranslation(); + const [regions, setRegions] = useState([]); const [selectedRegion, setSelectedRegion] = useState(''); const [showSwap, setShowSwap] = useState(); + + const { config } = useConfig(); + const { nativeLocale } = useContext(AppContext); + const swapStatus = useLoad(getSwapStatus, [accounts]); + const regionCodes = useLoad(getMarketRegionCodes); + + // update region Select component when `regionList` or `config` gets populated. + useEffect(() => { + if (!regionCodes || !config) { + return; + } + const regionNames = new Intl.DisplayNames([i18n.language], { type: 'region' }); + const regions: TOption[] = regionCodes.map(code => ({ + value: code, + label: regionNames.of(code) || code + })); + + regions.sort((a, b) => a.label.localeCompare(b.label, i18n.language)); + setRegions(regions); + + // if user had selected no region before, do not pre-select any. + if (config.frontend.selectedExchangeRegion === '') { + setSelectedRegion(''); + return; + } + + if (config.frontend.selectedExchangeRegion) { + // pre-select config region + setSelectedRegion(config.frontend.selectedExchangeRegion); + return; + } + + // user never selected a region preference, will derive it from native locale. + const userRegion = getRegionNameFromLocale(nativeLocale || ''); + // region is available in the list + const regionAvailable = !!(regionCodes.find(code => code === userRegion)); + // pre-selecting the region + const nextRegion = regionAvailable ? userRegion : ''; + setSelectedRegion(nextRegion); + }, [regionCodes, config, nativeLocale, setRegions, setSelectedRegion, i18n.language]); + useEffect(() => { if (swapStatus?.available !== undefined) { @@ -40,7 +87,6 @@ export const MarketProvider = ({ diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 9a5d5acee9..806f431515 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -5,21 +5,20 @@ import { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { SingleValue } from 'react-select'; -import { i18n } from '@/i18n/i18n'; import * as marketAPI from '@/api/market'; import { getSwapStatus } from '@/api/swap'; import { AccountCode, TAccount } from '@/api/account'; import { View, ViewContent } from '@/components/view/view'; import { useLoad } from '@/hooks/api'; import { useVendorTerms } from '@/hooks/vendor-iframe-terms'; -import { getRegionNameFromLocale } from '@/i18n/utils'; -import { getFallbackMarketAccountCode, getMarketActionFromSearchParams, getVendorFormattedName } from './utils'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { MarketTab } from './components/markettab'; +import { getFallbackMarketAccountCode, getVendorFormattedName } from './utils'; import { Spinner } from '@/components/spinner/Spinner'; import { Dialog } from '@/components/dialog/dialog'; import { alertUser } from '@/components/alert/Alert'; import { InfoButton } from '@/components/infobutton/infobutton'; import { Deals } from './components/deals'; -import { getNativeLocale } from '@/api/nativelocale'; import { useConfig } from '@/contexts/ConfigProvider'; import { CountrySelect, TOption } from './components/countryselect'; import { getBTCDirectOTCLink, getPocketOTCLink, InfoContent, TInfoContentProps } from './components/infocontent'; @@ -27,6 +26,8 @@ import { GroupedAccountSelector } from '@/components/groupedaccountselector/grou import { connectAnyKeystore, connectKeystore } from '@/api/keystores'; import { open } from '@/api/system'; import { useMarketContext } from './market-context'; +import { MarketGuide } from './guide'; +import { isBitcoinOnly } from '../account/utils'; import style from './market.module.css'; type TProps = { @@ -39,24 +40,22 @@ export const Market = ({ code, }: TProps) => { const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + + const activeTab: marketAPI.TMarketAction = searchParams.get('tab') as marketAPI.TMarketAction || 'buy'; + const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); + const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; + const { config, setConfig } = useConfig(); const navigate = useNavigate(); - const [searchParams] = useSearchParams(); const { regions, selectedRegion, - setRegions, setSelectedRegion, } = useMarketContext(); - const validRouteAccountCode = accounts.some(account => account.code === code) ? code : ''; const [info, setInfo] = useState(); - const [supportedAccounts, setSupportedAccounts] = useState(accounts); - const activeTab = getMarketActionFromSearchParams(searchParams); - const selectedAccount = validRouteAccountCode || getFallbackMarketAccountCode(accounts); - - const regionCodes = useLoad(marketAPI.getMarketRegionCodes); - const nativeLocale = useLoad(getNativeLocale); + const selectedAccount = code || getFallbackMarketAccountCode(accounts); const { agreedTerms: agreedBTCDirectOTCTerms, @@ -66,53 +65,13 @@ export const Market = ({ agreedTerms: agreedPocketOTCTerms, } = useVendorTerms(config?.frontend.skipPocketOTCDisclaimer ?? false); - // keep account list in sync. - useEffect(() => { - setSupportedAccounts(accounts); - }, [accounts]); - // keep URLs normalized to include the selected account. useEffect(() => { - if (validRouteAccountCode || !selectedAccount) { + if (!selectedAccount) { return; } navigate(`/market/select/${selectedAccount}?tab=${activeTab}`, { replace: true }); - }, [activeTab, navigate, selectedAccount, validRouteAccountCode]); - - // update region Select component when `regionList` or `config` gets populated. - useEffect(() => { - if (!regionCodes || !config) { - return; - } - const regionNames = new Intl.DisplayNames([i18n.language], { type: 'region' }); - const regions: TOption[] = regionCodes.map(code => ({ - value: code, - label: regionNames.of(code) || code - })); - - regions.sort((a, b) => a.label.localeCompare(b.label, i18n.language)); - setRegions(regions); - - // if user had selected no region before, do not pre-select any. - if (config.frontend.selectedExchangeRegion === '') { - setSelectedRegion(''); - return; - } - - if (config.frontend.selectedExchangeRegion) { - // pre-select config region - setSelectedRegion(config.frontend.selectedExchangeRegion); - return; - } - - // user never selected a region preference, will derive it from native locale. - const userRegion = getRegionNameFromLocale(nativeLocale || ''); - //Region is available in the list - const regionAvailable = !!(regionCodes.find(code => code === userRegion)); - //Pre-selecting the region - const nextRegion = regionAvailable ? userRegion : ''; - setSelectedRegion(nextRegion); - }, [regionCodes, config, nativeLocale, setRegions, setSelectedRegion]); + }, [activeTab, navigate, selectedAccount]); const buyDealsResponse = useLoad(selectedAccount ? () => marketAPI.getMarketDeals('buy', selectedAccount, selectedRegion) : null, [selectedAccount, selectedRegion]); const sellDealsResponse = useLoad(selectedAccount ? () => marketAPI.getMarketDeals('sell', selectedAccount, selectedRegion) : null, [selectedAccount, selectedRegion]); @@ -121,7 +80,7 @@ export const Market = ({ const otcDealsResponse = useLoad(selectedAccount ? () => marketAPI.getMarketDeals('otc', selectedAccount, selectedRegion) : null, [selectedAccount, selectedRegion]); const promptConnectKeystore = async (accountCode: string): Promise => { - const account = supportedAccounts.find(acc => acc.code === accountCode); + const account = accounts.find(acc => acc.code === accountCode); if (!account) { return false; } @@ -151,7 +110,6 @@ export const Market = ({ } }; - const getDealReponse = (action: marketAPI.TMarketAction) => { switch (action) { case 'buy': @@ -223,83 +181,94 @@ export const Market = ({ }; return ( - <> - setInfo(undefined)} - open={!!info} - > - {info && ( - + + +
{t('generic.buySell')}} /> + - )} -
- - -
- {regions.length ? ( - <> - {activeTab !== 'swap' && ( + setInfo(undefined)} + open={!!info} + > + {info && ( + + )} + + + +
+ {regions.length ? ( <> - - -
- - setInfo({ - action: activeTab, - vendorName: 'region', - paymentFees: {} - })} /> -
- - {activeTab !== 'otc' && ( + {activeTab !== 'swap' && ( <> +
- + setInfo({ + action: activeTab, + vendorName: 'region', + paymentFees: {} + })} />
+ + {activeTab !== 'otc' && ( + <> + +
+ +
+ + )} )} - - )} -
- {(activeTab === 'swap' || !!selectedAccount) && ( - - )} - -
- - ) : } -
-
-
- +
+ {(activeTab === 'swap' || !!selectedAccount) && ( + + )} + +
+ + ) : } +
+
+
+ + + + ); }; diff --git a/frontends/web/src/routes/market/marketplace-layout.tsx b/frontends/web/src/routes/market/marketplace-layout.tsx deleted file mode 100644 index e67e8f3e52..0000000000 --- a/frontends/web/src/routes/market/marketplace-layout.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactNode } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import type { AccountCode, TAccount } from '@/api/account'; -import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; -import { isBitcoinOnly } from '@/routes/account/utils'; -import { MarketGuide } from './guide'; -import { MarketTab } from './components/markettab'; -import { useMarketContext } from './market-context'; -import { useMarketplaceTabNavigation } from './use-marketplace-tab-navigation'; -import { getFallbackMarketAccountCode, getMarketActionFromSearchParams } from './utils'; - -type TProps = { - accounts: TAccount[]; - children: ReactNode; - code: AccountCode; -}; - -const MarketplaceLayoutContent = ({ accounts, children, code }: TProps) => { - const { t } = useTranslation(); - const [searchParams] = useSearchParams(); - const { showSwap } = useMarketContext(); - - const selectedAccountCode = code || getFallbackMarketAccountCode(accounts); - const activeTab = getMarketActionFromSearchParams(searchParams); - const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); - const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; - const handleChangeTab = useMarketplaceTabNavigation(accounts, selectedAccountCode); - - return ( -
- - -
{t('generic.buySell')}} /> - - {children} - - - -
- ); -}; - -export const MarketplaceLayout = ({ accounts, children, code }: TProps) => { - return ( - - {children} - - ); -}; diff --git a/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts b/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts deleted file mode 100644 index 8678174e21..0000000000 --- a/frontends/web/src/routes/market/use-marketplace-tab-navigation.ts +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { useNavigate } from 'react-router-dom'; -import type { TAccount } from '@/api/account'; -import type { TMarketplaceTab } from './components/markettab'; - -export const useMarketplaceTabNavigation = ( - accounts: TAccount[], - selectedAccountCode: string, -) => { - const navigate = useNavigate(); - - return (tab: TMarketplaceTab) => { - if (tab === 'insure') { - const bitsurancePath = accounts.some(({ bitsuranceStatus }) => bitsuranceStatus) - ? `/market/bitsurance/dashboard${selectedAccountCode ? `/${selectedAccountCode}` : ''}` - : `/market/bitsurance${selectedAccountCode ? `/${selectedAccountCode}` : ''}`; - navigate(bitsurancePath); - return; - } - const marketSelectPath = selectedAccountCode ? `/market/select/${selectedAccountCode}` : '/market/select'; - navigate(`${marketSelectPath}?tab=${tab}`); - }; -}; diff --git a/frontends/web/src/routes/market/utils.ts b/frontends/web/src/routes/market/utils.ts index b6ed0e55c7..b94f8dda42 100644 --- a/frontends/web/src/routes/market/utils.ts +++ b/frontends/web/src/routes/market/utils.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import type { TMarketAction, TVendorName } from '@/api/market'; +import type { TVendorName } from '@/api/market'; import type { TAccount } from '@/api/account'; export const getFallbackMarketAccountCode = (accounts: TAccount[]) => { @@ -9,22 +9,6 @@ export const getFallbackMarketAccountCode = (accounts: TAccount[]) => { || ''; }; -export const getMarketActionFromSearchParams = ( - searchParams: URLSearchParams, -): TMarketAction => { - const tab = searchParams.get('tab'); - switch (tab) { - case 'buy': - case 'sell': - case 'spend': - case 'swap': - case 'otc': - return tab; - default: - return 'buy'; - } -}; - /** * Gets formatted name for vendors. */ diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index c3f43d4ffd..de23615c61 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -7,7 +7,6 @@ import { TDevices } from '@/api/devices'; import { AddAccount } from './account/add/add-account'; import { Moonpay } from './market/moonpay'; import { Market } from './market/market'; -import { MarketplaceLayout } from './market/marketplace-layout'; import { MarketProvider } from './market/market-context'; import { Pocket } from './market/pocket'; import { BTCDirect } from './market/btcdirect'; @@ -40,7 +39,6 @@ import { Bitsurance } from './bitsurance/bitsurance'; import { BitsuranceAccount } from './bitsurance/account'; import { BitsuranceWidget } from './bitsurance/widget'; import { BitsuranceDashboard } from './bitsurance/dashboard'; -import { BitsuranceLayout, BitsuranceWidgetLayout } from './bitsurance/bitsurance-layout'; import { ConnectScreenWalletConnect } from './account/walletconnect/connect'; import { DashboardWalletConnect } from './account/walletconnect/dashboard'; import { AllAccounts } from '@/routes/accounts/all-accounts'; @@ -163,54 +161,20 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp code={''} /> ); - const BitsuranceWidgetRouteEl = ( - - {BitsuranceWidgetEl} - - ); - const BitsuranceEl = ( - - ); - - const BitsuranceDashboardEl = ( - - ); - - const BitsuranceIntroRouteEl = ( + const BitsuranceIntroEl = ( - - {BitsuranceEl} - - - ); - - const BitsuranceAccountRouteEl = ( - - - {BitsuranceAccountEl} - + code={''} /> ); const BitsuranceDashboardRouteEl = ( - - {BitsuranceDashboardEl} - + code={''} /> ); @@ -267,36 +231,6 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp /> ); - const MarketSelectEl = ( - - - {MarketEl} - - - ); - - const BitsuranceRoutesEl = ( - - - - - - ); - - const MarketplaceRoutesEl = ( - - - - - - - - - ); - const PocketBuyEl = ( } /> - + + + + + + + + + + + + + } />