From 7b7ab45758205775143d79468ab462bd612d62b4 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 layout and add the Insure tab.Move Bitsurance entry points to /market/bitsurance, update navigationentry points, and keep the single-account auto-connect flow. Also preserve account-code marketplace tab paths, clean stale no-account routing, and cover the new tab/path helpers in tests. --- CHANGELOG.md | 1 + frontends/web/__mocks__/i18n.ts | 2 + frontends/web/src/app.tsx | 5 - .../bottom-navigation/bottom-navigation.tsx | 2 +- .../src/components/bottom-navigation/utils.ts | 2 +- .../web/src/components/sidebar/sidebar.tsx | 15 +- .../src/contexts/BackNavigationContext.tsx | 19 +- frontends/web/src/locales/en/app.json | 1 + .../routes/account/components/insuredtag.tsx | 2 +- .../web/src/routes/bitsurance/account.tsx | 51 ++-- .../web/src/routes/bitsurance/bitsurance.tsx | 150 ++++++------ .../web/src/routes/bitsurance/dashboard.tsx | 182 +++++++------- .../src/routes/bitsurance/widget.module.css | 5 - .../web/src/routes/bitsurance/widget.tsx | 65 +++-- .../components/marketplace-navigation.tsx | 65 +++++ .../market/components/markettab.module.css | 10 + .../market/components/markettab.test.tsx | 30 ++- .../routes/market/components/markettab.tsx | 17 +- frontends/web/src/routes/market/market.tsx | 228 +++++++++--------- .../src/routes/market/marketplace-layout.tsx | 100 ++++++++ frontends/web/src/routes/router.tsx | 31 +-- frontends/web/src/routes/settings/more.tsx | 13 +- 22 files changed, 575 insertions(+), 421 deletions(-) create mode 100644 frontends/web/src/routes/market/components/marketplace-navigation.tsx create mode 100644 frontends/web/src/routes/market/marketplace-layout.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c3710b6269..bd4c5ae769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- Move insurance to Marketplace ## v4.51.0 - Bundle BitBox02 and BitBox02 Nova firmware version v9.26.1 diff --git a/frontends/web/__mocks__/i18n.ts b/frontends/web/__mocks__/i18n.ts index 841067dfac..8682ab0da5 100644 --- a/frontends/web/__mocks__/i18n.ts +++ b/frontends/web/__mocks__/i18n.ts @@ -11,6 +11,8 @@ i18n resources: { en: { translation: { + 'generic.insure': 'Insure', + 'generic.swap': 'Swap', 'key': 'value' } } 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/sidebar/sidebar.tsx b/frontends/web/src/components/sidebar/sidebar.tsx index ccdf820c20..6bc5e2e21e 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 userInSpecificAccountMarketPage = pathname.startsWith('/market'); return (
@@ -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..6346dc39f5 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,20 @@ const implicitBackRules: ImplicitBackRule[] = [ previousPattern: '/account/:code', })), { - currentPattern: '/bitsurance/bitsurance', - previousPattern: '/settings/more', + currentPattern: '/market/bitsurance', + previousPattern: '/market/select', + }, + { + currentPattern: '/market/bitsurance', + previousPattern: '/market/select/:code', + }, + { + currentPattern: '/market/bitsurance/dashboard', + previousPattern: '/market/select', + }, + { + currentPattern: '/market/bitsurance/dashboard', + previousPattern: '/market/select/:code', }, ]; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..96050525c0 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1022,6 +1022,7 @@ "enable": "Enable", "enabled_false": "Disabled", "enabled_true": "Enabled", + "insure": "Insure", "max": "Max", "new": "New", "noOptionOnIos": "Not available on iOS", diff --git a/frontends/web/src/routes/account/components/insuredtag.tsx b/frontends/web/src/routes/account/components/insuredtag.tsx index dbd7596e08..d34d369f21 100644 --- a/frontends/web/src/routes/account/components/insuredtag.tsx +++ b/frontends/web/src/routes/account/components/insuredtag.tsx @@ -8,7 +8,7 @@ import style from './insuredtag.module.css'; export const Insured = () => { 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..70d822dfed 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'; @@ -63,7 +61,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]); @@ -82,7 +80,7 @@ export const BitsuranceAccount = ({ code, accounts }: TProps) => { } finally { setDisabled(false); } - navigate(`/bitsurance/widget/${selected}`); + navigate(`/market/bitsurance/widget/${selected}`); }; if (btcAccounts === undefined) { @@ -90,29 +88,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.tsx b/frontends/web/src/routes/bitsurance/bitsurance.tsx index a1e33a3000..692d42d868 100644 --- a/frontends/web/src/routes/bitsurance/bitsurance.tsx +++ b/frontends/web/src/routes/bitsurance/bitsurance.tsx @@ -9,10 +9,9 @@ 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'; @@ -25,7 +24,7 @@ export const Bitsurance = ({ accounts }: TProps) => { const { t } = useTranslation(); const { isDarkMode } = useDarkmode(); const [insuredAccounts, setInsuredAccounts] = useState([]); - const [redirecting, setRedirecting] = useState(true); + const [redirecting, setRedirecting] = useState(() => accounts.some(({ bitsuranceStatus }) => bitsuranceStatus)); const [scanDone, setScanDone] = useState(false); const [scanLoading, setScanLoading] = useState(false); @@ -34,7 +33,7 @@ export const Bitsurance = ({ accounts }: TProps) => { useEffect(() => { if (accounts.some(({ bitsuranceStatus }) => bitsuranceStatus)) { // replace current history item when redirecting so that the user can go back - navigate('/bitsurance/dashboard', { replace: true }); + navigate('/market/bitsurance/dashboard', { replace: true }); } else { setRedirecting(false); } @@ -46,18 +45,21 @@ export const Bitsurance = ({ accounts }: TProps) => { 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'); + } + } finally { + setScanLoading(false); } }; @@ -74,7 +76,7 @@ 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'); }; if (redirecting) { @@ -82,67 +84,59 @@ export const Bitsurance = ({ accounts }: TProps) => { } 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..c1b16dd464 100644 --- a/frontends/web/src/routes/bitsurance/dashboard.tsx +++ b/frontends/web/src/routes/bitsurance/dashboard.tsx @@ -9,16 +9,13 @@ 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 = { @@ -106,102 +103,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 f16c616577..472785dc83 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 { getConfig } from '@/utils/config'; -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'); 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/marketplace-navigation.tsx b/frontends/web/src/routes/market/components/marketplace-navigation.tsx new file mode 100644 index 0000000000..692a0b9a2f --- /dev/null +++ b/frontends/web/src/routes/market/components/marketplace-navigation.tsx @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect } from 'react'; +import type { TAccount } from '@/api/account'; +import { getSwapStatus } from '@/api/swap'; +import { useLoad } from '@/hooks/api'; +import { MarketTab } from './markettab'; +import type { TMarketplaceTab } from './markettab'; +import type { TMarketAction } from '@/api/market'; +import style from './markettab.module.css'; + +type TProps = { + accounts?: TAccount[]; + activeTab: TMarketplaceTab; + className?: string; + onChangeTab: (tab: TMarketplaceTab) => void; + showSwap?: boolean; +}; + +let cachedShowSwap: boolean | undefined; + +export const getInsurancePath = (accounts?: TAccount[]) => { + return accounts?.some(({ bitsuranceStatus }) => bitsuranceStatus) + ? '/market/bitsurance/dashboard' + : '/market/bitsurance'; +}; + +export const getMarketActionFromSearchParams = (searchParams: URLSearchParams): TMarketAction => { + const tab = searchParams.get('tab'); + if (tab === 'buy' || tab === 'sell' || tab === 'spend' || tab === 'swap' || tab === 'otc') { + return tab; + } + return 'buy'; +}; + +export const getMarketSelectPath = (tab: TMarketAction, code?: string) => { + return `/market/select${code ? `/${code}` : ''}?tab=${tab}`; +}; + +export const MarketplaceNavigation = ({ + accounts, + activeTab, + className = '', + onChangeTab, + showSwap, +}: TProps) => { + const swapStatus = useLoad(showSwap === undefined ? getSwapStatus : null, [accounts]); + const showSwapTab = showSwap ?? swapStatus?.available ?? cachedShowSwap ?? false; + + useEffect(() => { + const nextShowSwap = showSwap ?? swapStatus?.available; + if (nextShowSwap !== undefined) { + cachedShowSwap = nextShowSwap; + } + }, [showSwap, swapStatus?.available]); + + return ( + + ); +}; 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 0ba07526dc..0e16cf1e47 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MarketTab } from './markettab'; +import { getMarketSelectPath } from './marketplace-navigation'; import { getConfig } from '@/utils/config'; vi.mock('@/utils/config', () => ({ @@ -72,13 +73,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: '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: 'Insure' })); + + expect(onChangeTab).toHaveBeenCalledWith('insure'); + }); + it('shows the new badge on otc when enabled', async () => { mockedGetConfig.mockResolvedValue({ frontend: { @@ -97,4 +113,12 @@ describe('routes/market/components/markettab', () => { expect(await screen.findByTestId('otc-new-badge')).toBeInTheDocument(); }); + + it('preserves the account code in market select tab paths', () => { + expect(getMarketSelectPath('sell', 'btc-1')).toBe('/market/select/btc-1?tab=sell'); + }); + + it('builds market select tab paths without account code', () => { + expect(getMarketSelectPath('sell')).toBe('/market/select?tab=sell'); + }); }); diff --git a/frontends/web/src/routes/market/components/markettab.tsx b/frontends/web/src/routes/market/components/markettab.tsx index d088c67aa2..59407f8adb 100644 --- a/frontends/web/src/routes/market/components/markettab.tsx +++ b/frontends/web/src/routes/market/components/markettab.tsx @@ -2,14 +2,16 @@ 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; + className?: string; showSwap: boolean; }; @@ -17,11 +19,12 @@ type TProps = { export const MarketTab = ({ onChangeTab, activeTab, + className, showSwap, }: TProps) => { const { t } = useTranslation(); return ( - + onChangeTab('buy')} @@ -71,6 +74,12 @@ export const MarketTab = ({ /> + onChangeTab('insure')} + > + {t('generic.insure')} + ); }; diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 9153eb3647..0ec73107f3 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -2,17 +2,14 @@ import 'flag-icons'; import { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useOutletContext, 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 { 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'; @@ -21,7 +18,7 @@ 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 { getMarketActionFromSearchParams, getMarketSelectPath } from './components/marketplace-navigation'; import { Deals } from './components/deals'; import { getNativeLocale } from '@/api/nativelocale'; import { getConfig, setConfig } from '@/utils/config'; @@ -30,6 +27,7 @@ import { getBTCDirectOTCLink, getPocketOTCLink, InfoContent, TInfoContentProps } import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; import { connectAnyKeystore, connectKeystore } from '@/api/keystores'; import { open } from '@/api/system'; +import type { TMarketplaceOutletContext } from './marketplace-layout'; import style from './market.module.css'; type TProps = { @@ -43,23 +41,27 @@ export const Market = ({ }: TProps) => { const { t } = useTranslation(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - - const [selectedAccount, setSelectedAccount] = useState(code); - const [selectedRegion, setSelectedRegion] = useState(''); - const [regions, setRegions] = useState([]); + const marketplaceContext = useOutletContext(); + const [searchParams] = useSearchParams(); + const marketAccountCode = marketplaceContext?.marketAccountCode; + const setMarketAccountCode = marketplaceContext?.setMarketAccountCode; + const validRouteAccountCode = accounts.some(account => account.code === code) ? code : ''; + const validMarketAccountCode = accounts.some(account => account.code === marketAccountCode) ? marketAccountCode : ''; + + const [selectedAccount, setSelectedAccount] = useState(validRouteAccountCode || validMarketAccountCode || ''); + const [localSelectedRegion, setLocalSelectedRegion] = useState(''); + const [localRegions, setLocalRegions] = useState([]); const [info, setInfo] = useState(); - const [supportedAccounts, setSupportedAccounts] = useState([]); - const [activeTab, setActiveTab] = useState('buy'); + const [supportedAccounts, setSupportedAccounts] = useState(accounts); + const activeTab = getMarketActionFromSearchParams(searchParams); + const regions = marketplaceContext?.regions ?? localRegions; + const selectedRegion = marketplaceContext?.selectedRegion ?? localSelectedRegion; + const setRegions = marketplaceContext?.setRegions ?? setLocalRegions; + const setSelectedRegion = marketplaceContext?.setSelectedRegion ?? setLocalSelectedRegion; const regionCodes = useLoad(marketAPI.getMarketRegionCodes); const nativeLocale = useLoad(getNativeLocale); const config = useLoad(getConfig); - const swapStatus = useLoad(getSwapStatus, [accounts]); - - const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); - - const title = t('generic.buySell'); const { agreedTerms: agreedBTCDirectOTCTerms, @@ -72,11 +74,23 @@ export const Market = ({ // keep account list in sync and ensure a valid selected account. 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 || ''); + const selectedAccountIsValid = accounts.some(account => account.code === selectedAccount); + const accountOfConnectedKeystore = accounts.find(account => account.keystore.connected); + const nextAccount = validRouteAccountCode + || (selectedAccountIsValid ? selectedAccount : '') + || accountOfConnectedKeystore?.code + || accounts[0]?.code + || ''; + if (nextAccount) { + setMarketAccountCode?.(nextAccount); + if (!validRouteAccountCode) { + navigate(getMarketSelectPath(activeTab, nextAccount), { replace: true }); + } + } + if (nextAccount !== selectedAccount) { + setSelectedAccount(nextAccount); } - }, [accounts, selectedAccount]); + }, [accounts, activeTab, navigate, selectedAccount, setMarketAccountCode, validRouteAccountCode]); // update region Select component when `regionList` or `config` gets populated. useEffect(() => { @@ -94,6 +108,7 @@ export const Market = ({ // if user had selected no region before, do not pre-select any. if (config.frontend.selectedExchangeRegion === '') { + setSelectedRegion(''); return; } @@ -108,8 +123,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 +144,9 @@ export const Market = ({ const handleAccountChange = async (accountCode: string) => { if (await promptConnectKeystore(accountCode)) { + setMarketAccountCode?.(accountCode); setSelectedAccount(accountCode); + navigate(getMarketSelectPath(activeTab, accountCode), { replace: true }); } }; @@ -179,16 +197,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 +237,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..604e72a505 --- /dev/null +++ b/frontends/web/src/routes/market/marketplace-layout.tsx @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { TAccount } from '@/api/account'; +import { Header, GuidedContent, GuideWrapper, Main } from '@/components/layout'; +import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbutton'; +import { isBitcoinOnly } from '@/routes/account/utils'; +import { BitsuranceGuide } from '@/routes/bitsurance/guide'; +import { MarketGuide } from './guide'; +import { + getInsurancePath, + getMarketActionFromSearchParams, + getMarketSelectPath, + MarketplaceNavigation, +} from './components/marketplace-navigation'; +import type { TOption } from './components/countryselect'; +import type { TMarketplaceTab } from './components/markettab'; + +type TProps = { + accounts: TAccount[]; +}; + +export type TMarketplaceOutletContext = { + marketAccountCode?: string; + regions: TOption[]; + selectedRegion: string; + setMarketAccountCode: (accountCode: string) => void; + setRegions: (regions: TOption[]) => void; + setSelectedRegion: (region: string) => void; +}; + +export const MarketplaceLayout = ({ accounts }: TProps) => { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const { code } = useParams(); + const [searchParams] = useSearchParams(); + + const isBitsurance = pathname.startsWith('/market/bitsurance'); + const isMarketSelect = pathname.startsWith('/market/select'); + const showMarketplaceNavigation = !pathname.startsWith('/market/bitsurance/widget'); + const [marketAccountCode, setMarketAccountCode] = useState(isMarketSelect ? code : undefined); + const [regions, setRegions] = useState([]); + const [selectedRegion, setSelectedRegion] = useState(''); + const activeTab: TMarketplaceTab = isBitsurance ? 'insure' : getMarketActionFromSearchParams(searchParams); + const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); + const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; + + useEffect(() => { + if (isMarketSelect && code) { + setMarketAccountCode(code); + } + }, [code, isMarketSelect]); + + const outletContext = useMemo(() => ({ + marketAccountCode, + regions, + selectedRegion, + setMarketAccountCode, + setRegions, + setSelectedRegion, + }), [marketAccountCode, regions, selectedRegion]); + + const handleChangeTab = (tab: TMarketplaceTab) => { + if (tab === 'insure') { + navigate(getInsurancePath(accounts)); + return; + } + navigate(getMarketSelectPath(tab, isMarketSelect ? code || marketAccountCode : marketAccountCode)); + }; + + return ( +
+ + +
{t('generic.buySell')}}> + {pathname.startsWith('/market/bitsurance/dashboard') && ( + + )} +
+ {showMarketplaceNavigation && ( + + )} + +
+ {isBitsurance ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index db58213964..851c304215 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -7,6 +7,7 @@ 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 { Pocket } from './market/pocket'; import { BTCDirect } from './market/btcdirect'; import { BTCDirectOTC } from './market/btcdirect-otc'; @@ -203,6 +204,10 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp /> ); + const MarketplaceLayoutEl = ( + + ); + const PocketBuyEl = ( } /> - - + + + + + }/> + + + + + }/> + + @@ -305,18 +320,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 3bfc0bbae15af3262203826c8498712b73742fb3 Mon Sep 17 00:00:00 2001 From: strmci Date: Wed, 20 May 2026 11:14:26 +0200 Subject: [PATCH 2/2] frontend: move insurance to marketplace updates - remove generic.insure / generic.swap from the shared i18n mock and kept translation setup local to the relevant test. - rename userInSpecificAccountMarketPage to inMarketSection. - remove redirecting state in Bitsurance and derived redirect state from hasBitsuranceAccount. - removed getMarketSelectPath and used explicit market select URL template strings. - remove getInsurancePath and inlined the Bitsurance route decision. - removed unused sidebar.insurance, bitsuranceAccount.title - replaced hidden global cachedShowSwap with market- scoped React context. - replaced React Router outlet context usage with a market-specific React context, following the staging-spark style. - simplify market navigation state in market.tsx --- frontends/web/__mocks__/i18n.ts | 2 - .../web/src/components/sidebar/sidebar.tsx | 4 +- frontends/web/src/locales/en/app.json | 4 +- .../web/src/routes/bitsurance/bitsurance.tsx | 10 ++- .../components/marketplace-navigation.tsx | 20 ++---- .../market/components/markettab.test.tsx | 23 ++++--- .../web/src/routes/market/market-context.tsx | 55 +++++++++++++++ frontends/web/src/routes/market/market.tsx | 68 +++++++++++-------- .../src/routes/market/marketplace-layout.tsx | 54 +++++++-------- 9 files changed, 142 insertions(+), 98 deletions(-) create mode 100644 frontends/web/src/routes/market/market-context.tsx diff --git a/frontends/web/__mocks__/i18n.ts b/frontends/web/__mocks__/i18n.ts index 8682ab0da5..841067dfac 100644 --- a/frontends/web/__mocks__/i18n.ts +++ b/frontends/web/__mocks__/i18n.ts @@ -11,8 +11,6 @@ i18n resources: { en: { translation: { - 'generic.insure': 'Insure', - 'generic.swap': 'Swap', 'key': 'value' } } diff --git a/frontends/web/src/components/sidebar/sidebar.tsx b/frontends/web/src/components/sidebar/sidebar.tsx index 6bc5e2e21e..e9a1a68e27 100644 --- a/frontends/web/src/components/sidebar/sidebar.tsx +++ b/frontends/web/src/components/sidebar/sidebar.tsx @@ -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">
diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 96050525c0..3d60594780 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -409,8 +409,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" @@ -1997,7 +1996,6 @@ "setup": "Setup device", "sidebar": { "device": "Manage device", - "insurance": "Insurance", "leave": "Leave", "settings": "Settings" }, diff --git a/frontends/web/src/routes/bitsurance/bitsurance.tsx b/frontends/web/src/routes/bitsurance/bitsurance.tsx index 692d42d868..9b92db8dd8 100644 --- a/frontends/web/src/routes/bitsurance/bitsurance.tsx +++ b/frontends/web/src/routes/bitsurance/bitsurance.tsx @@ -24,22 +24,20 @@ export const Bitsurance = ({ accounts }: TProps) => { const { t } = useTranslation(); const { isDarkMode } = useDarkmode(); const [insuredAccounts, setInsuredAccounts] = useState([]); - const [redirecting, setRedirecting] = useState(() => accounts.some(({ bitsuranceStatus }) => bitsuranceStatus)); 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 (hasBitsuranceAccount) { // replace current history item when redirecting so that the user can go back navigate('/market/bitsurance/dashboard', { replace: true }); - } else { - setRedirecting(false); } return () => setInsuredAccounts([]); - }, [accounts, navigate]); + }, [accounts, hasBitsuranceAccount, navigate]); const detect = async (redirectToDashboard: boolean) => { setScanLoading(true); @@ -79,7 +77,7 @@ export const Bitsurance = ({ accounts }: TProps) => { navigate('/market/bitsurance/account'); }; - if (redirecting) { + if (hasBitsuranceAccount) { return null; } diff --git a/frontends/web/src/routes/market/components/marketplace-navigation.tsx b/frontends/web/src/routes/market/components/marketplace-navigation.tsx index 692a0b9a2f..de02bce1c5 100644 --- a/frontends/web/src/routes/market/components/marketplace-navigation.tsx +++ b/frontends/web/src/routes/market/components/marketplace-navigation.tsx @@ -7,6 +7,7 @@ import { useLoad } from '@/hooks/api'; import { MarketTab } from './markettab'; import type { TMarketplaceTab } from './markettab'; import type { TMarketAction } from '@/api/market'; +import { useMarketContext } from '../market-context'; import style from './markettab.module.css'; type TProps = { @@ -17,14 +18,6 @@ type TProps = { showSwap?: boolean; }; -let cachedShowSwap: boolean | undefined; - -export const getInsurancePath = (accounts?: TAccount[]) => { - return accounts?.some(({ bitsuranceStatus }) => bitsuranceStatus) - ? '/market/bitsurance/dashboard' - : '/market/bitsurance'; -}; - export const getMarketActionFromSearchParams = (searchParams: URLSearchParams): TMarketAction => { const tab = searchParams.get('tab'); if (tab === 'buy' || tab === 'sell' || tab === 'spend' || tab === 'swap' || tab === 'otc') { @@ -33,10 +26,6 @@ export const getMarketActionFromSearchParams = (searchParams: URLSearchParams): return 'buy'; }; -export const getMarketSelectPath = (tab: TMarketAction, code?: string) => { - return `/market/select${code ? `/${code}` : ''}?tab=${tab}`; -}; - export const MarketplaceNavigation = ({ accounts, activeTab, @@ -44,15 +33,16 @@ export const MarketplaceNavigation = ({ onChangeTab, showSwap, }: TProps) => { + const { setShowSwap, showSwap: contextShowSwap } = useMarketContext(); const swapStatus = useLoad(showSwap === undefined ? getSwapStatus : null, [accounts]); - const showSwapTab = showSwap ?? swapStatus?.available ?? cachedShowSwap ?? false; + const showSwapTab = showSwap ?? swapStatus?.available ?? contextShowSwap ?? false; useEffect(() => { const nextShowSwap = showSwap ?? swapStatus?.available; if (nextShowSwap !== undefined) { - cachedShowSwap = nextShowSwap; + setShowSwap(nextShowSwap); } - }, [showSwap, swapStatus?.available]); + }, [setShowSwap, showSwap, swapStatus?.available]); return ( ({ + useTranslation: () => ({ + t: (key: string) => ({ + 'buy.exchange.buy': 'Buy', + 'buy.exchange.sell': 'Sell', + 'buy.exchange.spend': 'Spend', + 'generic.insure': 'Insure', + 'generic.new': 'New', + 'generic.swap': 'Swap', + }[key] || key), + }), +})); + vi.mock('@/utils/config', () => ({ getConfig: vi.fn(), setConfig: vi.fn(), @@ -113,12 +124,4 @@ describe('routes/market/components/markettab', () => { expect(await screen.findByTestId('otc-new-badge')).toBeInTheDocument(); }); - - it('preserves the account code in market select tab paths', () => { - expect(getMarketSelectPath('sell', 'btc-1')).toBe('/market/select/btc-1?tab=sell'); - }); - - it('builds market select tab paths without account code', () => { - expect(getMarketSelectPath('sell')).toBe('/market/select?tab=sell'); - }); }); 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..d1c2db8b27 --- /dev/null +++ b/frontends/web/src/routes/market/market-context.tsx @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Dispatch, ReactNode, SetStateAction, createContext, useContext, useState } from 'react'; +import type { TOption } from './components/countryselect'; + +type TMarketContext = { + marketAccountCode?: string; + regions: TOption[]; + selectedRegion: string; + setMarketAccountCode: Dispatch>; + setRegions: Dispatch>; + setSelectedRegion: Dispatch>; + setShowSwap: Dispatch>; + showSwap?: boolean; +}; + +const MarketContext = createContext(null); + +type TProps = { + children: ReactNode; + initialMarketAccountCode?: string; +}; + +export const MarketProvider = ({ + children, + initialMarketAccountCode, +}: TProps) => { + const [marketAccountCode, setMarketAccountCode] = useState(initialMarketAccountCode); + const [regions, setRegions] = useState([]); + const [selectedRegion, setSelectedRegion] = useState(''); + const [showSwap, setShowSwap] = useState(); + + 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 0ec73107f3..6c198a774c 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -2,7 +2,7 @@ import 'flag-icons'; import { useState, useEffect } from 'react'; -import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { SingleValue } from 'react-select'; import { i18n } from '@/i18n/i18n'; @@ -18,7 +18,7 @@ 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 { getMarketActionFromSearchParams, getMarketSelectPath } from './components/marketplace-navigation'; +import { getMarketActionFromSearchParams } from './components/marketplace-navigation'; import { Deals } from './components/deals'; import { getNativeLocale } from '@/api/nativelocale'; import { getConfig, setConfig } from '@/utils/config'; @@ -27,7 +27,7 @@ import { getBTCDirectOTCLink, getPocketOTCLink, InfoContent, TInfoContentProps } import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; import { connectAnyKeystore, connectKeystore } from '@/api/keystores'; import { open } from '@/api/system'; -import type { TMarketplaceOutletContext } from './marketplace-layout'; +import { useMarketContext } from './market-context'; import style from './market.module.css'; type TProps = { @@ -41,23 +41,29 @@ export const Market = ({ }: TProps) => { const { t } = useTranslation(); const navigate = useNavigate(); - const marketplaceContext = useOutletContext(); const [searchParams] = useSearchParams(); - const marketAccountCode = marketplaceContext?.marketAccountCode; - const setMarketAccountCode = marketplaceContext?.setMarketAccountCode; + const { + marketAccountCode, + regions, + selectedRegion, + setMarketAccountCode, + setRegions, + setSelectedRegion, + } = useMarketContext(); const validRouteAccountCode = accounts.some(account => account.code === code) ? code : ''; const validMarketAccountCode = accounts.some(account => account.code === marketAccountCode) ? marketAccountCode : ''; const [selectedAccount, setSelectedAccount] = useState(validRouteAccountCode || validMarketAccountCode || ''); - const [localSelectedRegion, setLocalSelectedRegion] = useState(''); - const [localRegions, setLocalRegions] = useState([]); const [info, setInfo] = useState(); const [supportedAccounts, setSupportedAccounts] = useState(accounts); const activeTab = getMarketActionFromSearchParams(searchParams); - const regions = marketplaceContext?.regions ?? localRegions; - const selectedRegion = marketplaceContext?.selectedRegion ?? localSelectedRegion; - const setRegions = marketplaceContext?.setRegions ?? setLocalRegions; - const setSelectedRegion = marketplaceContext?.setSelectedRegion ?? setLocalSelectedRegion; + const selectedAccountIsValid = accounts.some(account => account.code === selectedAccount); + const connectedAccountCode = accounts.find(account => account.keystore.connected)?.code; + const nextSelectedAccount = validRouteAccountCode + || (selectedAccountIsValid ? selectedAccount : '') + || connectedAccountCode + || accounts[0]?.code + || ''; const regionCodes = useLoad(marketAPI.getMarketRegionCodes); const nativeLocale = useLoad(getNativeLocale); @@ -71,26 +77,28 @@ export const Market = ({ agreedTerms: agreedPocketOTCTerms, } = useVendorTerms(!!config?.frontend?.skipPocketOTCDisclaimer); - // keep account list in sync and ensure a valid selected account. + // keep account list in sync. useEffect(() => { setSupportedAccounts(accounts); - const selectedAccountIsValid = accounts.some(account => account.code === selectedAccount); - const accountOfConnectedKeystore = accounts.find(account => account.keystore.connected); - const nextAccount = validRouteAccountCode - || (selectedAccountIsValid ? selectedAccount : '') - || accountOfConnectedKeystore?.code - || accounts[0]?.code - || ''; - if (nextAccount) { - setMarketAccountCode?.(nextAccount); - if (!validRouteAccountCode) { - navigate(getMarketSelectPath(activeTab, nextAccount), { replace: true }); - } + }, [accounts]); + + // ensure a valid selected account. + useEffect(() => { + if (nextSelectedAccount) { + setMarketAccountCode(nextSelectedAccount); } - if (nextAccount !== selectedAccount) { - setSelectedAccount(nextAccount); + if (nextSelectedAccount !== selectedAccount) { + setSelectedAccount(nextSelectedAccount); + } + }, [nextSelectedAccount, selectedAccount, setMarketAccountCode]); + + // keep URLs normalized to include the selected account. + useEffect(() => { + if (validRouteAccountCode || !nextSelectedAccount) { + return; } - }, [accounts, activeTab, navigate, selectedAccount, setMarketAccountCode, validRouteAccountCode]); + navigate(`/market/select/${nextSelectedAccount}?tab=${activeTab}`, { replace: true }); + }, [activeTab, navigate, nextSelectedAccount, validRouteAccountCode]); // update region Select component when `regionList` or `config` gets populated. useEffect(() => { @@ -144,9 +152,9 @@ export const Market = ({ const handleAccountChange = async (accountCode: string) => { if (await promptConnectKeystore(accountCode)) { - setMarketAccountCode?.(accountCode); + setMarketAccountCode(accountCode); setSelectedAccount(accountCode); - navigate(getMarketSelectPath(activeTab, accountCode), { replace: true }); + navigate(`/market/select/${accountCode}?tab=${activeTab}`, { replace: true }); } }; diff --git a/frontends/web/src/routes/market/marketplace-layout.tsx b/frontends/web/src/routes/market/marketplace-layout.tsx index 604e72a505..637e0cadef 100644 --- a/frontends/web/src/routes/market/marketplace-layout.tsx +++ b/frontends/web/src/routes/market/marketplace-layout.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useMemo, useState } from 'react'; +import { useEffect } from 'react'; import { Outlet, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { TAccount } from '@/api/account'; @@ -10,40 +10,27 @@ import { isBitcoinOnly } from '@/routes/account/utils'; import { BitsuranceGuide } from '@/routes/bitsurance/guide'; import { MarketGuide } from './guide'; import { - getInsurancePath, getMarketActionFromSearchParams, - getMarketSelectPath, MarketplaceNavigation, } from './components/marketplace-navigation'; -import type { TOption } from './components/countryselect'; import type { TMarketplaceTab } from './components/markettab'; +import { MarketProvider, useMarketContext } from './market-context'; type TProps = { accounts: TAccount[]; }; -export type TMarketplaceOutletContext = { - marketAccountCode?: string; - regions: TOption[]; - selectedRegion: string; - setMarketAccountCode: (accountCode: string) => void; - setRegions: (regions: TOption[]) => void; - setSelectedRegion: (region: string) => void; -}; - -export const MarketplaceLayout = ({ accounts }: TProps) => { +const MarketplaceLayoutContent = ({ accounts }: TProps) => { const { t } = useTranslation(); const { pathname } = useLocation(); const navigate = useNavigate(); const { code } = useParams(); const [searchParams] = useSearchParams(); + const { marketAccountCode, setMarketAccountCode } = useMarketContext(); const isBitsurance = pathname.startsWith('/market/bitsurance'); const isMarketSelect = pathname.startsWith('/market/select'); const showMarketplaceNavigation = !pathname.startsWith('/market/bitsurance/widget'); - const [marketAccountCode, setMarketAccountCode] = useState(isMarketSelect ? code : undefined); - const [regions, setRegions] = useState([]); - const [selectedRegion, setSelectedRegion] = useState(''); const activeTab: TMarketplaceTab = isBitsurance ? 'insure' : getMarketActionFromSearchParams(searchParams); const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); const translationContext = hasOnlyBTCAccounts ? 'bitcoin' : 'crypto'; @@ -52,23 +39,18 @@ export const MarketplaceLayout = ({ accounts }: TProps) => { if (isMarketSelect && code) { setMarketAccountCode(code); } - }, [code, isMarketSelect]); - - const outletContext = useMemo(() => ({ - marketAccountCode, - regions, - selectedRegion, - setMarketAccountCode, - setRegions, - setSelectedRegion, - }), [marketAccountCode, regions, selectedRegion]); + }, [code, isMarketSelect, setMarketAccountCode]); const handleChangeTab = (tab: TMarketplaceTab) => { if (tab === 'insure') { - navigate(getInsurancePath(accounts)); + const bitsurancePath = accounts.some(({ bitsuranceStatus }) => bitsuranceStatus) + ? '/market/bitsurance/dashboard' + : '/market/bitsurance'; + navigate(bitsurancePath); return; } - navigate(getMarketSelectPath(tab, isMarketSelect ? code || marketAccountCode : marketAccountCode)); + const marketSelectAccountCode = isMarketSelect ? code || marketAccountCode : marketAccountCode; + navigate(`/market/select${marketSelectAccountCode ? `/${marketSelectAccountCode}` : ''}?tab=${tab}`); }; return ( @@ -87,7 +69,7 @@ export const MarketplaceLayout = ({ accounts }: TProps) => { onChangeTab={handleChangeTab} /> )} - + {isBitsurance ? ( @@ -98,3 +80,15 @@ export const MarketplaceLayout = ({ accounts }: TProps) => { ); }; + +export const MarketplaceLayout = ({ accounts }: TProps) => { + const { pathname } = useLocation(); + const { code } = useParams(); + const isMarketSelect = pathname.startsWith('/market/select'); + + return ( + + + + ); +};