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 = (
+
+ );
+ } else if (isError) {
+ salesContent = (
+
+
+ Failed to load sales
+ {salesErrorMessage ? `: ${salesErrorMessage}` : "."}
+
+
+ );
+ } else if (salesUrls.length === 0 && !hasNextPage) {
+ salesContent = (
+
+ );
+ } else {
+ salesContent = (
+
+ {salesUrls.length > 0 ? (
+
+ {salesUrls.map((url, index) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {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 && (