diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index 2148e78014..d322585d6d 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import BrainMobile from "@/components/brain/BrainMobile"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import BrainMobile, { BrainView } from "@/components/brain/BrainMobile"; jest.mock("next/image", () => ({ __esModule: true, @@ -34,12 +34,9 @@ jest.mock("@/hooks/useWaveData", () => ({ useWaveData: () => ({ data: waveData }), })); +const mockUseWave = jest.fn(); jest.mock("@/hooks/useWave", () => ({ - useWave: () => ({ - isMemesWave: false, - isCurationWave: false, - isRankWave: true, - }), + useWave: (...args: any[]) => mockUseWave(...args), })); jest.mock("@/hooks/useWaveTimers", () => ({ @@ -58,9 +55,13 @@ jest.mock("@/components/brain/BrainDesktopDrop", () => ({ ), })); +let latestTabsProps: any = null; jest.mock("@/components/brain/mobile/BrainMobileTabs", () => ({ __esModule: true, - default: () =>
, + default: (props: any) => { + latestTabsProps = props; + return
; + }, })); jest.mock("@/components/brain/mobile/BrainMobileAbout", () => ({ @@ -93,6 +94,12 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveOutcome", () => ({ default: () =>
, })); +const mockMyStreamWaveSales = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/MyStreamWaveSales", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveSales(props), +})); + jest.mock("@/components/waves/winners/WaveWinners", () => ({ __esModule: true, WaveWinners: () =>
, @@ -112,12 +119,19 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveFAQ", () => ({ describe("BrainMobile", () => { beforeEach(() => { + jest.clearAllMocks(); mockSearchParams = new URLSearchParams(); mockPathname = "/"; mockPush.mockClear(); dropData = null; waveData = null; isApp = true; + latestTabsProps = null; + mockUseWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: false, + isRankWave: true, + }); }); it("renders BrainDesktopDrop when drop is open", () => { @@ -145,4 +159,37 @@ describe("BrainMobile", () => { await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); rerender(
); }); + + it("renders the Sales view for non-rank curation waves and falls back when unavailable", async () => { + mockSearchParams.set("wave", "1"); + waveData = { id: "1", wave: { type: "APPROVE" } }; + mockUseWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + isRankWave: false, + }); + + const { rerender } = render(child); + + await waitFor(() => expect(screen.getByTestId("tabs")).toBeInTheDocument()); + + act(() => { + latestTabsProps.onViewChange(BrainView.SALES); + }); + + expect(screen.getByTestId("sales")).toBeInTheDocument(); + expect(mockMyStreamWaveSales).toHaveBeenCalledWith({ waveId: "1" }); + + mockUseWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: false, + isRankWave: false, + }); + rerender(child); + + await waitFor(() => { + expect(screen.queryByTestId("sales")).toBeNull(); + expect(screen.getByText("child")).toBeInTheDocument(); + }); + }); }); diff --git a/__tests__/components/brain/ContentTabContext.test.tsx b/__tests__/components/brain/ContentTabContext.test.tsx index 4e2402bde5..12c3deea26 100644 --- a/__tests__/components/brain/ContentTabContext.test.tsx +++ b/__tests__/components/brain/ContentTabContext.test.tsx @@ -89,7 +89,7 @@ describe("ContentTabContext", () => { expect(result.current.activeContentTab).toBe(MyStreamWaveTab.CHAT); }); - it("omits OUTCOME for curation waves", () => { + it("adds SALES and omits OUTCOME for curation waves", () => { const { result } = setup(); act(() => result.current.updateAvailableTabs({ @@ -105,6 +105,7 @@ describe("ContentTabContext", () => { expect(result.current.availableTabs).toEqual([ MyStreamWaveTab.CHAT, MyStreamWaveTab.LEADERBOARD, + MyStreamWaveTab.SALES, MyStreamWaveTab.MY_VOTES, ]); }); diff --git a/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx b/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx index 4746715f31..003ec1d17a 100644 --- a/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx +++ b/__tests__/components/brain/mobile/BrainMobileTabs.test.tsx @@ -7,6 +7,7 @@ enum BrainView { DEFAULT = "DEFAULT", ABOUT = "ABOUT", LEADERBOARD = "LEADERBOARD", + SALES = "SALES", WINNERS = "WINNERS", OUTCOME = "OUTCOME", MY_VOTES = "MY_VOTES", @@ -56,7 +57,12 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveTabsLeaderboard", () => ({ __esModule: true, default: (props: any) => { leaderboardMock(props); - return
; + return ( + <> +
+ {props.renderAfterLeaderboard} + + ); }, })); @@ -192,7 +198,31 @@ describe("BrainMobileTabs", () => { /> ); + expect(screen.getByText("Sales")).toBeInTheDocument(); expect(screen.getByText("My Votes")).toBeInTheDocument(); expect(screen.queryByText("FAQ")).toBeNull(); }); + + it("renders Sales for non-rank curation waves", () => { + (useWave as jest.Mock).mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + isRankWave: false, + }); + + render( + + ); + + expect(screen.getByText("Sales")).toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).toBeNull(); + }); }); diff --git a/__tests__/components/brain/my-stream/MyStreamWave.test.tsx b/__tests__/components/brain/my-stream/MyStreamWave.test.tsx index 612f719d32..2f89dad3ae 100644 --- a/__tests__/components/brain/my-stream/MyStreamWave.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWave.test.tsx @@ -34,6 +34,12 @@ jest.mock("@/components/brain/my-stream/MyStreamWaveOutcome", () => ({ default: () =>
, })); +const mockMyStreamWaveSales = jest.fn(() =>
); +jest.mock("@/components/brain/my-stream/MyStreamWaveSales", () => ({ + __esModule: true, + default: (props: any) => mockMyStreamWaveSales(props), +})); + jest.mock("@/components/waves/winners/WaveWinners", () => ({ __esModule: true, WaveWinners: ({ onDropClick }: any) => ( @@ -153,4 +159,12 @@ describe("MyStreamWave", () => { render(); expect(screen.getByTestId("tabs")).toHaveTextContent("1"); }); + + it("renders sales placeholder when SALES tab is active", () => { + useWaveData.mockReturnValue({ data: wave }); + useContentTab.mockReturnValue({ activeContentTab: MyStreamWaveTab.SALES }); + render(); + expect(screen.getByTestId("sales")).toBeInTheDocument(); + expect(mockMyStreamWaveSales).toHaveBeenCalledWith({ waveId: "1" }); + }); }); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx index 26d75070a1..3388d36569 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveDesktopTabs.test.tsx @@ -129,15 +129,37 @@ describe("MyStreamWaveDesktopTabs", () => { }; mockAvailableTabs = [ MyStreamWaveTab.CHAT, - MyStreamWaveTab.MY_VOTES, MyStreamWaveTab.LEADERBOARD, + MyStreamWaveTab.SALES, + MyStreamWaveTab.MY_VOTES, ]; renderComponent(MyStreamWaveTab.MY_VOTES); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Chat", + "Leaderboard", + "Sales", + "My Votes", + ]); + expect(screen.getByText("Sales")).toBeInTheDocument(); expect(screen.getByText("My Votes")).toBeInTheDocument(); expect(setActiveTab).not.toHaveBeenCalled(); }); + it("keeps Sales hidden outside curation waves", () => { + mockWaveInfo = { + isChatWave: false, + isMemesWave: false, + isCurationWave: false, + isRankWave: false, + }; + mockAvailableTabs = [MyStreamWaveTab.CHAT, MyStreamWaveTab.SALES]; + renderComponent(MyStreamWaveTab.SALES); + + expect(screen.queryByText("Sales")).toBeNull(); + expect(setActiveTab).toHaveBeenCalledWith(MyStreamWaveTab.CHAT); + }); + it("keeps FAQ hidden outside memes waves", () => { mockWaveInfo = { isChatWave: false, diff --git a/__tests__/components/brain/my-stream/MyStreamWaveSales.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveSales.test.tsx new file mode 100644 index 0000000000..4b94df196f --- /dev/null +++ b/__tests__/components/brain/my-stream/MyStreamWaveSales.test.tsx @@ -0,0 +1,334 @@ +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import MyStreamWaveSales from "@/components/brain/my-stream/MyStreamWaveSales"; +import { useWaveSalesDecisions } from "@/hooks/waves/useWaveSalesDecisions"; + +const mockSalesViewStyle = { height: "240px", maxHeight: "240px" }; +const mockMarketplacePreview = jest.fn(({ href }: { href: string }) => ( +
{href}
+)); +let intersectionCb: ((isIntersecting: boolean) => void) | undefined; + +jest.mock("@/hooks/waves/useWaveSalesDecisions", () => ({ + useWaveSalesDecisions: jest.fn(), +})); + +jest.mock("@/hooks/useIntersectionObserver", () => ({ + useIntersectionObserver: (cb: (isIntersecting: boolean) => void) => { + intersectionCb = cb; + return { current: null }; + }, +})); + +jest.mock("@/components/waves/MarketplacePreview", () => ({ + __esModule: true, + default: (props: any) => mockMarketplacePreview(props), +})); + +jest.mock( + "@/components/waves/leaderboard/drops/WaveLeaderboardLoadingBar", + () => ({ + WaveLeaderboardLoadingBar: () => ( +
+ ), + }) +); + +jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ + useLayout: () => ({ salesViewStyle: mockSalesViewStyle }), +})); + +const useWaveSalesDecisionsMock = useWaveSalesDecisions as jest.Mock; +const fetchNextPage = jest.fn(); + +const mockWaveDecisions = (overrides: Record = {}) => { + useWaveSalesDecisionsMock.mockReturnValue({ + decisionPoints: [], + fetchNextPage, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isError: false, + error: null, + refetch: jest.fn(), + ...overrides, + }); +}; + +const expectMockSalesViewStyle = () => { + const scrollContainer = screen.getByTestId("wave-sales-scroll-container"); + expect(scrollContainer.style.height).toBe(mockSalesViewStyle.height); + expect(scrollContainer.style.maxHeight).toBe(mockSalesViewStyle.maxHeight); + return scrollContainer; +}; + +describe("MyStreamWaveSales", () => { + beforeEach(() => { + jest.clearAllMocks(); + intersectionCb = undefined; + }); + + it("shows loading shell while decisions are fetching", () => { + mockWaveDecisions({ + isFetching: true, + }); + + render(); + + expectMockSalesViewStyle(); + expect(screen.getByText("Loading sales...")).toBeInTheDocument(); + expect(mockMarketplacePreview).not.toHaveBeenCalled(); + expect(useWaveSalesDecisionsMock).toHaveBeenCalledWith({ + waveId: "wave-1", + }); + }); + + it.each([ + ["an Error instance", new Error("boom"), "Failed to load sales: boom"], + ["a string error", "boom", "Failed to load sales: boom"], + ["an unknown error", null, "Failed to load sales."], + ])("shows the error state for %s", (_label, error, expectedText) => { + mockWaveDecisions({ + isError: true, + error, + }); + + render(); + + expect(screen.getByText(expectedText)).toBeInTheDocument(); + expect(mockMarketplacePreview).not.toHaveBeenCalled(); + }); + + it("shows empty shell when there are no decision winners and no further pages", () => { + mockWaveDecisions(); + + render(); + + expectMockSalesViewStyle(); + expect(screen.getByText("No sales yet.")).toBeInTheDocument(); + expect(mockMarketplacePreview).not.toHaveBeenCalled(); + }); + + it("shows empty shell when winner drops have no usable sale URLs and pagination is exhausted", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 1, + winners: [ + { + place: 1, + drop: { nft_links: [] }, + }, + { + place: 2, + drop: { + nft_links: [{ url_in_text: " " }, { url_in_text: null }], + }, + }, + ], + }, + ], + }); + + render(); + + expectMockSalesViewStyle(); + expect(screen.getByText("No sales yet.")).toBeInTheDocument(); + expect(mockMarketplacePreview).not.toHaveBeenCalled(); + }); + + it("renders a flat sales grid from loaded decision pages with latest rounds first", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 1, + winners: [ + { + place: 1, + drop: { + nft_links: [{ url_in_text: "https://market.example/old-1" }], + }, + }, + { + place: 2, + drop: { + nft_links: [{ url_in_text: "https://market.example/shared" }], + }, + }, + ], + }, + { + decision_time: 2, + winners: [ + { + place: 1, + drop: { + nft_links: [ + { url_in_text: " " }, + { url_in_text: "https://market.example/new-1" }, + ], + }, + }, + { + place: 2, + drop: { + nft_links: [], + }, + }, + { + place: 3, + drop: { + nft_links: [{ url_in_text: "https://market.example/shared" }], + }, + }, + ], + }, + ], + }); + + render(); + + const scrollContainer = expectMockSalesViewStyle(); + expect(scrollContainer).toHaveClass("tw-overflow-y-auto"); + expect(screen.getByTestId("wave-sales-grid")).toHaveClass( + "tw-grid", + "tw-gap-4", + "@lg:tw-grid-cols-2", + "@3xl:tw-grid-cols-3" + ); + expect( + screen.getAllByTestId("sale-preview").map((item) => item.textContent) + ).toEqual([ + "https://market.example/new-1", + "https://market.example/shared", + "https://market.example/old-1", + "https://market.example/shared", + ]); + }); + + it("fetches the next page when the pagination sentinel intersects", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 2, + winners: [ + { + place: 1, + drop: { + nft_links: [{ url_in_text: "https://market.example/new-1" }], + }, + }, + ], + }, + ], + hasNextPage: true, + }); + + render(); + + act(() => { + intersectionCb?.(true); + }); + + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("continues paging when loaded decisions have no renderable sales yet", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 2, + winners: [ + { + place: 1, + drop: { + nft_links: [], + }, + }, + ], + }, + ], + hasNextPage: true, + }); + + render(); + + expect(screen.getByText("No sales yet.")).toBeInTheDocument(); + + act(() => { + intersectionCb?.(true); + }); + + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("does not fetch the next page when pagination is unavailable", () => { + mockWaveDecisions({ + hasNextPage: false, + }); + + render(); + + act(() => { + intersectionCb?.(true); + }); + + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it("does not fetch the next page while a page fetch is already in flight", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 2, + winners: [ + { + place: 1, + drop: { + nft_links: [{ url_in_text: "https://market.example/new-1" }], + }, + }, + ], + }, + ], + hasNextPage: true, + isFetching: true, + isFetchingNextPage: true, + }); + + render(); + + act(() => { + intersectionCb?.(true); + }); + + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it("keeps the sales content visible while fetching the next page", () => { + mockWaveDecisions({ + decisionPoints: [ + { + decision_time: 2, + winners: [ + { + place: 1, + drop: { + nft_links: [{ url_in_text: "https://market.example/new-1" }], + }, + }, + ], + }, + ], + hasNextPage: true, + isFetching: true, + isFetchingNextPage: true, + }); + + render(); + + expect(screen.getByTestId("wave-sales-grid")).toBeInTheDocument(); + expect(screen.getByTestId("wave-sales-loading-bar")).toBeInTheDocument(); + expect(screen.queryByText("Loading sales...")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx index 0584e0688d..4570256e39 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveTabsLeaderboard.test.tsx @@ -1,14 +1,19 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import MyStreamWaveTabsLeaderboard from '@/components/brain/my-stream/MyStreamWaveTabsLeaderboard'; -import { BrainView } from '@/components/brain/BrainMobile'; - -jest.mock('@/components/brain/BrainMobile', () => ({ - BrainView: { DEFAULT: 'DEFAULT', LEADERBOARD: 'LEADERBOARD', WINNERS: 'WINNERS' } +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import MyStreamWaveTabsLeaderboard from "@/components/brain/my-stream/MyStreamWaveTabsLeaderboard"; +import { BrainView } from "@/components/brain/BrainMobile"; + +jest.mock("@/components/brain/BrainMobile", () => ({ + BrainView: { + DEFAULT: "DEFAULT", + LEADERBOARD: "LEADERBOARD", + SALES: "SALES", + WINNERS: "WINNERS", + }, })); -jest.mock('@/hooks/useWaveTimers', () => ({ +jest.mock("@/hooks/useWaveTimers", () => ({ useWaveTimers: () => ({ voting: { isCompleted: mockCompleted }, decisions: { firstDecisionDone: mockFirstDecision }, @@ -33,31 +38,45 @@ function renderComponent(view: BrainView = BrainView.DEFAULT, props: any = {}) { return { onViewChange, registerTabRef }; } -describe('MyStreamWaveTabsLeaderboard', () => { +describe("MyStreamWaveTabsLeaderboard", () => { beforeEach(() => { mockCompleted = false; mockFirstDecision = false; }); - it('shows leaderboard tab when voting ongoing', () => { + it("shows leaderboard tab when voting ongoing", () => { const { registerTabRef } = renderComponent(); - expect(screen.getByText('Leaderboard')).toBeInTheDocument(); - expect(registerTabRef).toHaveBeenCalledWith(BrainView.LEADERBOARD, expect.any(HTMLButtonElement)); - expect(screen.queryByText('Winners')).toBeNull(); + expect(screen.getByText("Leaderboard")).toBeInTheDocument(); + expect(registerTabRef).toHaveBeenCalledWith( + BrainView.LEADERBOARD, + expect.any(HTMLButtonElement) + ); + expect(screen.queryByText("Winners")).toBeNull(); }); - it('shows winners tab after first decision', async () => { + it("shows winners tab after first decision", async () => { mockFirstDecision = true; const { onViewChange } = renderComponent(); - const button = screen.getByText('Winners'); + const button = screen.getByText("Winners"); expect(button).toBeInTheDocument(); await userEvent.click(button); expect(onViewChange).toHaveBeenCalledWith(BrainView.WINNERS); }); - it('hides leaderboard when voting completed', () => { + it("hides leaderboard when voting completed", () => { mockCompleted = true; renderComponent(); - expect(screen.queryByText('Leaderboard')).toBeNull(); + expect(screen.queryByText("Leaderboard")).toBeNull(); + }); + + it("renders injected content between leaderboard and winners", () => { + mockFirstDecision = true; + renderComponent(BrainView.DEFAULT, { + renderAfterLeaderboard: , + }); + + expect( + screen.getAllByRole("button").map((button) => button.textContent) + ).toEqual(["Leaderboard", "Sales", "Winners"]); }); }); diff --git a/__tests__/hooks/useDebouncedQueryRefetch.test.ts b/__tests__/hooks/useDebouncedQueryRefetch.test.ts new file mode 100644 index 0000000000..f3f9a9e976 --- /dev/null +++ b/__tests__/hooks/useDebouncedQueryRefetch.test.ts @@ -0,0 +1,152 @@ +import { act, renderHook } from "@testing-library/react"; +import { useDebouncedQueryRefetch } from "@/hooks/useDebouncedQueryRefetch"; + +describe("useDebouncedQueryRefetch", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("refetches immediately when idle", () => { + const refetch = jest.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useDebouncedQueryRefetch({ + refetch, + isFetching: false, + isFetchingNextPage: false, + }) + ); + + act(() => { + result.current(); + }); + + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("debounces repeated refetch requests inside the wait window", () => { + const refetch = jest.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useDebouncedQueryRefetch({ + refetch, + isFetching: false, + isFetchingNextPage: false, + }) + ); + + act(() => { + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(200); + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(799); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it("waits for in-flight fetching to finish before refetching again", () => { + const refetch = jest.fn().mockResolvedValue(undefined); + + const { result, rerender } = renderHook( + ({ + isFetching, + isFetchingNextPage, + }: { + readonly isFetching: boolean; + readonly isFetchingNextPage: boolean; + }) => + useDebouncedQueryRefetch({ + refetch, + isFetching, + isFetchingNextPage, + }), + { + initialProps: { + isFetching: false, + isFetchingNextPage: false, + }, + } + ); + + act(() => { + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(200); + rerender({ + isFetching: true, + isFetchingNextPage: false, + }); + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + rerender({ + isFetching: false, + isFetchingNextPage: false, + }); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(799); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it("clears scheduled refetches on unmount", () => { + const refetch = jest.fn().mockResolvedValue(undefined); + + const { result, unmount } = renderHook(() => + useDebouncedQueryRefetch({ + refetch, + isFetching: false, + isFetchingNextPage: false, + }) + ); + + act(() => { + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(200); + result.current(); + }); + expect(refetch).toHaveBeenCalledTimes(1); + + unmount(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/hooks/useWaveDrops.test.ts b/__tests__/hooks/useWaveDrops.test.ts new file mode 100644 index 0000000000..c25ee38ae1 --- /dev/null +++ b/__tests__/hooks/useWaveDrops.test.ts @@ -0,0 +1,139 @@ +import { renderHook } from "@testing-library/react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { commonApiFetch } from "@/services/api/common-api"; +import { useWaveDrops } from "@/hooks/useWaveDrops"; + +jest.mock("@tanstack/react-query"); +jest.mock("@/services/api/common-api"); +jest.mock("@/services/websocket/useWebSocketMessage", () => ({ + useWebSocketMessage: jest.fn(), +})); + +const useInfiniteQueryMock = useInfiniteQuery as jest.Mock; +const commonApiFetchMock = commonApiFetch as jest.Mock; + +describe("useWaveDrops", () => { + beforeEach(() => { + jest.clearAllMocks(); + useInfiniteQueryMock.mockReturnValue({ + data: { pages: [] }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + refetch: jest.fn(), + }); + }); + + it("configures the query key and winner filter correctly", () => { + renderHook(() => + useWaveDrops({ + waveId: "wave-1", + dropType: ApiDropType.Winner, + limit: 12, + }) + ); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [ + QueryKey.DROPS, + { + waveId: "wave-1", + limit: 12, + dropType: ApiDropType.Winner, + containsMedia: false, + context: "wave-drops", + }, + ], + enabled: true, + }) + ); + }); + + it("calls the drops endpoint with wave and drop type filters", async () => { + commonApiFetchMock.mockResolvedValue([]); + renderHook(() => + useWaveDrops({ + waveId: "wave-1", + dropType: ApiDropType.Winner, + limit: 12, + }) + ); + + const options = useInfiniteQueryMock.mock.calls[0][0]; + await options.queryFn({ pageParam: 42 }); + + expect(commonApiFetchMock).toHaveBeenCalledWith({ + endpoint: "drops", + params: { + wave_id: "wave-1", + limit: "12", + drop_type: ApiDropType.Winner, + serial_no_less_than: "42", + }, + }); + }); + + it("adds the contains_media filter when requested", async () => { + commonApiFetchMock.mockResolvedValue([]); + renderHook(() => + useWaveDrops({ + waveId: "wave-1", + containsMedia: true, + }) + ); + + const options = useInfiniteQueryMock.mock.calls[0][0]; + await options.queryFn({ pageParam: null }); + + expect(commonApiFetchMock).toHaveBeenCalledWith({ + endpoint: "drops", + params: { + wave_id: "wave-1", + limit: "20", + contains_media: "true", + }, + }); + }); + + it("debounces refetch for same-wave websocket updates and ignores others", () => { + jest.useFakeTimers(); + const refetch = jest.fn().mockResolvedValue(undefined); + useInfiniteQueryMock.mockReturnValue({ + data: { pages: [] }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + refetch, + }); + + let socketCallback: ((message: { wave: { id: string } }) => void) | null = + null; + const { + useWebSocketMessage, + } = require("@/services/websocket/useWebSocketMessage"); + (useWebSocketMessage as jest.Mock).mockImplementation((_, callback) => { + socketCallback = callback; + }); + + renderHook(() => + useWaveDrops({ + waveId: "wave-1", + }) + ); + + socketCallback?.({ wave: { id: "wave-2" } }); + jest.advanceTimersByTime(1000); + expect(refetch).not.toHaveBeenCalled(); + + socketCallback?.({ wave: { id: "wave-1" } }); + jest.advanceTimersByTime(1000); + expect(refetch).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); +}); diff --git a/__tests__/hooks/useWaveGalleryDrops.test.ts b/__tests__/hooks/useWaveGalleryDrops.test.ts new file mode 100644 index 0000000000..1c3fe15370 --- /dev/null +++ b/__tests__/hooks/useWaveGalleryDrops.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from "@testing-library/react"; +import { useWaveGalleryDrops } from "@/hooks/useWaveGalleryDrops"; + +const mockUseWaveDrops = jest.fn(); + +jest.mock("@/hooks/useWaveDrops", () => ({ + useWaveDrops: (args: unknown) => mockUseWaveDrops(args), +})); + +describe("useWaveGalleryDrops", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseWaveDrops.mockReturnValue({ + drops: [], + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + refetch: jest.fn(), + }); + }); + + it("delegates to useWaveDrops with gallery defaults", () => { + const { result } = renderHook(() => useWaveGalleryDrops("wave-1")); + + expect(mockUseWaveDrops).toHaveBeenCalledWith({ + waveId: "wave-1", + containsMedia: true, + limit: 20, + }); + expect(result.current.drops).toEqual([]); + }); +}); diff --git a/__tests__/hooks/waves/useWaveDecisions.test.ts b/__tests__/hooks/waves/useWaveDecisions.test.ts index be4352d49c..e64210bede 100644 --- a/__tests__/hooks/waves/useWaveDecisions.test.ts +++ b/__tests__/hooks/waves/useWaveDecisions.test.ts @@ -1,34 +1,83 @@ -import { renderHook } from '@testing-library/react'; -import { useWaveDecisions } from '@/hooks/waves/useWaveDecisions'; -import { useQuery } from '@tanstack/react-query'; -import { commonApiFetch } from '@/services/api/common-api'; -import { QueryKey } from '@/components/react-query-wrapper/ReactQueryWrapper'; +import { renderHook } from "@testing-library/react"; +import { useQuery } from "@tanstack/react-query"; +import { useWaveDecisions } from "@/hooks/waves/useWaveDecisions"; +import { commonApiFetch } from "@/services/api/common-api"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -jest.mock('@tanstack/react-query'); -jest.mock('@/services/api/common-api'); +jest.mock("@tanstack/react-query", () => ({ + useQuery: jest.fn(), +})); +jest.mock("@/services/api/common-api"); const useQueryMock = useQuery as jest.Mock; const fetchMock = commonApiFetch as jest.Mock; -describe('useWaveDecisions', () => { - const wave = { id: 'w1' } as any; +describe("useWaveDecisions", () => { + beforeEach(() => { + jest.clearAllMocks(); + useQueryMock.mockReturnValue({ + data: { data: [] }, + isError: false, + error: null, + refetch: jest.fn(), + isFetching: false, + }); + }); - it('fetches decisions and sorts winners', async () => { - const data = { - data: [ - { decision_time: 2, winners: [{ place: 2 }, { place: 1 }] }, - { decision_time: 1, winners: [{ place: 1 }] } - ] + it("configures a single-page query and sorts loaded decisions", () => { + const unsortedDecision = { + decision_time: 2, + winners: [{ place: 2 }, { place: 1 }], }; - fetchMock.mockResolvedValue(data); - useQueryMock.mockReturnValue({ data, isError: false, error: null, refetch: jest.fn(), isFetching: false }); - const { result } = renderHook(() => useWaveDecisions({ wave })); - expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - queryKey: [QueryKey.WAVE_DECISIONS, { waveId: 'w1' }], - enabled: true, - })); - expect(result.current.decisionPoints[0]?.decision_time).toBe(1); - expect(result.current.decisionPoints[0]?.winners[0].place).toBe(1); - expect(result.current.decisionPoints[1]?.winners[0].place).toBe(1); + + useQueryMock.mockReturnValue({ + data: { + data: [ + { decision_time: 3, winners: [{ place: 3 }, { place: 1 }] }, + unsortedDecision, + { decision_time: 1, winners: [{ place: 1 }] }, + ], + }, + isError: false, + error: null, + refetch: jest.fn(), + isFetching: false, + }); + + const { result } = renderHook(() => useWaveDecisions({ waveId: "w1" })); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [QueryKey.WAVE_DECISIONS, { waveId: "w1" }], + enabled: true, + }) + ); + expect( + result.current.decisionPoints.map((point) => point.decision_time) + ).toEqual([1, 2, 3]); + expect( + result.current.decisionPoints[1]?.winners.map((winner) => winner.place) + ).toEqual([1, 2]); + }); + + it("requests the first page of decisions", async () => { + fetchMock.mockResolvedValue({ + data: [], + }); + + renderHook(() => useWaveDecisions({ waveId: "w1" })); + + const options = useQueryMock.mock.calls[0][0]; + await options.queryFn(); + + expect(fetchMock).toHaveBeenCalledWith({ + endpoint: "waves/w1/decisions", + params: { + sort_direction: "DESC", + sort: "decision_time", + page: "1", + page_size: "100", + }, + }); }); }); diff --git a/__tests__/hooks/waves/useWaveSalesDecisions.test.ts b/__tests__/hooks/waves/useWaveSalesDecisions.test.ts new file mode 100644 index 0000000000..310762dbeb --- /dev/null +++ b/__tests__/hooks/waves/useWaveSalesDecisions.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from "@testing-library/react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useWaveSalesDecisions } from "@/hooks/waves/useWaveSalesDecisions"; +import { commonApiFetch } from "@/services/api/common-api"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; + +jest.mock("@tanstack/react-query", () => ({ + useInfiniteQuery: jest.fn(), +})); +jest.mock("@/services/api/common-api"); + +const useInfiniteQueryMock = useInfiniteQuery as jest.Mock; +const fetchMock = commonApiFetch as jest.Mock; + +describe("useWaveSalesDecisions", () => { + beforeEach(() => { + jest.clearAllMocks(); + useInfiniteQueryMock.mockReturnValue({ + data: { pages: [], pageParams: [] }, + isError: false, + error: null, + refetch: jest.fn(), + isFetching: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + }); + }); + + it("uses a sales-specific query key and merges paginated decision pages", () => { + const fetchNextPage = jest.fn(); + const pageOneDecision = { + decision_time: 2, + winners: [{ place: 2 }, { place: 1 }], + }; + + useInfiniteQueryMock.mockReturnValue({ + data: { + pages: [ + { + page: 1, + next: true, + data: [ + { decision_time: 3, winners: [{ place: 3 }, { place: 1 }] }, + pageOneDecision, + ], + }, + { + page: 2, + next: false, + data: [ + { decision_time: 2, winners: [{ place: 99 }] }, + { decision_time: 1, winners: [{ place: 1 }] }, + ], + }, + ], + pageParams: [1, 2], + }, + isError: false, + error: null, + refetch: jest.fn(), + isFetching: false, + fetchNextPage, + hasNextPage: true, + isFetchingNextPage: false, + }); + + const { result } = renderHook(() => + useWaveSalesDecisions({ waveId: "w1" }) + ); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [QueryKey.WAVE_DECISIONS_SALES, { waveId: "w1" }], + enabled: true, + initialPageParam: 1, + }) + ); + expect( + result.current.decisionPoints.map((point) => point.decision_time) + ).toEqual([1, 2, 3]); + expect( + result.current.decisionPoints[1]?.winners.map((winner) => winner.place) + ).toEqual([1, 2]); + expect(result.current.fetchNextPage).toBe(fetchNextPage); + expect(result.current.hasNextPage).toBe(true); + }); + + it("requests the current page and advances based on the API next flag", async () => { + fetchMock.mockResolvedValue({ + page: 3, + next: true, + count: 250, + data: [], + }); + + renderHook(() => useWaveSalesDecisions({ waveId: "w1" })); + + const options = useInfiniteQueryMock.mock.calls[0][0]; + await options.queryFn({ pageParam: 3 }); + + expect(fetchMock).toHaveBeenCalledWith({ + endpoint: "waves/w1/decisions", + params: { + sort_direction: "DESC", + sort: "decision_time", + page: "3", + page_size: "100", + }, + }); + expect(options.getNextPageParam({ page: 3, next: true })).toBe(4); + expect(options.getNextPageParam({ page: 3, next: false })).toBeUndefined(); + }); +}); diff --git a/components/brain/BrainMobile.tsx b/components/brain/BrainMobile.tsx index 27159387f4..ff0f4c9088 100644 --- a/components/brain/BrainMobile.tsx +++ b/components/brain/BrainMobile.tsx @@ -19,9 +19,9 @@ import { useWaveTimers } from "@/hooks/useWaveTimers"; import { QueryKey } from "../react-query-wrapper/ReactQueryWrapper"; import MyStreamWaveMyVotes from "./my-stream/votes/MyStreamWaveMyVotes"; import MyStreamWaveFAQ from "./my-stream/MyStreamWaveFAQ"; +import MyStreamWaveSales from "./my-stream/MyStreamWaveSales"; import { useWave } from "@/hooks/useWave"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { ApiWaveType } from "@/generated/models/ApiWaveType"; import BrainMobileWaves from "./mobile/BrainMobileWaves"; import BrainMobileMessages from "./mobile/BrainMobileMessages"; import useDeviceInfo from "@/hooks/useDeviceInfo"; @@ -42,6 +42,7 @@ export enum BrainView { DEFAULT = "DEFAULT", ABOUT = "ABOUT", LEADERBOARD = "LEADERBOARD", + SALES = "SALES", WINNERS = "WINNERS", OUTCOME = "OUTCOME", MY_VOTES = "MY_VOTES", @@ -107,7 +108,7 @@ const BrainMobile: React.FC = ({ children }) => { }, }); - const { isMemesWave, isCurationWave } = useWave(wave); + const { isMemesWave, isCurationWave, isRankWave } = useWave(wave); const { voting: { isCompleted }, @@ -138,8 +139,6 @@ const BrainMobile: React.FC = ({ children }) => { !!drop && drop.id.toLowerCase() === effectiveDropId.toLowerCase(); - const isRankWave = wave?.wave.type === ApiWaveType.Rank; - const hasWave = Boolean(waveId); useEffect(() => { @@ -207,6 +206,7 @@ const BrainMobile: React.FC = ({ children }) => { const shouldResetToDefault = (activeView === BrainView.LEADERBOARD && isCompleted) || + (activeView === BrainView.SALES && !isCurationWave) || (activeView === BrainView.WINNERS && !firstDecisionDone) || (activeView === BrainView.MY_VOTES && !isMemesWave && !isCurationWave) || (activeView === BrainView.FAQ && !isMemesWave); @@ -233,6 +233,7 @@ const BrainMobile: React.FC = ({ children }) => { activeView, isMemesWave, isCurationWave, + isRankWave, waveId, pathname, ]); @@ -285,6 +286,8 @@ const BrainMobile: React.FC = ({ children }) => { onDropClick={onDropClick} /> ) : null, + [BrainView.SALES]: + isCurationWave && !!wave ? : null, [BrainView.WINNERS]: isRankWave && !!wave ? (
diff --git a/components/brain/ContentTabContext.tsx b/components/brain/ContentTabContext.tsx index 3b8d50bdab..6e4b4ce69b 100644 --- a/components/brain/ContentTabContext.tsx +++ b/components/brain/ContentTabContext.tsx @@ -78,6 +78,9 @@ const buildDefaultTabs = ( if (votingState !== WaveVotingState.ENDED) { tabs.push(MyStreamWaveTab.LEADERBOARD); } + if (isCurationWave) { + tabs.push(MyStreamWaveTab.SALES); + } if (hasFirstDecisionPassed) { tabs.push(MyStreamWaveTab.WINNERS); } diff --git a/components/brain/mobile/BrainMobileTabs.tsx b/components/brain/mobile/BrainMobileTabs.tsx index 8e717e94ff..a30f4a027f 100644 --- a/components/brain/mobile/BrainMobileTabs.tsx +++ b/components/brain/mobile/BrainMobileTabs.tsx @@ -68,6 +68,7 @@ const BrainMobileTabs: React.FC = ({ [BrainView.DEFAULT]: null, [BrainView.ABOUT]: null, [BrainView.LEADERBOARD]: null, + [BrainView.SALES]: null, [BrainView.WINNERS]: null, [BrainView.OUTCOME]: null, [BrainView.MY_VOTES]: null, @@ -111,6 +112,14 @@ const BrainMobileTabs: React.FC = ({ activeView === BrainView.MY_VOTES ? "tw-text-iron-300" : "tw-text-iron-400" }`; + const salesButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ + activeView === BrainView.SALES ? "tw-bg-iron-800" : "tw-bg-iron-950" + }`; + + const salesButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ + activeView === BrainView.SALES ? "tw-text-iron-300" : "tw-text-iron-400" + }`; + const chatButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ activeView === BrainView.DEFAULT ? "tw-bg-iron-800" : "tw-bg-iron-950" }`; @@ -155,6 +164,19 @@ const BrainMobileTabs: React.FC = ({ onViewChange(BrainView.NOTIFICATIONS); }; + const salesTabButton = + waveActive && wave && isCurationWave ? ( + + ) : null; + return (
= ({ About )} + {!isRankWave && salesTabButton} {waveActive && wave && isRankWave && ( <> = ({ registerTabRef={(view, el) => { tabRefs.current[view] = el; }} + renderAfterLeaderboard={salesTabButton} /> {(isMemesWave || isCurationWave) && ( <> diff --git a/components/brain/my-stream/MyStreamWave.tsx b/components/brain/my-stream/MyStreamWave.tsx index 7844db1a20..324c9f4588 100644 --- a/components/brain/my-stream/MyStreamWave.tsx +++ b/components/brain/my-stream/MyStreamWave.tsx @@ -15,6 +15,7 @@ import { MyStreamWaveTab } from "@/types/waves.types"; import { MyStreamWaveTabs } from "./tabs/MyStreamWaveTabs"; import MyStreamWaveMyVotes from "./votes/MyStreamWaveMyVotes"; import MyStreamWaveFAQ from "./MyStreamWaveFAQ"; +import MyStreamWaveSales from "./MyStreamWaveSales"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; import { createBreakpoint } from "react-use"; import { getHomeRoute, getWaveHomeRoute } from "@/helpers/navigation.helpers"; @@ -123,6 +124,7 @@ const MyStreamWave: React.FC = ({ waveId }) => { onDropClick={onDropClick} /> ), + [MyStreamWaveTab.SALES]: , [MyStreamWaveTab.WINNERS]: ( ), diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 1d7546bbdb..52a10a3629 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -31,6 +31,7 @@ const AUTO_EXPAND_LIMIT = 5; const TAB_LABELS: Record = { [MyStreamWaveTab.CHAT]: "Chat", [MyStreamWaveTab.LEADERBOARD]: "Leaderboard", + [MyStreamWaveTab.SALES]: "Sales", [MyStreamWaveTab.WINNERS]: "Winners", [MyStreamWaveTab.OUTCOME]: "Outcome", [MyStreamWaveTab.MY_VOTES]: "My Votes", @@ -172,6 +173,9 @@ const MyStreamWaveDesktopTabs: React.FC = ({ if (tab === MyStreamWaveTab.MY_VOTES) { return isMemesWave || isCurationWave; } + if (tab === MyStreamWaveTab.SALES) { + return isCurationWave; + } if (tab === MyStreamWaveTab.FAQ) { return isMemesWave; } @@ -188,9 +192,14 @@ const MyStreamWaveDesktopTabs: React.FC = ({ useEffect(() => { const isMyVotesHidden = activeTab === MyStreamWaveTab.MY_VOTES && !isMemesWave && !isCurationWave; + const isSalesHidden = + activeTab === MyStreamWaveTab.SALES && !isCurationWave; const isFaqHidden = activeTab === MyStreamWaveTab.FAQ && !isMemesWave; - if ((isMyVotesHidden || isFaqHidden) && options.length > 0) { + if ( + (isMyVotesHidden || isSalesHidden || isFaqHidden) && + options.length > 0 + ) { setActiveTab(options[0]?.key!); } }, [isMemesWave, isCurationWave, activeTab, options, setActiveTab]); diff --git a/components/brain/my-stream/MyStreamWaveSales.tsx b/components/brain/my-stream/MyStreamWaveSales.tsx new file mode 100644 index 0000000000..3f811067c2 --- /dev/null +++ b/components/brain/my-stream/MyStreamWaveSales.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { WaveLeaderboardLoadingBar } from "@/components/waves/leaderboard/drops/WaveLeaderboardLoadingBar"; +import MarketplacePreview from "@/components/waves/MarketplacePreview"; +import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useWaveSalesDecisions } from "@/hooks/waves/useWaveSalesDecisions"; +import { useLayout } from "./layout/LayoutContext"; +import React, { useCallback, useMemo } from "react"; + +interface MyStreamWaveSalesProps { + readonly waveId: string; +} + +const getFirstSaleUrl = ( + nftLinks: { readonly url_in_text?: string | null | undefined }[] | undefined +): string | null => { + for (const nftLink of nftLinks ?? []) { + if (typeof nftLink.url_in_text !== "string") { + continue; + } + + const url = nftLink.url_in_text.trim(); + if (url.length > 0) { + return url; + } + } + + return null; +}; + +const getSalesErrorMessage = (error: unknown): string | null => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + return null; +}; + +const MyStreamWaveSales: React.FC = ({ waveId }) => { + const { salesViewStyle } = useLayout(); + const { + decisionPoints, + error, + fetchNextPage, + hasNextPage, + isError, + isFetching, + isFetchingNextPage, + } = useWaveSalesDecisions({ waveId }); + const salesUrls = useMemo( + () => + decisionPoints + .slice() + .reverse() + .flatMap((decisionPoint) => + decisionPoint.winners.flatMap((winner) => { + const url = getFirstSaleUrl(winner.drop.nft_links); + return url ? [url] : []; + }) + ), + [decisionPoints] + ); + + const handleIntersection = useCallback( + (isIntersecting: boolean) => { + if (!isIntersecting || !hasNextPage || isFetching || isFetchingNextPage) { + return; + } + + void fetchNextPage(); + }, + [fetchNextPage, hasNextPage, isFetching, isFetchingNextPage] + ); + const intersectionElementRef = useIntersectionObserver(handleIntersection); + const isInitialLoading = + isFetching && !isFetchingNextPage && decisionPoints.length === 0; + const salesErrorMessage = getSalesErrorMessage(error); + + let salesContent: React.ReactNode; + if (isInitialLoading) { + salesContent = ( +
+

+ Loading sales... +

+
+ ); + } else if (isError) { + salesContent = ( +
+

+ Failed to load sales + {salesErrorMessage ? `: ${salesErrorMessage}` : "."} +

+
+ ); + } else if (salesUrls.length === 0 && !hasNextPage) { + salesContent = ( +
+

+ No sales yet. +

+
+ ); + } else { + salesContent = ( +
+ {salesUrls.length > 0 ? ( +
+ {salesUrls.map((url, index) => ( + + ))} +
+ ) : ( +
+

+ No sales yet. +

+
+ )} + + {isFetchingNextPage && ( +
+ +
+ )} + {hasNextPage && ( + + ); + } + + return ( +
+ {salesContent} +
+ ); +}; + +export default MyStreamWaveSales; diff --git a/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx index 57b2bc1fe8..e86ff53043 100644 --- a/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveTabsLeaderboard.tsx @@ -10,11 +10,18 @@ interface MyStreamWaveTabsLeaderboardProps { readonly activeView: BrainView; readonly onViewChange: (view: BrainView) => void; readonly registerTabRef?: RegisterTabRef | undefined; + readonly renderAfterLeaderboard?: React.ReactNode; } const MyStreamWaveTabsLeaderboard: React.FC< MyStreamWaveTabsLeaderboardProps -> = ({ wave, activeView, onViewChange, registerTabRef }) => { +> = ({ + wave, + activeView, + onViewChange, + registerTabRef, + renderAfterLeaderboard, +}) => { const { voting: { isCompleted }, decisions: { firstDecisionDone }, @@ -43,7 +50,7 @@ const MyStreamWaveTabsLeaderboard: React.FC< {/* Show Leaderboard tab always except when voting has ended */} {!isCompleted && ( )} + {renderAfterLeaderboard} {/* Show Winners tab if first decision has passed */} {firstDecisionDone && (