diff --git a/__tests__/components/HeaderSearchModal.test.tsx b/__tests__/components/HeaderSearchModal.test.tsx deleted file mode 100644 index 760c2e31bd..0000000000 --- a/__tests__/components/HeaderSearchModal.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import HeaderSearchModal from "@/components/header/header-search/HeaderSearchModal"; - -jest.mock( - "@/components/header/header-search/HeaderSearchModalItem", - () => () => null -); - -jest.mock("@/hooks/useWaves", () => ({ - useWaves: () => ({ - waves: [], - isFetching: false, - error: null, - refetch: jest.fn(), - }), -})); - -jest.mock("@/hooks/useLocalPreference", () => - jest.fn(() => ["PROFILES", jest.fn()]) -); - -jest.mock("@tanstack/react-query", () => { - const actual = jest.requireActual("@tanstack/react-query"); - return { - ...actual, - useQuery: jest.fn(() => ({ - data: [{ handle: "bob", wallet: "0x1" }], - isFetching: false, - error: undefined, - refetch: jest.fn(), - })), - }; -}); - -jest.mock("next/navigation", () => ({ - useRouter: () => ({ push: jest.fn() }), - usePathname: () => "/", - useSearchParams: () => new URLSearchParams(), -})); - -describe("HeaderSearchModal", () => { - it("updates input", () => { - render( {}} />); - const input = screen.getByPlaceholderText("Search"); - fireEvent.change(input, { target: { value: "bob" } }); - expect(input).toHaveValue("bob"); - }); -}); diff --git a/__tests__/components/brain/content/input/BrainContentInput.test.tsx b/__tests__/components/brain/content/input/BrainContentInput.test.tsx index e0fdf19225..3a783c35b5 100644 --- a/__tests__/components/brain/content/input/BrainContentInput.test.tsx +++ b/__tests__/components/brain/content/input/BrainContentInput.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import BrainContentInput from '@/components/brain/content/input/BrainContentInput'; const useWaveDataMock = jest.fn(); -const useCapacitorMock = jest.fn(); +const capacitorMock = jest.fn(); jest.mock('@/hooks/useWaveData', () => ({ useWaveData: (args: any) => useWaveDataMock(args), @@ -10,7 +10,7 @@ jest.mock('@/hooks/useWaveData', () => ({ jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, - default: () => useCapacitorMock(), + default: () => capacitorMock(), })); jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ @@ -22,11 +22,11 @@ jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ describe('BrainContentInput', () => { beforeEach(() => { useWaveDataMock.mockReset(); - useCapacitorMock.mockReset(); + capacitorMock.mockReset(); }); it('returns null when wave is missing', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: false }); + capacitorMock.mockReturnValue({ isCapacitor: false }); useWaveDataMock.mockReturnValue({ data: null }); const { container } = render( @@ -35,7 +35,7 @@ describe('BrainContentInput', () => { }); it('renders creator and passes wave id', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: false }); + capacitorMock.mockReturnValue({ isCapacitor: false }); useWaveDataMock.mockReturnValue({ data: { id: 'w1' } }); render( { it('uses capacitor height and triggers onWaveNotFound', () => { const onCancel = jest.fn(); let onWaveNotFound: (() => void) | null = null; - useCapacitorMock.mockReturnValue({ isCapacitor: true }); + capacitorMock.mockReturnValue({ isCapacitor: true }); useWaveDataMock.mockImplementation((args: any) => { onWaveNotFound = args.onWaveNotFound; return { data: { id: 'w2' } }; diff --git a/__tests__/components/breadcrumb/Breadcrumb.test.tsx b/__tests__/components/breadcrumb/Breadcrumb.test.tsx index e2329226ef..93c311f758 100644 --- a/__tests__/components/breadcrumb/Breadcrumb.test.tsx +++ b/__tests__/components/breadcrumb/Breadcrumb.test.tsx @@ -3,12 +3,12 @@ import Breadcrumb, { Crumb } from '@/components/breadcrumb/Breadcrumb'; jest.mock('next/link', () => ({ __esModule: true, default: ({ href, children }: any) => {children} })); -const useCapacitorMock = jest.fn(); -jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: () => useCapacitorMock() })); +const capacitorMock = jest.fn(); +jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: () => capacitorMock() })); describe('Breadcrumb', () => { beforeEach(() => { - useCapacitorMock.mockReset(); + capacitorMock.mockReset(); }); const crumbs: Crumb[] = [ @@ -18,7 +18,7 @@ describe('Breadcrumb', () => { ]; it('renders crumbs with separators on web', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: false }); + capacitorMock.mockReturnValue({ isCapacitor: false }); render(); const links = screen.getAllByRole('link'); @@ -33,7 +33,7 @@ describe('Breadcrumb', () => { }); it('adds capacitor classes and placeholder when running on capacitor', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: true }); + capacitorMock.mockReturnValue({ isCapacitor: true }); render(); const container = document.querySelector('.capacitorBreadcrumb'); diff --git a/__tests__/components/header/HeaderSearchModal.test.tsx b/__tests__/components/header/HeaderSearchModal.test.tsx index 90759b8437..215d1d6c93 100644 --- a/__tests__/components/header/HeaderSearchModal.test.tsx +++ b/__tests__/components/header/HeaderSearchModal.test.tsx @@ -14,6 +14,10 @@ const useSearchParams = jest.fn(); const useWaves = jest.fn(); const useLocalPreference = jest.fn(); const mockUseDeviceInfo = jest.fn(); +const useAppWalletsMock = jest.fn(); +const useCookieConsentMock = jest.fn(); +const useSidebarSectionsMock = jest.fn(); +const capacitorMock = jest.fn(); jest.mock("react-use", () => { return { @@ -54,6 +58,24 @@ jest.mock( (...args: any[]) => useLocalPreference(...args) ); +jest.mock("@/components/app-wallets/AppWalletsContext", () => ({ + useAppWallets: () => useAppWalletsMock(), +})); +jest.mock("@/components/cookies/CookieConsentContext", () => ({ + useCookieConsent: () => useCookieConsentMock(), +})); +jest.mock("@/hooks/useCapacitor", () => ({ + __esModule: true, + default: () => capacitorMock(), +})); +jest.mock("@/hooks/useSidebarSections", () => { + const actual = jest.requireActual("@/hooks/useSidebarSections"); + return { + __esModule: true, + useSidebarSections: (...args: any[]) => useSidebarSectionsMock(...args), + mapSidebarSectionsToPages: actual.mapSidebarSectionsToPages, + }; +}); jest.mock("@/components/header/header-search/HeaderSearchModalItem", () => { const MockHeaderSearchModalItem = (props: any) => (
{JSON.stringify(props)}
@@ -64,18 +86,31 @@ jest.mock("@/components/header/header-search/HeaderSearchModalItem", () => { const profile = { handle: "alice", wallet: "0x1", display: "Alice", level: 1 }; +const defaultSidebarSections = [ + { + key: "tools", + name: "Tools", + icon: () => null, + items: [ + { name: "Delegation Center", href: "/delegation/delegation-center" }, + ], + subsections: [], + }, +]; + interface SetupOptions { queryImpl?: (params: { queryKey: [QueryKey, string]; profilesRefetch: jest.Mock, []>; nftsRefetch: jest.Mock, []>; + enabled?: boolean; }) => { isFetching: boolean; data: unknown; error?: Error; refetch: jest.Mock, []>; }; - selectedCategory?: "PROFILES" | "NFTS" | "WAVES"; + selectedCategory?: "ALL" | "PROFILES" | "NFTS" | "WAVES" | "PAGES"; wavesReturn?: { waves: unknown[]; isFetching: boolean; @@ -85,16 +120,18 @@ interface SetupOptions { profilesRefetch?: jest.Mock, []>; nftsRefetch?: jest.Mock, []>; wavesRefetch?: jest.Mock, []>; + sidebarSections?: typeof defaultSidebarSections; } function setup(options: SetupOptions = {}) { const { queryImpl, - selectedCategory = "PROFILES", + selectedCategory = "ALL", wavesReturn, profilesRefetch = jest.fn(() => Promise.resolve()), nftsRefetch = jest.fn(() => Promise.resolve()), wavesRefetch = jest.fn(() => Promise.resolve()), + sidebarSections = defaultSidebarSections, } = options; const push = jest.fn(); const onClose = jest.fn(); @@ -106,6 +143,10 @@ function setup(options: SetupOptions = {}) { isMobileDevice: false, hasTouchScreen: false, }); + useAppWalletsMock.mockReturnValue({ appWalletsSupported: true }); + useCookieConsentMock.mockReturnValue({ country: "US" }); + capacitorMock.mockReturnValue({ isIos: false }); + useSidebarSectionsMock.mockReturnValue(sidebarSections); useWaves.mockReturnValue( wavesReturn ?? { waves: [], @@ -116,15 +157,29 @@ function setup(options: SetupOptions = {}) { ); useLocalPreference.mockReturnValue([selectedCategory, jest.fn()]); if (queryImpl) { - useQueryMock.mockImplementation(({ queryKey }) => + useQueryMock.mockImplementation(({ queryKey, enabled }) => queryImpl({ queryKey: queryKey as [QueryKey, string], profilesRefetch, nftsRefetch, + enabled, }) ); } else { - useQueryMock.mockImplementation(({ queryKey }) => { + useQueryMock.mockImplementation(({ queryKey, enabled }) => { + if (enabled === false) { + const refetch = + queryKey[0] === QueryKey.PROFILE_SEARCH + ? profilesRefetch + : nftsRefetch; + return { + isFetching: false, + data: undefined, + error: undefined, + refetch, + }; + } + if (queryKey[0] === QueryKey.PROFILE_SEARCH) { return { isFetching: false, @@ -169,9 +224,156 @@ describe("HeaderSearchModal", () => { setup(); const input = screen.getByRole("textbox", { name: "Search" }); fireEvent.change(input, { target: { value: "abc" } }); + expect( + screen.getByRole("heading", { name: "Profiles" }) + ).toBeInTheDocument(); expect(screen.getByTestId("item")).toBeInTheDocument(); }); + it("clears search input when the clear button is pressed", () => { + setup(); + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "faq" } }); + + const clearButton = screen.getByRole("button", { name: "Clear search" }); + fireEvent.click(clearButton); + + expect(input).toHaveValue(""); + }); + + it("includes navigation pages in search results when query matches", () => { + setup(); + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "Delegation" } }); + + expect(screen.getByRole("heading", { name: "Pages" })).toBeInTheDocument(); + const renderedItems = screen + .getAllByTestId("item") + .map((element) => element.textContent ?? ""); + expect( + renderedItems.some((content) => content.includes('"type":"PAGE"')) + ).toBe(true); + }); + + it("prioritizes exact page title matches ahead of partial matches", async () => { + setup({ + selectedCategory: "PAGES", + sidebarSections: [ + { + key: "tools", + name: "Tools", + icon: () => null, + items: [], + subsections: [ + { + name: "NFT Delegation", + items: [ + { + name: "Delegation FAQs", + href: "/delegation/delegation-faq", + }, + ], + }, + ], + }, + { + key: "about", + name: "About", + icon: () => null, + items: [], + subsections: [ + { + name: "Support", + items: [{ name: "FAQ", href: "/about/faq" }], + }, + ], + }, + ], + queryImpl: () => ({ + isFetching: false, + data: [], + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }), + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "faq" } }); + + const items = await screen.findAllByTestId("item"); + expect(items[0].textContent).toContain('"title":"FAQ"'); + expect(items[1].textContent).toContain('"title":"Delegation FAQs"'); + }); + + it("renders result categories in deterministic order in All view", async () => { + const profiles = [ + { handle: "alice", wallet: "0x1" }, + { handle: "bob", wallet: "0x2" }, + { handle: "carol", wallet: "0x3" }, + ]; + const nfts = [ + { id: 1, name: "NFT One", contract: "0xabc" }, + { id: 2, name: "NFT Two", contract: "0xabc" }, + ]; + const waves = [ + { id: "w1", serial_no: 1 }, + { id: "w2", serial_no: 2 }, + { id: "w3", serial_no: 3 }, + { id: "w4", serial_no: 4 }, + ]; + + setup({ + sidebarSections: [ + { + key: "about", + name: "About", + icon: () => null, + items: [], + subsections: [ + { + name: "Support", + items: [{ name: "FAQ", href: "/about/faq" }], + }, + ], + }, + ], + queryImpl: ({ queryKey }) => { + const [key] = queryKey; + if (key === QueryKey.PROFILE_SEARCH) { + return { + isFetching: false, + data: profiles, + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }; + } + return { + isFetching: false, + data: nfts, + error: undefined, + refetch: jest.fn(() => Promise.resolve()), + }; + }, + wavesReturn: { + waves, + isFetching: false, + error: null, + refetch: jest.fn(() => Promise.resolve()), + }, + }); + + const input = screen.getByRole("textbox", { name: "Search" }); + fireEvent.change(input, { target: { value: "faq" } }); + + const headings = await screen.findAllByRole("heading", { level: 3 }); + expect(headings.map((heading) => heading.textContent)).toEqual([ + "Pages", + "NFTs", + "Profiles", + "Waves", + ]); + }); + it("triggers onClose on click away", () => { const { onClose } = setup(); clickAwayCb(); @@ -188,7 +390,7 @@ describe("HeaderSearchModal", () => { it("shows an error message and allows retry when a search fails", async () => { const profilesRefetch = jest.fn(() => Promise.resolve()); - setup({ + const { wavesRefetch: wavesRefetchMock } = setup({ profilesRefetch, queryImpl: ({ queryKey, profilesRefetch, nftsRefetch }) => { const [key, search] = queryKey; @@ -225,5 +427,6 @@ describe("HeaderSearchModal", () => { fireEvent.click(retryButton); expect(profilesRefetch).toHaveBeenCalled(); + expect(wavesRefetchMock).toHaveBeenCalled(); }); }); diff --git a/__tests__/components/header/HeaderSearchModalItem.test.tsx b/__tests__/components/header/HeaderSearchModalItem.test.tsx index f1edf6f9d7..6dbaf99555 100644 --- a/__tests__/components/header/HeaderSearchModalItem.test.tsx +++ b/__tests__/components/header/HeaderSearchModalItem.test.tsx @@ -1,15 +1,17 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import HeaderSearchModalItem from "@/components/header/header-search/HeaderSearchModalItem"; +import HeaderSearchModalItem, { + HeaderSearchModalItemType, +} from "@/components/header/header-search/HeaderSearchModalItem"; import { MEMES_CONTRACT } from "@/constants"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; const useHoverDirty = jest.fn(); -const useRouter = jest.fn(); jest.mock("react-use", () => ({ useHoverDirty: (...args: any[]) => useHoverDirty(...args), })); -jest.mock("../../../hooks/useDeviceInfo", () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, default: jest.fn(() => ({ isApp: false, @@ -27,6 +29,14 @@ jest.mock("next/link", () => ({ ), })); +const mockUsePathname = jest.fn(); +const mockUseSearchParams = jest.fn(); + +jest.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), + useSearchParams: () => mockUseSearchParams(), +})); + const getProfileTargetRouteMock = jest.fn(() => "/profile-route"); jest.mock("@/helpers/Helpers", () => ({ @@ -35,11 +45,6 @@ jest.mock("@/helpers/Helpers", () => ({ getProfileTargetRoute: () => getProfileTargetRouteMock(), })); -jest.mock("@/components/user/utils/UserCICAndLevel", () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock( "@/components/header/header-search/HeaderSearchModalItemMedia", () => ({ @@ -51,104 +56,124 @@ jest.mock( ); beforeEach(() => { - window.matchMedia = jest - .fn() - .mockReturnValue({ - matches: true, - addListener: jest.fn(), - removeListener: jest.fn(), - }); + globalThis.matchMedia = jest.fn().mockReturnValue({ + matches: true, + addListener: jest.fn(), + removeListener: jest.fn(), + }); jest.clearAllMocks(); }); -it("renders profile item and handles interactions", () => { - useHoverDirty.mockReturnValue(true); - useRouter.mockReturnValue({ query: {} }); - const profile: any = { - handle: "alice", - wallet: "0x1", - display: "Alice", - level: 1, - cic_rating: 2, - tdh: 10, - }; +const renderComponent = ( + content: HeaderSearchModalItemType, + searchValue: string, + isSelected: boolean +) => { const onClose = jest.fn(); const onHover = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); render( - + + + ); - expect(onHover).toHaveBeenCalledWith(true); - const link = screen.getByTestId("link"); - expect(link).toHaveAttribute("href", "/profile-route"); - expect(screen.getByText("Ali")).toBeInTheDocument(); - expect(link.textContent).toContain("Alice"); - expect(screen.getByText("formatted-10")).toBeInTheDocument(); - fireEvent.click(link); - expect(onClose).toHaveBeenCalled(); - expect(screen.getByTestId("level")).toBeInTheDocument(); -}); + return { onClose, onHover }; +}; -it("renders nft item with collection path", () => { - useHoverDirty.mockReturnValue(false); - useRouter.mockReturnValue({ query: {} }); - const nft: any = { - id: 1, - name: "Meme", - contract: MEMES_CONTRACT.toLowerCase(), - icon_url: "", - thumbnail_url: "", - image_url: "", - }; - const onClose = jest.fn(); - const onHover = jest.fn(); - render( - - ); - expect(onHover).toHaveBeenCalledWith(false); - const link = screen.getByTestId("link"); - expect(link).toHaveAttribute("href", "/the-memes/1"); - expect(link.textContent).toContain("Meme"); - expect(link.textContent).toContain("The Memes #1"); - expect(screen.getByTestId("media").textContent).toContain('"nft"'); - fireEvent.click(link); - expect(onClose).toHaveBeenCalled(); -}); +describe("HeaderSearchModalItem", () => { + it("renders profile item and handles interactions", () => { + useHoverDirty.mockReturnValue(true); + mockUsePathname.mockReturnValue("/profile-route"); + const profile: any = { + handle: "alice", + wallet: "0x1", + display: "Alice", + level: 1, + cic_rating: 2, + tdh: 10, + }; + const { onClose, onHover } = renderComponent(profile, "ali", false); + expect(onHover).toHaveBeenCalledWith(true); + const link = screen.getByTestId("link"); + expect(link).toHaveAttribute("href", "/profile-route"); + expect(screen.getByText("alice")).toBeInTheDocument(); + expect(link.textContent).toContain("alice"); + expect(screen.getByText(/TDH: 10 - Level: 1/i)).toBeInTheDocument(); + fireEvent.click(link); + expect(onClose).toHaveBeenCalled(); + }); -it("renders wave item and uses query to build path", () => { - useHoverDirty.mockReturnValue(false); - useRouter.mockReturnValue({ query: { wave: "other" } }); - const wave: any = { - id: "wave1", - name: "Wave 1", - picture: "pic.png", - serial_no: 2, - }; - const onClose = jest.fn(); - const onHover = jest.fn(); - render( - - ); - const link = screen.getByTestId("link"); - expect(link).toHaveAttribute("href", "/waves?wave=wave1"); - expect(link.textContent).toContain("Wave 1"); - expect(link.textContent).toContain("Wave #2"); - expect(screen.getByTestId("media").textContent).toContain("pic.png"); + it("renders nft item with collection path", () => { + useHoverDirty.mockReturnValue(false); + mockUsePathname.mockReturnValue("/the-memes/1"); + const nft: any = { + id: 1, + name: "Meme", + contract: MEMES_CONTRACT.toLowerCase(), + icon_url: "", + thumbnail_url: "", + image_url: "", + }; + const { onClose, onHover } = renderComponent(nft, "me", false); + expect(onHover).toHaveBeenCalledWith(false); + const link = screen.getByTestId("link"); + expect(link).toHaveAttribute("href", "/the-memes/1"); + expect(link.textContent).toContain("Meme"); + expect(link.textContent).toContain("The Memes #1"); + expect(screen.getByTestId("media").textContent).toContain('"nft"'); + fireEvent.click(link); + expect(onClose).toHaveBeenCalled(); + }); + + it("renders wave item and uses query to build path", () => { + useHoverDirty.mockReturnValue(false); + mockUsePathname.mockReturnValue("/waves"); + mockUseSearchParams.mockReturnValue({ + get: jest.fn((key: string) => (key === "wave" ? "other" : null)), + }); + const wave: any = { + id: "wave1", + name: "Wave 1", + picture: "pic.png", + serial_no: 2, + }; + renderComponent(wave, "wave", false); + const link = screen.getByTestId("link"); + expect(link).toHaveAttribute("href", "/waves?wave=wave1"); + expect(link.textContent).toContain("Wave 1"); + expect(link.textContent).toContain("Wave #2"); + expect(screen.getByTestId("media").textContent).toContain("pic.png"); + }); + + it("renders page item and shows breadcrumbs", () => { + useHoverDirty.mockReturnValue(false); + mockUsePathname.mockReturnValue("/delegation/delegation-faq"); + const PageIcon = ({ className }: { className?: string }) => ( +
+ ); + const page: any = { + type: "PAGE", + title: "Delegation FAQs", + href: "/delegation/delegation-faq", + breadcrumbs: ["Tools", "NFT Delegation"], + icon: PageIcon, + }; + renderComponent(page, "delegation", false); + const link = screen.getByTestId("link"); + expect(link).toHaveAttribute("href", "/delegation/delegation-faq"); + expect(link.textContent).toContain("Delegation FAQs"); + expect(link.textContent).toContain("Tools • NFT Delegation"); + expect(screen.getByTestId("page-icon")).toBeInTheDocument(); + }); }); diff --git a/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx b/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx index 78d7ced6a6..bb7b010fbd 100644 --- a/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx +++ b/__tests__/components/header/header-search/HeaderSearchModalFocus.test.tsx @@ -1,10 +1,10 @@ -import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import HeaderSearchButton from "@/components/header/header-search/HeaderSearchButton"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { useClickAway, useKey, useKeyPressEvent } from "react-use"; +import type { Handler, KeyFilter } from "react-use/lib/useKey"; jest.mock("focus-trap-react", () => jest.requireActual("focus-trap-react")); jest.mock("react-use"); @@ -16,9 +16,17 @@ const useSearchParamsMock = jest.fn(); const useWavesMock = jest.fn(); const useLocalPreferenceMock = jest.fn(); const useKeyMock = useKey as jest.MockedFunction; -const useClickAwayMock = useClickAway as jest.MockedFunction; -const useKeyPressEventMock = - useKeyPressEvent as jest.MockedFunction; +const useClickAwayMock = useClickAway as jest.MockedFunction< + typeof useClickAway +>; +const useKeyPressEventMock = useKeyPressEvent as jest.MockedFunction< + (key: KeyFilter, keydown?: Handler | null, keyup?: Handler | null) => void +>; + +const useAppWalletsMock = jest.fn(); +const useCookieConsentMock = jest.fn(); +const useSidebarSectionsMock = jest.fn(); +const capacitorMock = jest.fn(); let escapeHandler: (() => void) | null = null; @@ -54,20 +62,52 @@ jest.mock("@/components/utils/animation/CommonAnimationOpacity", () => ({ })); jest.mock("@/hooks/useDeviceInfo"); +jest.mock("@/components/app-wallets/AppWalletsContext", () => ({ + useAppWallets: () => useAppWalletsMock(), +})); +jest.mock("@/components/cookies/CookieConsentContext", () => ({ + useCookieConsent: () => useCookieConsentMock(), +})); +jest.mock("@/hooks/useCapacitor", () => ({ + __esModule: true, + default: () => capacitorMock(), +})); +jest.mock("@/hooks/useSidebarSections", () => { + const actual = jest.requireActual("@/hooks/useSidebarSections"); + return { + __esModule: true, + useSidebarSections: (...args: any[]) => useSidebarSectionsMock(...args), + mapSidebarSectionsToPages: actual.mapSidebarSectionsToPages, + }; +}); const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction< typeof useDeviceInfo >; +const defaultSidebarSections = [ + { + key: "tools", + name: "Tools", + icon: () => null, + items: [ + { name: "Delegation Center", href: "/delegation/delegation-center" }, + ], + subsections: [], + }, +]; + beforeEach(() => { jest.clearAllMocks(); escapeHandler = null; useKeyMock.mockImplementation(() => {}); useClickAwayMock.mockImplementation(() => {}); useKeyPressEventMock.mockImplementation( - (targetKey: string, handler: () => void) => { - if (targetKey === "Escape") { - escapeHandler = handler; + (key: KeyFilter, keydown?: Handler | null, _keyup?: Handler | null) => { + const isEscape = + key === "Escape" || (Array.isArray(key) && key.includes("Escape")); + if (isEscape && keydown) { + escapeHandler = () => keydown(new KeyboardEvent("keydown")); } } ); @@ -99,10 +139,16 @@ beforeEach(() => { error: null, refetch: jest.fn(), }); - useLocalPreferenceMock.mockReturnValue(["PROFILES", jest.fn()]); + useLocalPreferenceMock.mockReturnValue(["ALL", jest.fn()]); useDeviceInfoMock.mockReturnValue({ isApp: false } as any); + useAppWalletsMock.mockReturnValue({ appWalletsSupported: true }); + useCookieConsentMock.mockReturnValue({ country: "US" }); + capacitorMock.mockReturnValue({ isIos: false }); + useSidebarSectionsMock.mockReturnValue(defaultSidebarSections); }); +const PLACEHOLDER_TEXT = "Search 6529.io"; + describe("HeaderSearchModal focus management", () => { it("keeps focus trapped within the modal while it is open", async () => { const user = userEvent.setup(); @@ -111,7 +157,7 @@ describe("HeaderSearchModal focus management", () => { const trigger = screen.getByRole("button", { name: /search/i }); await user.click(trigger); - const input = await screen.findByPlaceholderText("Search"); + const input = await screen.findByPlaceholderText(PLACEHOLDER_TEXT); await waitFor(() => expect(input).toHaveFocus()); await screen.findByRole("dialog"); @@ -148,13 +194,15 @@ describe("HeaderSearchModal focus management", () => { await user.click(closeButton); await waitFor(() => { - expect(screen.queryByPlaceholderText("Search")).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(PLACEHOLDER_TEXT) + ).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); }); await user.keyboard("[Space]"); - await screen.findByPlaceholderText("Search"); + await screen.findByPlaceholderText(PLACEHOLDER_TEXT); expect(escapeHandler).not.toBeNull(); @@ -163,7 +211,9 @@ describe("HeaderSearchModal focus management", () => { }); await waitFor(() => { - expect(screen.queryByPlaceholderText("Search")).not.toBeInTheDocument(); + expect( + screen.queryByPlaceholderText(PLACEHOLDER_TEXT) + ).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); }); }); diff --git a/__tests__/components/header/share/HeaderShare.test.tsx b/__tests__/components/header/share/HeaderShare.test.tsx index 6bae986b57..e9b19dd038 100644 --- a/__tests__/components/header/share/HeaderShare.test.tsx +++ b/__tests__/components/header/share/HeaderShare.test.tsx @@ -315,7 +315,7 @@ describe("HeaderShare", () => { // Should show both mobile and browser options expect(screen.getByText("6529 Mobile")).toBeInTheDocument(); expect(screen.getByText("Browser")).toBeInTheDocument(); - expect(screen.getByText("6529 Core")).toBeInTheDocument(); + expect(screen.getByText("6529 Desktop")).toBeInTheDocument(); // Click Browser tab await userEvent.click(screen.getByText("Browser")); @@ -339,7 +339,7 @@ describe("HeaderShare", () => { // Should still show mobile/core options but content changes expect(screen.getByText("6529 Mobile")).toBeInTheDocument(); - expect(screen.getByText("6529 Core")).toBeInTheDocument(); + expect(screen.getByText("6529 Desktop")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/user/layout/UserPageTabs.test.tsx b/__tests__/components/user/layout/UserPageTabs.test.tsx index 3cf79321f0..63e5e06469 100644 --- a/__tests__/components/user/layout/UserPageTabs.test.tsx +++ b/__tests__/components/user/layout/UserPageTabs.test.tsx @@ -10,10 +10,10 @@ jest.mock("next/navigation", () => ({ usePathname: jest.fn(), useSearchParams: jest.fn(), })); -const useCapacitorMock = jest.fn(); +const capacitorMock = jest.fn(); jest.mock("@/hooks/useCapacitor", () => ({ __esModule: true, - default: () => useCapacitorMock(), + default: () => capacitorMock(), })); jest.mock("@/components/user/layout/UserPageTab", () => ({ __esModule: true, @@ -35,7 +35,7 @@ const renderTabs = ( (useRouter as jest.Mock).mockReturnValue({ push: jest.fn() }); (usePathname as jest.Mock).mockReturnValue("/[user]/rep"); (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()); - useCapacitorMock.mockReturnValue({ isIos }); + capacitorMock.mockReturnValue({ isIos }); (useCookieConsent as jest.Mock).mockReturnValue({ showCookieConsent: false, country, diff --git a/__tests__/hooks/useDeepLinkNavigation.test.ts b/__tests__/hooks/useDeepLinkNavigation.test.ts index d3a0a87231..42d3aa799a 100644 --- a/__tests__/hooks/useDeepLinkNavigation.test.ts +++ b/__tests__/hooks/useDeepLinkNavigation.test.ts @@ -5,8 +5,8 @@ import { App } from "@capacitor/app"; jest.mock("next/navigation", () => ({ useRouter: jest.fn() })); jest.mock("@capacitor/app", () => ({ App: { addListener: jest.fn() } })); -const useCapacitorMock = jest.fn(() => ({ isCapacitor: true })); -jest.mock("@/hooks/useCapacitor", () => () => useCapacitorMock()); +const capacitorMock = jest.fn(() => ({ isCapacitor: true })); +jest.mock("@/hooks/useCapacitor", () => () => capacitorMock()); const push = jest.fn(); (useRouter as jest.Mock).mockReturnValue({ push }); @@ -19,7 +19,7 @@ let callback: any; beforeEach(() => { jest.clearAllMocks(); - useCapacitorMock.mockImplementation(() => ({ isCapacitor: true })); + capacitorMock.mockImplementation(() => ({ isCapacitor: true })); (App.addListener as jest.Mock).mockImplementation((_e: any, cb: any) => { callback = cb; return Promise.resolve({ remove }); @@ -40,7 +40,7 @@ test("navigates on deep link and cleans up", async () => { }); test("does not register when not capacitor", () => { - useCapacitorMock.mockImplementation(() => ({ isCapacitor: false })); + capacitorMock.mockImplementation(() => ({ isCapacitor: false })); renderHook(() => useDeepLinkNavigation()); expect(App.addListener).not.toHaveBeenCalled(); }); diff --git a/__tests__/hooks/useDeviceInfo.test.ts b/__tests__/hooks/useDeviceInfo.test.ts index eb4149f5a5..13845035b0 100644 --- a/__tests__/hooks/useDeviceInfo.test.ts +++ b/__tests__/hooks/useDeviceInfo.test.ts @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react'; import useDeviceInfo from '@/hooks/useDeviceInfo'; jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: jest.fn(() => ({ isCapacitor: false })) })); -const useCapacitorMock = require('@/hooks/useCapacitor').default as jest.Mock; +const capacitorMock = require('@/hooks/useCapacitor').default as jest.Mock; defineMatchMedia(); @@ -28,7 +28,7 @@ describe('useDeviceInfo', () => { }); it('detects capacitor mobile with desktop UA', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: true }); + capacitorMock.mockReturnValue({ isCapacitor: true }); Object.defineProperty(window.navigator, 'userAgent', { value: 'Macintosh', configurable: true }); defineMatchMedia(true, true); const { result } = renderHook(() => useDeviceInfo()); @@ -37,7 +37,7 @@ describe('useDeviceInfo', () => { }); it('returns false for desktop without touch', () => { - useCapacitorMock.mockReturnValue({ isCapacitor: false }); + capacitorMock.mockReturnValue({ isCapacitor: false }); Object.defineProperty(window.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true }); defineMatchMedia(false, false); const { result } = renderHook(() => useDeviceInfo()); diff --git a/codex/STATE.md b/codex/STATE.md index 9f9a44af61..284c09e6cd 100644 --- a/codex/STATE.md +++ b/codex/STATE.md @@ -18,6 +18,7 @@ This table is the single source of truth for active and historical tickets. Keep | TKT-0012 | Refactor wave group edit buttons for modular clarity | In-Progress | P1 | openai-assistant | [#1544](https://github.com/6529-Collections/6529seize-frontend/pull/1544) | 2025-10-26 | | TKT-0013 | Respect unstyled flag in compact menu button | In-Progress | P1 | openai-assistant | — | 2025-10-23 | | TKT-0014 | Replace wave publish wait with backend confirmation | Backlog | P1 | openai-assistant | — | 2025-10-24 | +| TKT-0015 | Unify header search results | In-Progress | P1 | openai-assistant | [#1567](https://github.com/6529-Collections/6529seize-frontend/pull/1567) | 2025-10-24 | ## Usage Guidelines diff --git a/codex/tickets/TKT-0015.md b/codex/tickets/TKT-0015.md new file mode 100644 index 0000000000..081c051b25 --- /dev/null +++ b/codex/tickets/TKT-0015.md @@ -0,0 +1,37 @@ +--- +created: 2025-10-24 +id: TKT-0015 +owner: openai-assistant +priority: P1 +status: In-Progress +title: Unify header search results +--- + +## Context + +> Ensure the header search modal surfaces exact matches ahead of broader results and orders result categories by their relative size to improve discoverability. + +## Plan + +- [x] Audit the existing header search ordering for both item and category grouping logic. +- [x] Adjust the ranking to prioritize exact matches and sort categories with fewer results first. +- [x] Update or add tests covering the new ordering behaviors. + +## Acceptance + +- [x] Exact query matches appear before partial matches in the header search results. +- [x] Categories in the header search modal are ordered by ascending result counts. +- [x] Desktop header search modal maintains fixed tab and results widths with truncated primary text to avoid column shifts. +- [x] Relevant tests are updated or added, and `npm run test`, `npm run lint`, and `npm run type-check` pass. + +## Links + +- Primary PR: [#1567](https://github.com/6529-Collections/6529seize-frontend/pull/1567) +- Follow-ups: _(reference additional tickets or TODO items)_ + +## Log + +- 2025-10-24T09:46:52Z – Initialized ticket and outlined ordering improvements. +- 2025-10-24T10:45:00Z – Implemented page icons, scroll locking, and loading-state polish for the header search modal alongside updated tests. +- 2025-10-24T12:50:50Z – Redesigned desktop modal layout with vertical filtering, integrated inline clear control, and lowered NFT search thresholds while keeping the test suite healthy. +- 2025-10-27T09:22:32Z – Locked desktop search modal columns, ensured long primary text truncates cleanly, and queued regressions for follow-up validation once the suite stabilises. diff --git a/components/header/header-search/HeaderSearchModal.tsx b/components/header/header-search/HeaderSearchModal.tsx index e11bfa2d37..ad604391c0 100644 --- a/components/header/header-search/HeaderSearchModal.tsx +++ b/components/header/header-search/HeaderSearchModal.tsx @@ -1,31 +1,42 @@ "use client"; -import { TabToggle } from "@/components/common/TabToggle"; +import { useAppWallets } from "@/components/app-wallets/AppWalletsContext"; +import BellIcon from "@/components/common/icons/BellIcon"; +import ChatBubbleIcon from "@/components/common/icons/ChatBubbleIcon"; +import DiscoverIcon from "@/components/common/icons/DiscoverIcon"; +import HomeIcon from "@/components/common/icons/HomeIcon"; +import WavesIcon from "@/components/common/icons/WavesIcon"; +import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { UserPageTabType } from "@/components/user/layout/UserPageTabs"; import { CommunityMemberMinimal } from "@/entities/IProfile"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; import { getProfileTargetRoute } from "@/helpers/Helpers"; +import { getWaveHomeRoute, getWaveRoute } from "@/helpers/navigation.helpers"; +import useCapacitor from "@/hooks/useCapacitor"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import useLocalPreference from "@/hooks/useLocalPreference"; +import { + mapSidebarSectionsToPages, + useSidebarSections, + type SidebarPageEntry, +} from "@/hooks/useSidebarSections"; import { useWaves } from "@/hooks/useWaves"; import { commonApiFetch } from "@/services/api/common-api"; import { ChevronLeftIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useQuery } from "@tanstack/react-query"; -import FocusTrap from "focus-trap-react"; +import { FocusTrap } from "focus-trap-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useClickAway, useDebounce, useKeyPressEvent } from "react-use"; -import { - getWaveHomeRoute, - getWaveRoute, -} from "../../../helpers/navigation.helpers"; -import useDeviceInfo from "../../../hooks/useDeviceInfo"; import HeaderSearchModalItem, { + getNftCollectionMap, HeaderSearchModalItemType, NFTSearchResult, + PageSearchResult, } from "./HeaderSearchModalItem"; +import { HeaderSearchTabToggle } from "./HeaderSearchTabToggle"; enum STATE { INITIAL = "INITIAL", @@ -36,14 +47,104 @@ enum STATE { } enum CATEGORY { + ALL = "ALL", PROFILES = "PROFILES", NFTS = "NFTS", WAVES = "WAVES", + PAGES = "PAGES", } +type FilterableCategory = Exclude; + +const FILTERABLE_CATEGORIES: FilterableCategory[] = [ + CATEGORY.PAGES, + CATEGORY.NFTS, + CATEGORY.PROFILES, + CATEGORY.WAVES, +]; + +const isFilterableCategory = ( + category: CATEGORY +): category is FilterableCategory => category !== CATEGORY.ALL; + +const CATEGORY_LABELS: Record = { + [CATEGORY.PAGES]: "Pages", + [CATEGORY.PROFILES]: "Profiles", + [CATEGORY.NFTS]: "NFTs", + [CATEGORY.WAVES]: "Waves", +}; + +const CATEGORY_PREVIEW_LIMIT = 3; + +const PRIMARY_NAVIGATION_PAGES: SidebarPageEntry[] = [ + { name: "Home", href: "/", section: "Main", icon: HomeIcon }, + { name: "Waves", href: "/waves", section: "Main", icon: WavesIcon }, + { + name: "Messages", + href: "/messages", + section: "Main", + icon: ChatBubbleIcon, + }, + { name: "Discover", href: "/discover", section: "Main", icon: DiscoverIcon }, + { + name: "Notifications", + href: "/notifications", + section: "Main", + icon: BellIcon, + }, +]; + const MIN_SEARCH_LENGTH = 3; +const NFT_SEARCH_MIN_LENGTH = 3; const HEADER_SEARCH_RESULTS_PANEL_ID = "header-search-results-panel"; +interface PreviewGroupItem { + readonly item: HeaderSearchModalItemType; + readonly index: number; +} + +interface PreviewGroup { + readonly category: FilterableCategory; + readonly items: PreviewGroupItem[]; + readonly total: number; +} + +interface RankedPageMatch { + readonly page: PageSearchResult; + readonly normalizedTitle: string; + readonly priority: number; +} + +const getPageMatchPriority = ( + normalizedTitle: string, + hrefSegments: string[], + normalizedBreadcrumbs: string[], + normalizedQuery: string +): number => { + if (normalizedTitle === normalizedQuery) return 0; + if (hrefSegments.includes(normalizedQuery)) return 1; + if (normalizedTitle.startsWith(normalizedQuery)) return 2; + if (normalizedBreadcrumbs.includes(normalizedQuery)) return 3; + if (normalizedTitle.includes(normalizedQuery)) return 4; + if (hrefSegments.some((segment) => segment.includes(normalizedQuery))) { + return 5; + } + return 6; +}; + +const pageMatchesQuery = ( + normalizedTitle: string, + normalizedHref: string, + normalizedBreadcrumbs: string[], + normalizedQuery: string +) => { + if (normalizedTitle.includes(normalizedQuery)) return true; + if (normalizedHref.includes(normalizedQuery)) return true; + return normalizedBreadcrumbs.some((breadcrumb) => + breadcrumb.includes(normalizedQuery) + ); +}; + export default function HeaderSearchModal({ onClose, }: { @@ -59,12 +160,14 @@ export default function HeaderSearchModal({ const [searchValue, setSearchValue] = useState(""); const [selectedCategory, setSelectedCategory] = useLocalPreference( - "headerSearchCategory", - CATEGORY.PROFILES, + "headerSearchCategoryFilter", + CATEGORY.ALL, (value) => Object.values(CATEGORY).includes(value) ); const [debouncedValue, setDebouncedValue] = useState(""); + const [allowProfileFetch, setAllowProfileFetch] = useState(false); + const [allowWaveFetch, setAllowWaveFetch] = useState(false); useDebounce( () => { setDebouncedValue(searchValue); @@ -73,6 +176,53 @@ export default function HeaderSearchModal({ [searchValue] ); + const trimmedSearchValue = searchValue.trim(); + const trimmedDebouncedValue = debouncedValue.trim(); + const searchInputLength = trimmedSearchValue.length; + const meetsCharacterThreshold = searchInputLength >= MIN_SEARCH_LENGTH; + const shouldSearchPages = meetsCharacterThreshold; + const shouldSearchDefault = trimmedDebouncedValue.length >= MIN_SEARCH_LENGTH; + const shouldSearchNfts = + trimmedDebouncedValue.length >= NFT_SEARCH_MIN_LENGTH || + (trimmedDebouncedValue.length > 0 && + !Number.isNaN(Number(trimmedDebouncedValue))); + const hasActiveDebouncedSearch = shouldSearchNfts; + + const { appWalletsSupported } = useAppWallets(); + const { country } = useCookieConsent(); + const capacitor = useCapacitor(); + const sections = useSidebarSections( + appWalletsSupported, + capacitor.isIos, + country + ); + const sidebarPages = useMemo( + () => mapSidebarSectionsToPages(sections), + [sections] + ); + const allPageEntries = useMemo(() => { + const seen = new Set(); + return [...PRIMARY_NAVIGATION_PAGES, ...sidebarPages].filter((entry) => { + if (seen.has(entry.href)) return false; + seen.add(entry.href); + return true; + }); + }, [sidebarPages]); + + const pageCatalog = useMemo( + () => + allPageEntries.map((entry) => ({ + type: "PAGE", + title: entry.name, + href: entry.href, + icon: entry.icon, + breadcrumbs: [entry.section, entry.subsection] + .filter((value): value is string => !!value) + .map((value) => value), + })), + [allPageEntries] + ); + const handleInputChange = (event: React.ChangeEvent) => { const newValue = event.target.value; setSearchValue(newValue); @@ -86,23 +236,64 @@ export default function HeaderSearchModal({ } }, []); + const resultsPanelRef = useRef(null); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const previousOverflow = document.body.style.overflow; + const previousPaddingRight = document.body.style.paddingRight; + const scrollbarGap = + window.innerWidth - document.documentElement.clientWidth; + + document.body.style.overflow = "hidden"; + if (scrollbarGap > 0) { + document.body.style.paddingRight = `${scrollbarGap}px`; + } + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + if (resultsPanelRef.current) { + resultsPanelRef.current.scrollBy({ + top: event.deltaY, + left: event.deltaX, + }); + } + }; + + window.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + document.body.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPaddingRight; + window.removeEventListener("wheel", handleWheel); + }; + }, []); + + const sharedQueryDefaults = { + keepPreviousData: false, + } as const; + const { isFetching: isFetchingProfiles, data: profiles, error: profilesError, refetch: refetchProfiles, } = useQuery({ - queryKey: [QueryKey.PROFILE_SEARCH, debouncedValue], + queryKey: [QueryKey.PROFILE_SEARCH, trimmedDebouncedValue], queryFn: async () => await commonApiFetch({ endpoint: "community-members", params: { - param: debouncedValue, + param: trimmedDebouncedValue, }, }), enabled: - selectedCategory === CATEGORY.PROFILES && - debouncedValue.length >= MIN_SEARCH_LENGTH, + shouldSearchDefault && + (selectedCategory === CATEGORY.PROFILES || allowProfileFetch), + ...sharedQueryDefaults, }); const { @@ -111,19 +302,17 @@ export default function HeaderSearchModal({ error: nftsError, refetch: refetchNfts, } = useQuery({ - queryKey: [QueryKey.NFTS_SEARCH, debouncedValue], + queryKey: [QueryKey.NFTS_SEARCH, trimmedDebouncedValue], queryFn: async () => { return await commonApiFetch({ endpoint: "nfts_search", params: { - search: debouncedValue, + search: trimmedDebouncedValue, }, }); }, - enabled: - selectedCategory === CATEGORY.NFTS && - (debouncedValue.length >= MIN_SEARCH_LENGTH || - (debouncedValue.length > 0 && !isNaN(Number(debouncedValue)))), + enabled: shouldSearchNfts, + ...sharedQueryDefaults, }); const { @@ -134,10 +323,274 @@ export default function HeaderSearchModal({ } = useWaves({ identity: null, waveName: - debouncedValue.length >= MIN_SEARCH_LENGTH ? debouncedValue : null, + shouldSearchDefault && + (selectedCategory === CATEGORY.WAVES || allowWaveFetch) + ? trimmedDebouncedValue + : null, limit: 20, + enabled: + shouldSearchDefault && + (selectedCategory === CATEGORY.WAVES || allowWaveFetch), }); + const pageResults = useMemo(() => { + if (!shouldSearchPages) { + return []; + } + const normalizedQuery = trimmedSearchValue.toLowerCase(); + if (!normalizedQuery) { + return []; + } + + const rankedMatches = pageCatalog.reduce( + (accumulator, page) => { + const normalizedTitle = page.title.toLowerCase(); + const normalizedHref = page.href.toLowerCase(); + const normalizedBreadcrumbs = page.breadcrumbs.map((value) => + value.toLowerCase() + ); + + if ( + !pageMatchesQuery( + normalizedTitle, + normalizedHref, + normalizedBreadcrumbs, + normalizedQuery + ) + ) { + return accumulator; + } + + const hrefSegments = normalizedHref.split("/").filter(Boolean); + + accumulator.push({ + page, + normalizedTitle, + priority: getPageMatchPriority( + normalizedTitle, + hrefSegments, + normalizedBreadcrumbs, + normalizedQuery + ), + }); + + return accumulator; + }, + [] + ); + + return rankedMatches + .sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + + const titleComparison = a.normalizedTitle.localeCompare( + b.normalizedTitle + ); + if (titleComparison !== 0) { + return titleComparison; + } + + return a.page.href.localeCompare(b.page.href); + }) + .map((result) => result.page); + }, [shouldSearchPages, trimmedSearchValue, pageCatalog]); + + const profileResults: CommunityMemberMinimal[] = useMemo( + () => + shouldSearchDefault && + (selectedCategory === CATEGORY.PROFILES || allowProfileFetch) + ? profiles ?? [] + : [], + [shouldSearchDefault, selectedCategory, allowProfileFetch, profiles] + ); + + const nftResults: NFTSearchResult[] = useMemo( + () => (shouldSearchNfts ? nfts ?? [] : []), + [shouldSearchNfts, nfts] + ); + + const waveResults: ApiWave[] = useMemo( + () => + shouldSearchDefault && + (selectedCategory === CATEGORY.WAVES || allowWaveFetch) + ? waves ?? [] + : [], + [shouldSearchDefault, selectedCategory, allowWaveFetch, waves] + ); + + const nftsSettled = + shouldSearchNfts && + !isFetchingNfts && + (nfts !== undefined || Boolean(nftsError)); + + const profilesSettled = + shouldSearchDefault && + (selectedCategory === CATEGORY.PROFILES || allowProfileFetch) && + !isFetchingProfiles && + (profiles !== undefined || Boolean(profilesError)); + + useEffect(() => { + setAllowProfileFetch(false); + setAllowWaveFetch(false); + }, [debouncedValue, shouldSearchDefault]); + + useEffect(() => { + if (selectedCategory === CATEGORY.PROFILES) { + setAllowProfileFetch(true); + } + if (selectedCategory === CATEGORY.WAVES) { + setAllowWaveFetch(true); + } + }, [selectedCategory]); + + useEffect(() => { + if (!shouldSearchDefault) { + return; + } + + if (allowProfileFetch) { + return; + } + + if (!shouldSearchNfts) { + setAllowProfileFetch(true); + return; + } + + if (nftsSettled) { + setAllowProfileFetch(true); + } + }, [shouldSearchDefault, shouldSearchNfts, nftsSettled, allowProfileFetch]); + + useEffect(() => { + if (!shouldSearchDefault) { + return; + } + + if (allowWaveFetch) { + return; + } + + if (!allowProfileFetch && selectedCategory !== CATEGORY.WAVES) { + return; + } + + if (profilesSettled) { + setAllowWaveFetch(true); + } + }, [ + shouldSearchDefault, + allowProfileFetch, + profilesSettled, + allowWaveFetch, + selectedCategory, + ]); + + const charactersRemaining = Math.max( + MIN_SEARCH_LENGTH - searchInputLength, + 0 + ); + const shouldShowCountdown = searchInputLength > 0 && charactersRemaining > 0; + const isAwaitingDebouncedSearch = + meetsCharacterThreshold && trimmedDebouncedValue !== trimmedSearchValue; + + const isSearching = shouldSearchPages || hasActiveDebouncedSearch; + + const resultsByCategory = useMemo< + Record + >( + () => ({ + [CATEGORY.PAGES]: pageResults, + [CATEGORY.PROFILES]: profileResults, + [CATEGORY.NFTS]: nftResults, + [CATEGORY.WAVES]: waveResults, + }), + [pageResults, profileResults, nftResults, waveResults] + ); + + const categoriesWithResults = useMemo( + () => + FILTERABLE_CATEGORIES.filter( + (category) => resultsByCategory[category].length > 0 + ), + [resultsByCategory] + ); + + useEffect(() => { + if ( + selectedCategory !== CATEGORY.ALL && + (!isFilterableCategory(selectedCategory) || + !categoriesWithResults.includes(selectedCategory)) + ) { + setSelectedCategory(CATEGORY.ALL); + } + }, [categoriesWithResults, selectedCategory, setSelectedCategory]); + + const tabOptions = useMemo( + () => + [CATEGORY.ALL, ...categoriesWithResults].map((category) => ({ + key: category, + label: + category === CATEGORY.ALL + ? "All" + : CATEGORY_LABELS[category as FilterableCategory], + panelId: HEADER_SEARCH_RESULTS_PANEL_ID, + })), + [categoriesWithResults] + ); + + const shouldRenderCategoryToggle = + categoriesWithResults.length > 0 || selectedCategory !== CATEGORY.ALL; + + const previewGroups = useMemo(() => { + if (selectedCategory !== CATEGORY.ALL) { + return []; + } + + let runningIndex = 0; + return categoriesWithResults.map((category) => { + const items = resultsByCategory[category]; + const previewItems = items + .slice(0, CATEGORY_PREVIEW_LIMIT) + .map((item) => ({ item, index: runningIndex++ })); + + return { + category, + items: previewItems, + total: items.length, + }; + }); + }, [categoriesWithResults, resultsByCategory, selectedCategory]); + + const flattenedItems = useMemo(() => { + if (selectedCategory === CATEGORY.ALL) { + return previewGroups.flatMap((group) => + group.items.map((entry) => entry.item) + ); + } + + if (isFilterableCategory(selectedCategory)) { + return resultsByCategory[selectedCategory]; + } + + return []; + }, [previewGroups, resultsByCategory, selectedCategory]); + + const handleClearSearch = () => { + setSearchValue(""); + setDebouncedValue(""); + setSelectedCategory(CATEGORY.ALL); + setSelectedItemIndex(0); + setAllowProfileFetch(false); + setAllowWaveFetch(false); + setState(STATE.INITIAL); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }; + const onHover = (index: number, state: boolean) => { if (!state) return; setSelectedItemIndex(index); @@ -157,53 +610,68 @@ export default function HeaderSearchModal({ const [selectedItemIndex, setSelectedItemIndex] = useState(0); const [state, setState] = useState(STATE.INITIAL); - const getCurrentItems = (): HeaderSearchModalItemType[] => { - if (selectedCategory === CATEGORY.NFTS) { - return nfts ?? []; - } - if (selectedCategory === CATEGORY.PROFILES) { - return profiles ?? []; - } - return waves ?? []; - }; + const getCurrentItems = (): HeaderSearchModalItemType[] => flattenedItems; useKeyPressEvent("ArrowDown", () => - setSelectedItemIndex((i) => { - const count = getCurrentItems().length; - return count >= i + 2 ? i + 1 : i; - }) + setSelectedItemIndex((index) => + index + 1 < flattenedItems.length ? index + 1 : index + ) ); useKeyPressEvent("ArrowUp", () => setSelectedItemIndex((i) => (i > 0 ? i - 1 : i)) ); + const isPageResult = ( + item: HeaderSearchModalItemType + ): item is PageSearchResult => (item as PageSearchResult).type === "PAGE"; + + const isNftResult = ( + item: HeaderSearchModalItemType + ): item is NFTSearchResult => Object.hasOwn(item, "contract"); + + const isProfileResult = ( + item: HeaderSearchModalItemType + ): item is CommunityMemberMinimal => Object.hasOwn(item, "wallet"); + + const isWaveResult = (item: HeaderSearchModalItemType): item is ApiWave => + Object.hasOwn(item, "serial_no"); + useKeyPressEvent("Enter", () => { if (state !== STATE.SUCCESS) return; const items = getCurrentItems(); if (!items || items.length === 0) return; const item = items[selectedItemIndex]; - if (selectedCategory === CATEGORY.NFTS) { - const nft = item as NFTSearchResult; - router.push(`/the-memes/${nft.id}`); + if (!item) return; + + if (isPageResult(item)) { + router.push(item.href); onClose(); return; } - if (selectedCategory === CATEGORY.PROFILES) { - const profile = item as CommunityMemberMinimal; - goToProfile(profile); + + if (isNftResult(item)) { + const collectionMap = getNftCollectionMap(); + const key = item.contract.toLowerCase(); + router.push(`${collectionMap[key].path}/${item.id}`); + onClose(); return; } - if (selectedCategory === CATEGORY.WAVES) { - const wave = item as ApiWave; + + if (isProfileResult(item)) { + goToProfile(item); + return; + } + + if (isWaveResult(item)) { const currentWaveId = searchParams?.get("wave") ?? undefined; const isDirectMessage = - wave.chat?.scope?.group?.is_direct_message ?? false; + item.chat?.scope?.group?.is_direct_message ?? false; const target = - currentWaveId === wave.id + currentWaveId === item.id ? getWaveHomeRoute({ isDirectMessage, isApp }) : getWaveRoute({ - waveId: wave.id, + waveId: item.id, isDirectMessage, isApp, }); @@ -214,67 +682,96 @@ export default function HeaderSearchModal({ useEffect(() => { setSelectedItemIndex(0); - let fetching = false; - let items: HeaderSearchModalItemType[] = []; - let hasError = false; - if (selectedCategory === CATEGORY.NFTS) { - fetching = isFetchingNfts; - items = nfts ?? []; - hasError = Boolean(nftsError); - } else if (selectedCategory === CATEGORY.PROFILES) { - fetching = isFetchingProfiles; - items = profiles ?? []; - hasError = Boolean(profilesError); - } else { - fetching = isFetchingWaves; - items = waves ?? []; - hasError = Boolean(wavesError); - } - if (fetching) { - setState(STATE.LOADING); - return; - } - - if (debouncedValue.length === 0) { + if (!isSearching) { setState(STATE.INITIAL); return; } - if (hasError) { - setState(STATE.ERROR); - return; - } + const hasResults = categoriesWithResults.length > 0; + const anyFetching = + (shouldSearchDefault && (isFetchingProfiles || isFetchingWaves)) || + (shouldSearchNfts && isFetchingNfts); + const anyError = + (shouldSearchDefault && + (Boolean(profilesError) || Boolean(wavesError))) || + (shouldSearchNfts && Boolean(nftsError)); + + if (!hasResults) { + if (isAwaitingDebouncedSearch) { + setState(STATE.LOADING); + return; + } + + if (anyError) { + setState(STATE.ERROR); + return; + } + + if (anyFetching) { + setState(STATE.LOADING); + return; + } - if (items.length === 0) { setState(STATE.NO_RESULTS); return; } setState(STATE.SUCCESS); }, [ - selectedCategory, + categoriesWithResults, isFetchingProfiles, isFetchingNfts, isFetchingWaves, - profiles, - nfts, - waves, - debouncedValue, + isSearching, + shouldSearchDefault, + shouldSearchNfts, profilesError, nftsError, wavesError, + isAwaitingDebouncedSearch, ]); const handleRetry = () => { setState(STATE.LOADING); - const refetchByCategory: Record Promise> = { - [CATEGORY.NFTS]: refetchNfts, - [CATEGORY.PROFILES]: refetchProfiles, - [CATEGORY.WAVES]: refetchWaves, + + const refetchPromises: Promise[] = []; + + const queueRefetch = (callback: () => Promise) => { + refetchPromises.push(callback()); }; - refetchByCategory[selectedCategory]().catch(() => { + if (selectedCategory === CATEGORY.ALL) { + if (shouldSearchDefault) { + queueRefetch(refetchProfiles); + queueRefetch(refetchWaves); + } + if (shouldSearchNfts) { + queueRefetch(refetchNfts); + } + } else if (selectedCategory === CATEGORY.PAGES) { + setState(pageResults.length > 0 ? STATE.SUCCESS : STATE.NO_RESULTS); + return; + } else if (selectedCategory === CATEGORY.PROFILES) { + if (shouldSearchDefault) { + queueRefetch(refetchProfiles); + } + } else if (selectedCategory === CATEGORY.NFTS) { + if (shouldSearchNfts) { + queueRefetch(refetchNfts); + } + } else if (selectedCategory === CATEGORY.WAVES) { + if (shouldSearchDefault) { + queueRefetch(refetchWaves); + } + } + + if (refetchPromises.length === 0) { + setState(STATE.NO_RESULTS); + return; + } + + Promise.all(refetchPromises).catch(() => { setState(STATE.ERROR); }); }; @@ -291,23 +788,75 @@ export default function HeaderSearchModal({ } }, [selectedItemIndex]); - const renderItems = (items: HeaderSearchModalItemType[]) => - items.map((item, index) => { - const currentIndex = index; - return ( -
- onHover(currentIndex, state)} - onClose={onClose} - /> -
- ); - }); + const getItemKey = (item: HeaderSearchModalItemType): string => { + if (isPageResult(item)) { + return `page:${item.href}`; + } + if (isNftResult(item)) { + return `nft:${item.contract}:${item.id}`; + } + if (isProfileResult(item)) { + const base = (item.profile_id ?? item.wallet ?? "profile").toLowerCase(); + return `profile:${base}`; + } + if (isWaveResult(item)) { + return `wave:${item.id}`; + } + return JSON.stringify(item); + }; + + const renderItem = (item: HeaderSearchModalItemType, index: number) => ( +
+ onHover(index, state)} + onClose={onClose} + /> +
+ ); + + const renderItems = (items: HeaderSearchModalItemType[], offset = 0) => + items.map((item, index) => renderItem(item, offset + index)); + + const handleViewAll = (category: FilterableCategory) => { + setSelectedCategory(category); + setSelectedItemIndex(0); + }; + + const renderSuccessContent = () => { + if (selectedCategory === CATEGORY.ALL) { + return previewGroups.map((group) => ( +
+
+

+ {CATEGORY_LABELS[group.category]} +

+ {group.total > group.items.length && ( + + )} +
+
+ {group.items.map(({ item, index }) => renderItem(item, index))} +
+
+ )); + } + + if (isFilterableCategory(selectedCategory)) { + return renderItems(resultsByCategory[selectedCategory]); + } + + return null; + }; return createPortal(
-
+
+ aria-modal="true" + aria-labelledby="header-search-input" + className="tw-w-full tw-max-w-[min(100vw-3rem,900px)] sm:tw-max-w-3xl tw-relative tw-h-[520px] tw-max-h-[70vh] tw-transform tw-rounded-xl tw-bg-iron-950 tw-text-left tw-shadow-xl tw-transition-all tw-duration-500 tw-overflow-hidden inset-safe-area tw-flex tw-flex-col tw-min-h-0">
- {/* Back arrow mobile */} @@ -362,101 +912,145 @@ export default function HeaderSearchModal({ autoComplete="off" value={searchValue} onChange={handleInputChange} - className="tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-py-3 tw-pl-11 tw-pr-4 tw-bg-iron-900 tw-text-iron-50 tw-font-normal tw-caret-primary-300 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 hover:tw-ring-iron-600 placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-bg-transparent focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-300 tw-text-base sm:text-sm tw-transition tw-duration-300 tw-ease-out" - placeholder="Search" + className="tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-py-3 tw-pl-11 tw-pr-16 tw-bg-iron-900 tw-text-iron-50 tw-font-normal tw-caret-primary-300 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 hover:tw-ring-iron-600 placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-bg-transparent focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-300 tw-text-base sm:text-sm tw-transition tw-duration-300 tw-ease-out" + placeholder="Search 6529.io" /> + {searchValue.length > 0 && ( + + )}
-
- ({ - key: c, - label: c.charAt(0) + c.slice(1).toLowerCase(), - panelId: HEADER_SEARCH_RESULTS_PANEL_ID, - }))} - activeKey={selectedCategory} - onSelect={(k) => setSelectedCategory(k as CATEGORY)} - /> -
- - {state === STATE.SUCCESS && ( -
- {renderItems(getCurrentItems())} -
- )} - {state === STATE.LOADING && ( -
-

- Loading... -

-
- )} - {state === STATE.NO_RESULTS && ( -
-

- No results found -

-
- )} - {state === STATE.ERROR && ( -
-

- Something went wrong while searching. Please try again. -

- + {shouldRenderCategoryToggle && ( +
+ setSelectedCategory(k as CATEGORY)} + fullWidth + />
)} - {state === STATE.INITIAL && ( -
-

- Search for NFTs (by ID or name), Profiles and Waves -

+ +
+ {shouldRenderCategoryToggle && ( + + )} +
+ {state === STATE.SUCCESS && ( +
+ {renderSuccessContent()} +
+ )} + {(state === STATE.LOADING || + (state === STATE.INITIAL && isSearching)) && ( +
+

+ Loading... +

+
+ )} + {state === STATE.NO_RESULTS && ( +
+

+ No results found +

+
+ )} + {state === STATE.ERROR && ( +
+

+ Something went wrong while searching. Please try again. +

+ +
+ )} + {state === STATE.INITIAL && !isSearching && ( +
+

+ Start typing to search 6529.io + {shouldShowCountdown && + ` (${charactersRemaining} more character${ + charactersRemaining === 1 ? "" : "s" + })`} +

+
+ )}
- )} +
diff --git a/components/header/header-search/HeaderSearchModalItem.tsx b/components/header/header-search/HeaderSearchModalItem.tsx index 9d822c65d0..9bc69a0364 100644 --- a/components/header/header-search/HeaderSearchModalItem.tsx +++ b/components/header/header-search/HeaderSearchModalItem.tsx @@ -1,34 +1,29 @@ "use client"; -import { useHoverDirty } from "react-use"; -import { CommunityMemberMinimal } from "@/entities/IProfile"; +import ChatBubbleIcon from "@/components/common/icons/ChatBubbleIcon"; +import WavesIcon from "@/components/common/icons/WavesIcon"; import { - cicToType, - formatNumberWithCommas, - getProfileTargetRoute, -} from "@/helpers/Helpers"; -import { useEffect, useRef } from "react"; -import HeaderSearchModalItemHighlight from "./HeaderSearchModalItemHighlight"; -import UserCICAndLevel from "@/components/user/utils/UserCICAndLevel"; -import { usePathname, useSearchParams } from "next/navigation"; + NEXTGEN_CHAIN_ID, + NEXTGEN_CORE, +} from "@/components/nextGen/nextgen_contracts"; import { UserPageTabType } from "@/components/user/layout/UserPageTabs"; -import Link from "next/link"; import { GRADIENT_CONTRACT, MEMELAB_CONTRACT, MEMES_CONTRACT, } from "@/constants"; -import { - NEXTGEN_CORE, - NEXTGEN_CHAIN_ID, -} from "@/components/nextGen/nextgen_contracts"; -import HeaderSearchModalItemMedia from "./HeaderSearchModalItemMedia"; +import { CommunityMemberMinimal } from "@/entities/IProfile"; import type { ApiWave } from "@/generated/models/ApiWave"; -import useDeviceInfo from "../../../hooks/useDeviceInfo"; -import { - getWaveHomeRoute, - getWaveRoute, -} from "../../../helpers/navigation.helpers"; +import { getProfileTargetRoute } from "@/helpers/Helpers"; +import { getWaveHomeRoute, getWaveRoute } from "@/helpers/navigation.helpers"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect, useRef, type ComponentType } from "react"; +import { useHoverDirty } from "react-use"; +import HeaderSearchModalItemMedia from "./HeaderSearchModalItemMedia"; +import HeaderSearchModalPfp from "./HeaderSearchModalPfp"; export interface NFTSearchResult { id: number; @@ -39,10 +34,40 @@ export interface NFTSearchResult { image_url: string; } +export interface PageSearchResult { + type: "PAGE"; + title: string; + href: string; + breadcrumbs: string[]; + icon?: ComponentType<{ className?: string }>; +} + export type HeaderSearchModalItemType = | CommunityMemberMinimal | NFTSearchResult - | ApiWave; + | ApiWave + | PageSearchResult; + +export const getNftCollectionMap = () => { + return { + [MEMES_CONTRACT.toLowerCase()]: { + title: "The Memes", + path: "/the-memes", + }, + [MEMELAB_CONTRACT.toLowerCase()]: { + title: "Meme Lab", + path: "/meme-lab", + }, + [GRADIENT_CONTRACT.toLowerCase()]: { + title: "6529 Gradient", + path: "/6529-gradient", + }, + [NEXTGEN_CORE[NEXTGEN_CHAIN_ID].toLowerCase()]: { + title: "NextGen", + path: "/nextgen/token", + }, + }; +}; export default function HeaderSearchModalItem({ content, @@ -68,51 +93,50 @@ export default function HeaderSearchModalItem({ window.matchMedia && window.matchMedia("(hover: hover)").matches; - const isProfile = () => content.hasOwnProperty("handle"); - const isNft = () => content.hasOwnProperty("contract"); + const isPage = () => (content as PageSearchResult).type === "PAGE"; + const isProfile = () => Object.hasOwn(content, "handle"); + const isNft = () => Object.hasOwn(content, "contract"); const getWave = () => content as ApiWave; const getProfile = () => content as CommunityMemberMinimal; const getNft = () => content as NFTSearchResult; - - const getNftCollectionMap = () => { - return { - [MEMES_CONTRACT.toLowerCase()]: { - title: "The Memes", - path: "/the-memes", - }, - [MEMELAB_CONTRACT.toLowerCase()]: { - title: "Meme Lab", - path: "/meme-lab", - }, - [GRADIENT_CONTRACT.toLowerCase()]: { - title: "6529 Gradient", - path: "/6529-gradient", - }, - [NEXTGEN_CORE[NEXTGEN_CHAIN_ID].toLowerCase()]: { - title: "NextGen", - path: "/nextgen/token", - }, - }; + const getPage = () => content as PageSearchResult; + + const getMediaIcon = (Icon: ComponentType<{ className?: string }>) => { + return ( +
+ +
+ ); }; const getMedia = () => { if (isProfile()) { const profile = getProfile(); - const cicType = cicToType(profile.cic_rating); - return ; + return ; } else if (isNft()) { const nft = getNft(); return ; + } else if (isPage()) { + const page = getPage(); + const Icon = page.icon ?? DocumentTextIcon; + return getMediaIcon(Icon); } else { const wave = getWave(); - return ( - - ); + if (wave.picture) { + return ( + + ); + } + const isDm = wave.wave.admin_group.group?.is_direct_message; + if (isDm) { + return getMediaIcon(ChatBubbleIcon); + } + return getMediaIcon(WavesIcon); } }; @@ -133,7 +157,10 @@ export default function HeaderSearchModalItem({ } else if (isNft()) { const nft = getNft(); const collectionMap = getNftCollectionMap(); - return `${collectionMap[nft.contract].path}/${nft.id}`; + const key = nft.contract.toLowerCase(); + return `${collectionMap[key].path}/${nft.id}`; + } else if (isPage()) { + return getPage().href; } else { const wave = getWave(); const currentWaveId = searchParams?.get("wave") ?? undefined; @@ -160,6 +187,8 @@ export default function HeaderSearchModalItem({ return getProfile().handle ?? "-"; } else if (isNft()) { return getNft().name; + } else if (isPage()) { + return getPage().title; } else { return getWave().name; } @@ -167,17 +196,28 @@ export default function HeaderSearchModalItem({ const getSecondaryText = () => { if (isProfile()) { - return getProfile().display ?? ""; + const profile = getProfile(); + return `TDH: ${profile.tdh.toLocaleString()} - Level: ${profile.level}`; } else if (isNft()) { const nft = getNft(); const collectionMap = getNftCollectionMap(); - return `${collectionMap[nft.contract].title} #${nft.id}`; + const key = nft.contract.toLowerCase(); + return `${collectionMap[key].title} #${nft.id}`; + } else if (isPage()) { + const page = getPage(); + if (page.breadcrumbs.length > 0) { + return page.breadcrumbs.join(" • "); + } + return page.href; } else { const wave = getWave(); return `Wave #${wave.serial_no}`; } }; + const primaryText = getPrimaryText(); + const secondaryText = getSecondaryText(); + return (
+ className="tw-group tw-no-underline tw-select-none tw-flex tw-items-center tw-gap-3 tw-w-full tw-min-w-0 tw-text-left tw-text-sm tw-font-medium"> {getMedia()} -
-
- - - - {isProfile() && !!getProfile().tdh && ( - - {formatNumberWithCommas(getProfile().tdh)}{" "} - - TDH - +
+
+ + + {primaryText} - )} +
-

- +

+ {secondaryText}

diff --git a/components/header/header-search/HeaderSearchModalItemHighlight.tsx b/components/header/header-search/HeaderSearchModalItemHighlight.tsx deleted file mode 100644 index 44b71854fe..0000000000 --- a/components/header/header-search/HeaderSearchModalItemHighlight.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; - -export default function HeaderSearchModalItemHighlight({ - text, - highlight, -}: { - text: string; - readonly highlight: string; -}) { - const parts = text.split(new RegExp(`(${highlight})`, "gi")); - return parts.map((part) => ( - - {part.toLowerCase() === highlight.toLowerCase() ? ( - {part} - ) : ( - part - )} - - )); -} diff --git a/components/header/header-search/HeaderSearchModalPfp.tsx b/components/header/header-search/HeaderSearchModalPfp.tsx new file mode 100644 index 0000000000..492c4ea56a --- /dev/null +++ b/components/header/header-search/HeaderSearchModalPfp.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useResolvedIpfsUrl } from "@/hooks/useResolvedIpfsUrl"; +import Image from "next/image"; + +function getLevelBgColor(level: number) { + if (level >= 80) return "tw-bg-[#55B075] tw-text-black"; + if (level >= 60) return "tw-bg-[#AABE68] tw-text-black"; + return "tw-bg-[#DA8C60] tw-text-white"; +} + +export default function HeaderSearchModalPfp({ + src, + alt, + level, + size = 40, +}: { + readonly src?: string | null; + readonly alt?: string; + readonly level: number; + readonly size?: number; +}) { + const { data: resolved } = useResolvedIpfsUrl(src); + + const levelColor = getLevelBgColor(level); + + if (!resolved) { + return ( +
+ {level} +
+ ); + } + + return ( +
+ {alt +
+ {level} +
+
+ ); +} diff --git a/components/header/header-search/HeaderSearchTabToggle.tsx b/components/header/header-search/HeaderSearchTabToggle.tsx new file mode 100644 index 0000000000..07ac64eeb9 --- /dev/null +++ b/components/header/header-search/HeaderSearchTabToggle.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +interface HeaderSearchTabOption { + readonly key: string; + readonly label: string; + readonly hasIndicator?: boolean; + readonly panelId: string; +} + +interface HeaderSearchTabToggleProps { + readonly options: readonly HeaderSearchTabOption[]; + readonly activeKey: string; + readonly onSelect: (key: string) => void; + readonly fullWidth?: boolean; + readonly orientation?: "horizontal" | "vertical"; +} + +export const HeaderSearchTabToggle: React.FC = ({ + options, + activeKey, + onSelect, + fullWidth = false, + orientation = "horizontal", +}) => { + const isVertical = orientation === "vertical"; + const baseClasses = "tw-flex"; + const directionClasses = isVertical ? "tw-flex-col tw-gap-y-1.5" : "tw-gap-1"; + let widthClasses; + if (isVertical) { + widthClasses = ""; + } else if (fullWidth) { + widthClasses = "tw-w-full"; + } else { + widthClasses = "tw-w-auto"; + } + const containerClasses = + `${baseClasses} ${directionClasses} ${widthClasses}`.trim(); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; diff --git a/components/header/share/HeaderShare.tsx b/components/header/share/HeaderShare.tsx index a95795f1de..7ccd0c031a 100644 --- a/components/header/share/HeaderShare.tsx +++ b/components/header/share/HeaderShare.tsx @@ -248,7 +248,7 @@ function HeaderQRModal({ priority loading="eager" src="/6529Core.png" - alt="6529 Core" + alt="6529 Desktop" width={150} height={150} className="unselectable" @@ -258,7 +258,7 @@ function HeaderQRModal({ className="tw-flex tw-items-center tw-gap-2 tw-w-full" > -
Open in 6529 Core
+
Open in 6529 Desktop
@@ -448,7 +448,7 @@ function ModalMenu({ variant={activeSubTab === SubMode.CORE ? "light" : "outline-light"} onClick={() => onTabChange(activeTab, SubMode.CORE)} > - 6529 Core + 6529 Desktop )}
diff --git a/components/waves/Waves.tsx b/components/waves/Waves.tsx index 3644397b6f..908a385d6d 100644 --- a/components/waves/Waves.tsx +++ b/components/waves/Waves.tsx @@ -173,7 +173,10 @@ export default function Waves({ ), }; - const activeView = isApp ? viewMode : WavesViewMode.VIEW; + const activeView = + isApp || viewMode === WavesViewMode.CREATE + ? viewMode + : WavesViewMode.VIEW; return (
diff --git a/hooks/useResolvedIpfsUrl.ts b/hooks/useResolvedIpfsUrl.ts new file mode 100644 index 0000000000..a2dca474b4 --- /dev/null +++ b/hooks/useResolvedIpfsUrl.ts @@ -0,0 +1,11 @@ +import { resolveIpfsUrl } from "@/components/ipfs/IPFSContext"; +import { useQuery } from "@tanstack/react-query"; + +export function useResolvedIpfsUrl(src?: string | null) { + return useQuery({ + queryKey: ["ipfs-url", src], + queryFn: () => (src ? resolveIpfsUrl(src) : Promise.resolve(null)), + enabled: !!src, + staleTime: Infinity, + }); +} diff --git a/hooks/useSidebarSections.ts b/hooks/useSidebarSections.ts index 8db4d87d7d..a536a2be91 100644 --- a/hooks/useSidebarSections.ts +++ b/hooks/useSidebarSections.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type ComponentType } from "react"; import { UsersIcon, WrenchIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; import Squares2X2Icon from "@/components/common/icons/Squares2X2Icon"; import { SidebarSection } from "@/components/navigation/navTypes"; @@ -185,3 +185,38 @@ export function useSectionMap(sections: SidebarSection[]) { [sections] ); } + +export interface SidebarPageEntry { + name: string; + href: string; + section: string; + subsection?: string; + icon?: ComponentType<{ className?: string }>; +} + +export function mapSidebarSectionsToPages( + sections: SidebarSection[] +): SidebarPageEntry[] { + return sections.flatMap((section) => { + const sectionIcon = section.icon; + const sectionItems: SidebarPageEntry[] = section.items.map((item) => ({ + name: item.name, + href: item.href, + section: section.name, + icon: sectionIcon, + })); + + const subsectionItems = + section.subsections?.flatMap((subsection) => + subsection.items.map((item) => ({ + name: item.name, + href: item.href, + section: section.name, + subsection: subsection.name, + icon: sectionIcon, + })) + ) ?? []; + + return [...sectionItems, ...subsectionItems]; + }); +} diff --git a/hooks/useWaves.ts b/hooks/useWaves.ts index 4090a68388..3b87864051 100644 --- a/hooks/useWaves.ts +++ b/hooks/useWaves.ts @@ -23,6 +23,7 @@ interface UseWavesParams { readonly waveName: string | null; readonly limit?: number; readonly refetchInterval?: number; + readonly enabled?: boolean; } export function useWaves({ @@ -30,6 +31,7 @@ export function useWaves({ waveName, limit = 20, refetchInterval = Infinity, + enabled = true, }: UseWavesParams) { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); @@ -76,7 +78,7 @@ export function useWaves({ }, initialPageParam: null, getNextPageParam: (lastPage) => lastPage.at(-1)?.serial_no ?? null, - enabled: !usePublicWaves, + enabled: enabled && !usePublicWaves, refetchInterval, ...getDefaultQueryRetry(), }); @@ -101,7 +103,7 @@ export function useWaves({ }, initialPageParam: null, getNextPageParam: (lastPage) => lastPage.at(-1)?.serial_no ?? null, - enabled: usePublicWaves, + enabled: enabled && usePublicWaves, refetchInterval, ...getDefaultQueryRetry(), }); @@ -114,10 +116,13 @@ export function useWaves({ }; const [waves, setWaves] = useState(getWaves()); - useEffect( - () => setWaves(getWaves()), - [authQuery.data, publicQuery.data, usePublicWaves] - ); + useEffect(() => { + if (!enabled) { + setWaves([]); + return; + } + setWaves(getWaves()); + }, [enabled, authQuery.data, publicQuery.data, usePublicWaves]); const activeQuery = usePublicWaves ? publicQuery : authQuery; diff --git a/next.config.mjs b/next.config.mjs index 09562c772d..aff7125140 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -120,6 +120,7 @@ function sharedConfig(publicEnv, assetPrefix) { "localhost", "media.generator.seize.io", "d3lqz0a4bldqgf.cloudfront.net", + "ipfs.6529.io", ], minimumCacheTTL: 86400, },