diff --git a/e2e/TEST_MAP.md b/e2e/TEST_MAP.md index 6bb5fe61d..3c666e1e4 100644 --- a/e2e/TEST_MAP.md +++ b/e2e/TEST_MAP.md @@ -15,8 +15,8 @@ Playwright runs 3 projects (`playwright.config.ts`): **smoke** (`@smoke`-tagged, Desktop Chrome), **full** (everything else, Desktop Chrome), **mobile** (`@mobile`-tagged, Pixel 7 viewport — off CI, `pnpm e2e:mobile`). -73 specs across 5 top-level dirs: `general/` (8), `index-dtf/` (17), -`yield-dtf/` (6), `smoke/` (12), `flows/` (30). `index-dtf/` and `yield-dtf/` +75 specs across 5 top-level dirs: `general/` (8), `index-dtf/` (18), +`yield-dtf/` (6), `smoke/` (12), `flows/` (31). `index-dtf/` and `yield-dtf/` hold render/lifecycle/mobile specs per route; `flows/` holds deeper behavior/write/edge-case specs (desktop only, no `@mobile` tags anywhere in the directory). @@ -53,6 +53,7 @@ the directory). | Settings / Roles | [settings/lifecycle](tests/index-dtf/settings/lifecycle.spec.ts), [settings/distribute-fees](tests/index-dtf/settings/distribute-fees.spec.ts), [settings/fee-edge](tests/index-dtf/settings/fee-edge.spec.ts), [flows/settings](tests/flows/settings.spec.ts) | roster skeleton→roster; any-wallet `distributeFees()`; platformFee=100 shows Unavailable not a fabricated split; snapshot-scaled fee %s; registry-read-failure→UNAVAILABLE; zero-denominator/zero-numerator edges; public roles roster; governance cards; disconnected hides submit control | partial | yes (lifecycle only) | — | | Manage | [manage/render](tests/index-dtf/manage/render.spec.ts) | form renders offline | none | yes | SIWE→upload→save write flow | | Factsheet | [factsheet/render](tests/index-dtf/factsheet/render.spec.ts) | renders offline | none | yes | performance math (CSV, inception clamp) | +| Navigation – DTF switcher | [navigation/dtf-switcher](tests/index-dtf/navigation/dtf-switcher.spec.ts) | cross-DTF switch keeps the current section (governance→governance, cross-chain); issuance-panel switcher keeps `/issuance`; address search narrows to one option; ticker search ignores address hex + no-match empty list; inactive target falls back auctions→overview; mobile menu switch closes the parent sheet | none | yes | keyboard-only selection, unsupported-status filtering | ## Yield DTF (`/:chain/token/:tokenId/*`) diff --git a/e2e/tests/index-dtf/navigation/dtf-switcher.spec.ts b/e2e/tests/index-dtf/navigation/dtf-switcher.spec.ts new file mode 100644 index 000000000..2980eeb4d --- /dev/null +++ b/e2e/tests/index-dtf/navigation/dtf-switcher.spec.ts @@ -0,0 +1,166 @@ +import { expect, test } from '../../../harness' +import { REGISTRY } from '../../../helpers/registry' +import { loadSnapshot } from '../../../helpers/snapshots' + +// DTF switcher (nav logo / mobile pages menu): opening the popover lists the +// discover catalog and selecting another DTF lands on the SAME section for that +// DTF — the whole point of the control (stay on governance, change DTF). The +// inactive-DTF case re-routes away from Auctions, which inactive DTFs disable. + +const base = REGISTRY.find((d) => d.chainId === 8453 && !d.deprecated)! // lcap +const bsc = REGISTRY.find((d) => d.chainId === 56 && d.slug === 'cmc20')! +const deprecated = REGISTRY.find((d) => d.deprecated)! // base/vtf + +interface DtfSnapshot { + dtf: { token: { name: string; symbol: string } } +} + +const symbolOf = (dir: string) => + loadSnapshot(`${dir}/dtf.json`).dtf.token.symbol + +const optionFor = (page: import('@playwright/test').Page, address: string) => + page.locator( + `[data-testid="dtf-switcher-option"][data-address="${address.toLowerCase()}"]` + ) + +test('dtf switcher: desktop nav keeps the governance section across DTFs @smoke', async ({ + harness, +}) => { + const page = harness.page + await harness.goto(base, 'governance') + await expect(page.getByTestId('governance-proposals').first()).toBeVisible({ + timeout: 20_000, + }) + + await page.getByTestId('dtf-switcher-trigger').click() + await expect(page.getByTestId('dtf-switcher-content')).toBeVisible() + + const target = optionFor(page, bsc.address) + await expect(target).toBeVisible({ timeout: 15_000 }) + await target.click() + + // Same section, different DTF — no detour through discover/overview. + await expect(page).toHaveURL( + new RegExp(`/bsc/index-dtf/${bsc.address}/governance$`, 'i') + ) + await expect(page.getByTestId('governance-proposals').first()).toBeVisible({ + timeout: 20_000, + }) + await expect(page.getByTestId('dtf-switcher-content')).toHaveCount(0) +}) + +test('dtf switcher: search narrows the list to the typed DTF @smoke', async ({ + harness, +}) => { + const page = harness.page + await harness.goto(base, 'overview') + await expect(page.getByTestId('overview-dtf-symbol')).toHaveText( + `$${symbolOf(base.snapshotDir)}`, + { timeout: 20_000 } + ) + + await page.getByTestId('dtf-switcher-trigger').click() + const options = page.getByTestId('dtf-switcher-option') + await expect(options.first()).toBeVisible({ timeout: 15_000 }) + const total = await options.count() + expect(total).toBeGreaterThan(1) + + // Filter by the destination's address: cmdk matches the item keywords. + await page.getByTestId('dtf-switcher-search').fill(bsc.address) + await expect(options).toHaveCount(1) + await expect(optionFor(page, bsc.address)).toBeVisible() +}) + +test('dtf switcher: ticker search ignores address hex noise', async ({ + harness, +}) => { + const page = harness.page + await harness.goto(base, 'overview') + await page.getByTestId('dtf-switcher-trigger').click() + + const options = page.getByTestId('dtf-switcher-option') + await expect(options.first()).toBeVisible({ timeout: 15_000 }) + + // Ticker queries match symbols/names only — addresses would fuzzy-match every + // short hex-ish query and drown the intended row. + await page.getByTestId('dtf-switcher-search').fill(symbolOf(bsc.snapshotDir)) + await expect(options).toHaveCount(1) + await expect(optionFor(page, bsc.address)).toBeVisible() + + await page.getByTestId('dtf-switcher-search').fill('zzzznotadtf') + await expect(options).toHaveCount(0) +}) + +test('dtf switcher: issuance panel switcher keeps the issuance section', async ({ + harness, +}) => { + const page = harness.page + await harness.goto(base, 'issuance') + await expect(page.getByTestId('dtf-issuance')).toBeVisible({ + timeout: 20_000, + }) + + await page.getByTestId('dtf-switcher-trigger-issuance').click() + const target = optionFor(page, bsc.address) + await expect(target).toBeVisible({ timeout: 15_000 }) + await target.click() + + await expect(page).toHaveURL( + new RegExp(`/bsc/index-dtf/${bsc.address}/issuance$`, 'i') + ) + await expect(page.getByTestId('dtf-issuance')).toBeVisible({ + timeout: 20_000, + }) +}) + +test('dtf switcher: inactive DTF falls back to overview from auctions', async ({ + harness, +}) => { + const page = harness.page + await harness.goto(base, 'auctions') + await page.getByTestId('dtf-switcher-trigger').click() + + const target = optionFor(page, deprecated.address) + await expect(target).toBeVisible({ timeout: 15_000 }) + await target.click() + + // Auctions are disabled for inactive DTFs, so the switch lands on overview + // rather than a route the destination refuses to render. + await expect(page).toHaveURL( + new RegExp(`/base/index-dtf/${deprecated.address}/overview$`, 'i') + ) + await expect(page.getByTestId('overview-dtf-symbol')).toHaveText( + `$${symbolOf(deprecated.snapshotDir)}`, + { timeout: 20_000 } + ) +}) + +test('dtf switcher: mobile pages menu switches DTF in place @mobile', async ({ + harness, +}, testInfo) => { + test.skip( + testInfo.project.name !== 'mobile', + 'mobile chrome only (bottom-nav portal menu)' + ) + const page = harness.page + await harness.goto(base, 'governance') + await expect(page.getByTestId('governance-proposals').first()).toBeVisible({ + timeout: 20_000, + }) + + await page.getByTestId('dtf-nav-mobile-menu').click() + await page.getByTestId('dtf-switcher-trigger-mobile').click() + + const target = optionFor(page, bsc.address) + await expect(target).toBeVisible({ timeout: 15_000 }) + await target.click() + + await expect(page).toHaveURL( + new RegExp(`/bsc/index-dtf/${bsc.address}/governance$`, 'i') + ) + // Parent menu closes with the popover — no stale overlay over the new page. + await expect(page.getByTestId('dtf-switcher-trigger-mobile')).toHaveCount(0) + await expect(page.getByTestId('governance-proposals').first()).toBeVisible({ + timeout: 20_000, + }) +}) diff --git a/src/views/index-dtf/components/dtf-switcher/index.tsx b/src/views/index-dtf/components/dtf-switcher/index.tsx new file mode 100644 index 000000000..6236d2597 --- /dev/null +++ b/src/views/index-dtf/components/dtf-switcher/index.tsx @@ -0,0 +1,213 @@ +import ChainLogo from '@/components/icons/ChainLogo' +import TokenLogo from '@/components/token-logo' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Skeleton } from '@/components/ui/skeleton' +import useIndexDTFList, { IndexDTFItem } from '@/hooks/useIndexDTFList' +import { cn } from '@/lib/utils' +import { indexDTFAtom } from '@/state/dtf/atoms' +import { getFolioRoute } from '@/utils' +import { ROUTES } from '@/utils/constants' +import { useTrackIndexDTFClick } from '@/views/index-dtf/hooks/useTrackIndexDTFPage' +import { Trans, useLingui } from '@lingui/react/macro' +import { useAtomValue } from 'jotai' +import { Check } from 'lucide-react' +import { ReactNode, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +const DTF_SECTIONS: string[] = [ + ROUTES.OVERVIEW, + ROUTES.ISSUANCE, + ROUTES.GOVERNANCE, + ROUTES.AUCTIONS, + ROUTES.SETTINGS, +] + +// Keeps the user on the section they were browsing when they switch DTF +export const useCurrentDTFSection = () => { + const { pathname } = useLocation() + + return useMemo(() => { + const segments = pathname.split('/').filter(Boolean) + const dtfIndex = segments.indexOf('index-dtf') + const section = dtfIndex >= 0 ? segments[dtfIndex + 2] : undefined + + return section && DTF_SECTIONS.includes(section) ? section : ROUTES.OVERVIEW + }, [pathname]) +} + +const isActiveDTF = (dtf: IndexDTFItem) => dtf.status === 'active' + +// Addresses only match address-shaped queries, otherwise their hex characters +// fuzzy-match every short ticker search +const filterDTF = (_value: string, search: string, keywords?: string[]) => { + const query = search.trim().toLowerCase() + + if (!query) return 1 + + const [address = '', symbol = '', name = ''] = keywords ?? [] + + if (query.startsWith('0x')) { + return address.toLowerCase().startsWith(query) ? 1 : 0 + } + + if (symbol.toLowerCase().startsWith(query)) return 1 + if (name.toLowerCase().startsWith(query)) return 0.9 + + return symbol.toLowerCase().includes(query) || + name.toLowerCase().includes(query) + ? 0.5 + : 0 +} + +const DTFOption = ({ + dtf, + section, + isCurrent, + onSelect, +}: { + dtf: IndexDTFItem + section: string + isCurrent: boolean + onSelect: (dtf: IndexDTFItem, route: string) => void +}) => { + // Auctions are unavailable on inactive DTFs, so land on overview instead + const route = + section === ROUTES.AUCTIONS && !isActiveDTF(dtf) ? ROUTES.OVERVIEW : section + + return ( + + onSelect(dtf, getFolioRoute(dtf.address, dtf.chainId, route)) + } + > +
+ + +
+
+
${dtf.symbol}
+
{dtf.name}
+
+ {isCurrent && } +
+ ) +} + +const DTFSwitcher = ({ + children, + align = 'start', + className, + onNavigate, +}: { + children: ReactNode + align?: 'start' | 'center' | 'end' + className?: string + onNavigate?: () => void +}) => { + const { t } = useLingui() + const [open, setOpen] = useState(false) + const navigate = useNavigate() + const currentDTF = useAtomValue(indexDTFAtom) + const section = useCurrentDTFSection() + const { data } = useIndexDTFList() + const { trackClick } = useTrackIndexDTFClick(section, 'navigation') + + const dtfs = useMemo(() => { + if (!data) return undefined + + return data + .filter((dtf) => dtf.status !== 'unsupported') + .sort((a, b) => { + if (isActiveDTF(a) !== isActiveDTF(b)) return isActiveDTF(a) ? -1 : 1 + return (b.marketCap || 0) - (a.marketCap || 0) + }) + }, [data]) + + const onSelect = (dtf: IndexDTFItem, route: string) => { + trackClick('switch_dtf', { + target_ca: dtf.address, + target_ticker: dtf.symbol, + target_chain: dtf.chainId, + }) + setOpen(false) + onNavigate?.() + navigate(route) + } + + return ( + + {children} + + + + {!dtfs ? ( +
+ + + +
+ ) : ( + + + No DTFs found. + + + {dtfs.map((dtf) => ( + + ))} + + + )} +
+
+
+ ) +} + +export default DTFSwitcher diff --git a/src/views/index-dtf/components/navigation/index.tsx b/src/views/index-dtf/components/navigation/index.tsx index e7dc60c73..c0460164a 100644 --- a/src/views/index-dtf/components/navigation/index.tsx +++ b/src/views/index-dtf/components/navigation/index.tsx @@ -14,11 +14,13 @@ import { import { BRIDGED_INDEX_DTFS, CHAIN_TAGS, ROUTES } from '@/utils/constants' import { shortenAddress } from '@/utils' import { ChainId } from '@/utils/chains' +import DTFSwitcher from '@/views/index-dtf/components/dtf-switcher' import { useLingui } from '@lingui/react/macro' import { useAtomValue } from 'jotai' import { ArrowLeftRight, Blend, + ChevronsUpDown, CirclePlus, Copy, EllipsisVertical, @@ -143,18 +145,34 @@ const NavigationHeader = () => { } return ( -
-
- -
+
+ + +
-
- {indexDTF?.token.symbol} -
{!!indexDTF?.chainId && ( setOpen((current) => !current)} @@ -404,7 +424,35 @@ export const DTFMobilePagesMenuButton = ({ role="menu" className="fixed bottom-20 right-2 z-50 flex max-w-[calc(100vw-1rem)] w-80 flex-col gap-2 rounded-3xl border border-card bg-card/90 p-2 shadow-[0_-16px_60px_rgba(0,0,0,0.18)] backdrop-blur-[7px] dark:shadow-[0_-22px_90px_rgba(0,0,0,0.82),0_0_0_1px_rgba(255,255,255,0.08)] sm:bottom-24 lg:hidden" > -
+ setOpen(false)} + > + + +
{ticker ? t`${ticker} pages` : t`DTF pages`}
diff --git a/src/views/index-dtf/issuance/dtf-switcher-header.tsx b/src/views/index-dtf/issuance/dtf-switcher-header.tsx new file mode 100644 index 000000000..3f0750763 --- /dev/null +++ b/src/views/index-dtf/issuance/dtf-switcher-header.tsx @@ -0,0 +1,51 @@ +import TokenLogo from '@/components/token-logo' +import { indexDTFAtom, indexDTFBrandAtom } from '@/state/dtf/atoms' +import DTFSwitcher from '@/views/index-dtf/components/dtf-switcher' +import { useLingui } from '@lingui/react/macro' +import { useAtomValue } from 'jotai' +import { ChevronsUpDown } from 'lucide-react' + +// Lets the user swap into another DTF without leaving the trade surface — the +// widget's own token row lives in react-zapper and can't host this +const IssuanceDTFSwitcher = () => { + const { t } = useLingui() + const dtf = useAtomValue(indexDTFAtom) + const brand = useAtomValue(indexDTFBrandAtom) + + if (!dtf) return null + + return ( + + + + ) +} + +export default IssuanceDTFSwitcher diff --git a/src/views/index-dtf/issuance/index.tsx b/src/views/index-dtf/issuance/index.tsx index a09300600..d81a9136f 100644 --- a/src/views/index-dtf/issuance/index.tsx +++ b/src/views/index-dtf/issuance/index.tsx @@ -9,6 +9,7 @@ import ZapperWrapper from '../components/zapper/zapper-wrapper' import useTrackIndexDTFPage from '../hooks/useTrackIndexDTFPage' import useIsComplianceRestricted from '@/hooks/use-is-compliance-restricted' import AsyncMint from './async-mint' +import IssuanceDTFSwitcher from './dtf-switcher-header' import { wizardStepAtom } from './async-mint/atoms' import { panelModeAtom } from './atoms' import PanelModeSwitch from './panel-mode-switch' @@ -43,6 +44,7 @@ const IndexDTFIssuance = () => { ) : (
+