diff --git a/__tests__/components/brain/my-stream/MyStreamWave.test.tsx b/__tests__/components/brain/my-stream/MyStreamWave.test.tsx index 7409f7b728..612f719d32 100644 --- a/__tests__/components/brain/my-stream/MyStreamWave.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWave.test.tsx @@ -1,78 +1,92 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import MyStreamWave from '@/components/brain/my-stream/MyStreamWave'; -import { MyStreamWaveTab } from '@/types/waves.types'; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import MyStreamWave from "@/components/brain/my-stream/MyStreamWave"; +import { MyStreamWaveTab } from "@/types/waves.types"; + +const mockSetQueryData = jest.fn(); +jest.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + setQueryData: mockSetQueryData, + }), +})); + +const useWave = jest.fn(); +jest.mock("@/hooks/useWave", () => ({ + useWave: (...args: any[]) => useWave(...args), +})); -jest.mock('@/components/brain/my-stream/MyStreamWaveChat', () => ({ +jest.mock("@/components/brain/my-stream/MyStreamWaveChat", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/brain/my-stream/MyStreamWaveLeaderboard', () => ({ +jest.mock("@/components/brain/my-stream/MyStreamWaveLeaderboard", () => ({ __esModule: true, default: ({ onDropClick }: any) => ( - ), })); -jest.mock('@/components/brain/my-stream/MyStreamWaveOutcome', () => ({ +jest.mock("@/components/brain/my-stream/MyStreamWaveOutcome", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/waves/winners/WaveWinners', () => ({ +jest.mock("@/components/waves/winners/WaveWinners", () => ({ __esModule: true, WaveWinners: ({ onDropClick }: any) => ( - ), })); -jest.mock('@/components/brain/my-stream/votes/MyStreamWaveMyVotes', () => ({ +jest.mock("@/components/brain/my-stream/votes/MyStreamWaveMyVotes", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/brain/my-stream/MyStreamWaveFAQ', () => ({ +jest.mock("@/components/brain/my-stream/MyStreamWaveFAQ", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/brain/my-stream/tabs/MyStreamWaveTabs', () => ({ +jest.mock("@/components/brain/my-stream/tabs/MyStreamWaveTabs", () => ({ __esModule: true, MyStreamWaveTabs: ({ wave }: any) =>
{wave.id}
, })); const useContentTab = jest.fn(); -jest.mock('@/components/brain/ContentTabContext', () => ({ +jest.mock("@/components/brain/ContentTabContext", () => ({ useContentTab: (...args: any[]) => useContentTab(...args), })); const useWaveData = jest.fn(); -jest.mock('@/hooks/useWaveData', () => ({ +jest.mock("@/hooks/useWaveData", () => ({ useWaveData: (...args: any[]) => useWaveData(...args), })); const mockRouterPush = jest.fn(); -const mockSearchParams = new URLSearchParams('wave=1'); -const mockPathname = '/path'; +const mockSearchParams = new URLSearchParams("wave=1"); +const mockPathname = "/path"; -jest.mock('next/navigation', () => ({ +jest.mock("next/navigation", () => ({ useRouter: () => ({ push: mockRouterPush }), useSearchParams: () => mockSearchParams, usePathname: () => mockPathname, })); -let mockBreakpoint = 'LG'; -jest.mock('react-use', () => ({ createBreakpoint: () => () => mockBreakpoint })); +let mockBreakpoint = "LG"; +jest.mock("react-use", () => ({ + createBreakpoint: () => () => mockBreakpoint, +})); // Mock TitleContext -jest.mock('@/contexts/TitleContext', () => ({ +jest.mock("@/contexts/TitleContext", () => ({ useTitle: () => ({ - title: 'Test Title', + title: "Test Title", setTitle: jest.fn(), notificationCount: 0, setNotificationCount: jest.fn(), @@ -85,7 +99,7 @@ jest.mock('@/contexts/TitleContext', () => ({ })); // Mock MyStreamContext if needed -jest.mock('@/contexts/wave/MyStreamContext', () => ({ +jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: () => ({ waveId: null, setWaveId: jest.fn(), @@ -97,38 +111,46 @@ jest.mock('@/contexts/wave/MyStreamContext', () => ({ MyStreamProvider: ({ children }) => children, })); +const wave = { id: "1", wave: {} } as any; -const wave = { id: '1', wave: {} } as any; - -describe('MyStreamWave', () => { +describe("MyStreamWave", () => { beforeEach(() => { jest.clearAllMocks(); mockRouterPush.mockClear(); - mockSearchParams.set('wave', '1'); - mockBreakpoint = 'LG'; + mockSearchParams.set("wave", "1"); + mockBreakpoint = "LG"; + useWave.mockReturnValue({ + isRankWave: true, + isMemesWave: false, + isDm: false, + }); }); - it('returns null when no wave data', () => { + it("returns null when no wave data", () => { useWaveData.mockReturnValue({ data: undefined }); useContentTab.mockReturnValue({ activeContentTab: MyStreamWaveTab.CHAT }); const { container } = render(); expect(container.firstChild).toBeNull(); }); - it('renders leaderboard tab and handles drop click', () => { + it("renders leaderboard tab and handles drop click", () => { useWaveData.mockReturnValue({ data: wave }); - useContentTab.mockReturnValue({ activeContentTab: MyStreamWaveTab.LEADERBOARD }); + useContentTab.mockReturnValue({ + activeContentTab: MyStreamWaveTab.LEADERBOARD, + }); render(); - expect(screen.getByTestId('tabs')).toHaveTextContent('1'); - fireEvent.click(screen.getByTestId('leaderboard')); - expect(mockRouterPush).toHaveBeenCalledWith('/path?wave=1&drop=d1', { scroll: false }); + expect(screen.getByTestId("tabs")).toHaveTextContent("1"); + fireEvent.click(screen.getByTestId("leaderboard")); + expect(mockRouterPush).toHaveBeenCalledWith("/path?wave=1&drop=d1", { + scroll: false, + }); }); - it('still renders tabs when breakpoint is small', () => { - mockBreakpoint = 'S'; + it("still renders tabs when breakpoint is small", () => { + mockBreakpoint = "S"; useWaveData.mockReturnValue({ data: wave }); useContentTab.mockReturnValue({ activeContentTab: MyStreamWaveTab.CHAT }); render(); - expect(screen.getByTestId('tabs')).toHaveTextContent('1'); + expect(screen.getByTestId("tabs")).toHaveTextContent("1"); }); }); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx index 752d5d9600..558d132406 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { AuthContext } from "@/components/auth/Auth"; @@ -14,9 +14,15 @@ const replace = jest.fn(); let searchParamsString = ""; let dropsProps: any; let createDropProps: any[] = []; +let curationModalProps: any; jest.mock("@/hooks/useWave", () => ({ useWave: (...args: any[]) => useWave(...args), + SubmissionStatus: { + NOT_STARTED: "NOT_STARTED", + ACTIVE: "ACTIVE", + ENDED: "ENDED", + }, })); jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ useLayout: (...args: any[]) => useLayout(...args), @@ -84,6 +90,17 @@ jest.mock( "@/components/waves/memes/MemesArtSubmissionModal", () => (props: any) => (props.isOpen ?
: null) ); +jest.mock( + "@/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal", + () => ({ + WaveLeaderboardCurationDropModal: (props: any) => { + curationModalProps = props; + return props.isOpen ? ( + + + + ); + + const opener = screen.getByTestId("opener"); + opener.focus(); + expect(opener).toHaveFocus(); + + rerender( + <> + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText("Close modal")).toHaveFocus(); + }); + + rerender( + <> + + + + ); + + await waitFor(() => { + expect(opener).toHaveFocus(); + }); + }); + + it("locks body scroll while open and restores it when closed", () => { + const onClose = jest.fn(); + const originalOverflow = "scroll"; + document.body.style.overflow = originalOverflow; + + const { rerender } = render( + + ); + + expect(document.body.style.overflow).toBe("hidden"); + + rerender( + + ); + + expect(document.body.style.overflow).toBe(originalOverflow); + }); + + it("closes after successful submission", async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + + render( + + ); + + await user.click(await screen.findByTestId("modal-create-drop")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/waves/leaderboard/header/WaveleaderboardHeader.test.tsx b/__tests__/components/waves/leaderboard/header/WaveleaderboardHeader.test.tsx index fe7c2c1a1b..93661a7afb 100644 --- a/__tests__/components/waves/leaderboard/header/WaveleaderboardHeader.test.tsx +++ b/__tests__/components/waves/leaderboard/header/WaveleaderboardHeader.test.tsx @@ -1,7 +1,13 @@ import { AuthContext } from "@/components/auth/Auth"; import { WaveLeaderboardHeader } from "@/components/waves/leaderboard/header/WaveleaderboardHeader"; import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; -import { render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const useWave = jest.fn(); @@ -21,7 +27,7 @@ const curationComponentMock = jest.fn((props: any) => ( Curation )); -const resolveControlModesMock = jest.fn(); +const resolveHeaderLayoutMock = jest.fn(); jest.mock("@/components/waves/leaderboard/header/WaveleaderboardSort", () => ({ WAVE_LEADERBOARD_SORT_ITEMS: [ @@ -34,6 +40,17 @@ jest.mock("@/components/waves/leaderboard/header/WaveleaderboardSort", () => ({ { key: "TREND", label: "Hot", value: "TREND" }, { key: "CREATED_AT", label: "Newest", value: "CREATED_AT" }, ], + WAVE_LEADERBOARD_CURATION_SORT_ITEMS: [ + { key: "RANK", label: "Current Vote", value: "RANK" }, + { + key: "RATING_PREDICTION", + label: "Projected Vote", + value: "RATING_PREDICTION", + }, + { key: "TREND", label: "Hot", value: "TREND" }, + { key: "CREATED_AT", label: "Newest", value: "CREATED_AT" }, + { key: "PRICE", label: "Price", value: "PRICE" }, + ], WaveleaderboardSort: (props: any) => sortComponentMock(props), })); @@ -46,20 +63,26 @@ jest.mock( ); jest.mock( - "@/components/waves/leaderboard/header/waveLeaderboardHeaderControls", + "@/components/waves/leaderboard/header/waveLeaderboardHeaderLayout", () => ({ - resolveWaveLeaderboardHeaderControlModes: (...args: any[]) => - resolveControlModesMock(...args), + resolveWaveLeaderboardHeaderLayout: (...args: any[]) => + resolveHeaderLayoutMock(...args), }) ); jest.mock("@/hooks/useWave", () => ({ useWave: (...args: any[]) => useWave(...args), + SubmissionStatus: { + NOT_STARTED: "NOT_STARTED", + ACTIVE: "ACTIVE", + ENDED: "ENDED", + }, })); jest.mock("@/components/utils/button/PrimaryButton", () => (props: any) => ( )); -jest.mock("react-use", () => ({ - createBreakpoint: jest.fn(() => () => "MD"), -})); +jest.mock("react-use", () => { + const React = require("react"); + + return { + createBreakpoint: jest.fn(() => () => "MD"), + useDebounce: (fn: () => void, ms: number, deps: readonly unknown[]) => { + React.useEffect(() => { + const timeoutId = setTimeout(() => { + fn(); + }, ms); + return () => clearTimeout(timeoutId); + }, deps); + }, + }; +}); const wave = { id: "w" } as any; beforeEach(() => { sortComponentMock.mockClear(); curationComponentMock.mockClear(); - resolveControlModesMock.mockReset(); - resolveControlModesMock.mockReturnValue({ + resolveHeaderLayoutMock.mockReset(); + resolveHeaderLayoutMock.mockReturnValue({ sortMode: "dropdown", curationMode: "tabs", enableControlsScroll: false, + actionMode: "full", + wrapActions: false, }); useWave.mockReturnValue({ @@ -220,6 +257,570 @@ it("renders curation selector and handles curation filter changes", async () => expect(onCurationGroupChange).toHaveBeenCalledWith("cg-1"); }); +it("renders curation price controls and commits range updates", async () => { + const user = userEvent.setup(); + const onPriceRangeChange = jest.fn(); + const onCreateDrop = jest.fn(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + expect( + screen.queryByTestId("leaderboard-price-panel") + ).not.toBeInTheDocument(); + expect(screen.getByTestId("leaderboard-header-actions-row")).toHaveAttribute( + "data-action-mode", + "full" + ); + expect(screen.getByTestId("leaderboard-header-actions-row")).toHaveAttribute( + "data-wrap", + "no" + ); + const createButton = screen.getByTestId("create"); + expect(createButton).toHaveAttribute("data-padding", "tw-px-3.5 tw-py-2"); + expect(screen.getAllByText("Drop Art").length).toBeGreaterThan(0); + const createIcon = createButton.querySelector("svg"); + expect(createIcon).toHaveClass("tw-h-4", "tw-w-4"); + + await user.click(createButton); + expect(onCreateDrop).toHaveBeenCalledTimes(1); + + await user.click(screen.getByTestId("leaderboard-price-toggle")); + const minInput = screen.getByTestId("leaderboard-price-min-input"); + const maxInput = screen.getByTestId("leaderboard-price-max-input"); + const priceFiltersContainer = minInput.parentElement?.parentElement; + + expect(minInput).toHaveClass("tw-h-9", "tw-text-sm"); + expect(maxInput).toHaveClass("tw-h-9", "tw-text-sm"); + expect(priceFiltersContainer).not.toBeNull(); + expect(priceFiltersContainer).not.toHaveClass("tw-bg-iron-950"); + expect(priceFiltersContainer).not.toHaveClass("tw-border"); + expect(priceFiltersContainer).not.toHaveClass("tw-rounded-lg"); + expect(priceFiltersContainer).not.toHaveClass("tw-p-2"); + expect(minInput).toHaveAttribute("placeholder", "Min"); + expect(maxInput).toHaveAttribute("placeholder", "Max"); + expect(screen.getByLabelText("Minimum ETH")).toBe(minInput); + expect(screen.getByLabelText("Maximum ETH")).toBe(maxInput); + expect( + screen.queryByRole("button", { name: "Clear filters" }) + ).not.toBeInTheDocument(); + expect(screen.queryByText("Clear Filters")).not.toBeInTheDocument(); + await user.clear(minInput); + await user.type(minInput, "1.5"); + await user.clear(maxInput); + await user.type(maxInput, "3.25"); + await user.tab(); + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: 1.5, + maxPrice: 3.25, + }); + + const latestSortProps = + sortComponentMock.mock.calls[sortComponentMock.mock.calls.length - 1]?.[0]; + expect(latestSortProps?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: "PRICE", label: "Price" }), + ]) + ); + + const clearButton = screen.getByRole("button", { name: "Clear filters" }); + expect(clearButton).toHaveClass("tw-h-9", "tw-w-9", "tw-rounded-lg"); + await user.click(clearButton); + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: undefined, + maxPrice: undefined, + }); +}); + +it("uses long placeholders when the price filter container is wide", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const getBoundingClientRectSpy = jest + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function () { + return { + x: 0, + y: 0, + top: 0, + left: 0, + bottom: 40, + right: 600, + width: 600, + height: 40, + toJSON: () => ({}), + } as DOMRect; + }); + + const observeMock = jest.fn(); + const disconnectMock = jest.fn(); + let resizeObserverCallback: ResizeObserverCallback | null = null; + + globalThis.ResizeObserver = jest + .fn() + .mockImplementation((callback: ResizeObserverCallback) => { + resizeObserverCallback = callback; + return { + observe: observeMock, + unobserve: jest.fn(), + disconnect: disconnectMock, + }; + }) as unknown as typeof ResizeObserver; + + try { + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + const { unmount } = render( + + + + ); + + fireEvent.click(screen.getByTestId("leaderboard-price-toggle")); + const minInput = screen.getByTestId("leaderboard-price-min-input"); + const maxInput = screen.getByTestId("leaderboard-price-max-input"); + + expect(minInput).toHaveAttribute("placeholder", "Minimum ETH"); + expect(maxInput).toHaveAttribute("placeholder", "Maximum ETH"); + + expect(observeMock).toHaveBeenCalled(); + expect(resizeObserverCallback).toBeTruthy(); + + act(() => { + resizeObserverCallback?.([], {} as ResizeObserver); + }); + + await waitFor(() => + expect(minInput).toHaveAttribute("placeholder", "Minimum ETH") + ); + expect(maxInput).toHaveAttribute("placeholder", "Maximum ETH"); + + unmount(); + expect(disconnectMock).toHaveBeenCalled(); + } finally { + getBoundingClientRectSpy.mockRestore(); + globalThis.ResizeObserver = originalResizeObserver; + } +}); + +it("auto-applies price range updates after debounce while typing", () => { + jest.useFakeTimers(); + + try { + const onPriceRangeChange = jest.fn(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("leaderboard-price-toggle")); + const minInput = screen.getByTestId("leaderboard-price-min-input"); + const maxInput = screen.getByTestId("leaderboard-price-max-input"); + fireEvent.change(minInput, { target: { value: "1.5" } }); + fireEvent.change(maxInput, { target: { value: "3.25" } }); + + expect(onPriceRangeChange).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(349); + }); + expect(onPriceRangeChange).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: 1.5, + maxPrice: 3.25, + }); + + expect(screen.getByTestId("leaderboard-price-clear")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("leaderboard-price-clear")); + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: undefined, + maxPrice: undefined, + }); + } finally { + jest.useRealTimers(); + } +}); + +it("does not auto-apply zero draft values during debounce", () => { + jest.useFakeTimers(); + + try { + const onPriceRangeChange = jest.fn(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("leaderboard-price-toggle")); + const minInput = screen.getByTestId("leaderboard-price-min-input"); + + fireEvent.change(minInput, { target: { value: "0" } }); + act(() => { + jest.advanceTimersByTime(350); + }); + expect(onPriceRangeChange).not.toHaveBeenCalled(); + + fireEvent.change(minInput, { target: { value: "0." } }); + act(() => { + jest.advanceTimersByTime(350); + }); + expect(onPriceRangeChange).not.toHaveBeenCalled(); + + fireEvent.change(minInput, { target: { value: "0.125" } }); + act(() => { + jest.advanceTimersByTime(350); + }); + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: 0.125, + maxPrice: undefined, + }); + } finally { + jest.useRealTimers(); + } +}); + +it("commits zero value on blur", async () => { + const onPriceRangeChange = jest.fn(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("leaderboard-price-toggle")); + const minInput = screen.getByTestId("leaderboard-price-min-input"); + + fireEvent.focus(minInput); + fireEvent.change(minInput, { target: { value: "0" } }); + fireEvent.blur(minInput); + + await waitFor(() => + expect(onPriceRangeChange).toHaveBeenLastCalledWith({ + minPrice: 0, + maxPrice: undefined, + }) + ); +}); + +it("auto-expands price filters when min or max price is active", () => { + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + expect(screen.getByTestId("leaderboard-price-panel")).toBeInTheDocument(); + expect(screen.getByTestId("leaderboard-price-toggle")).toHaveAttribute( + "aria-expanded", + "true" + ); + expect(screen.getByTestId("leaderboard-price-clear")).toBeInTheDocument(); +}); + +it("allows collapsing and reopening filters while price filters are active", async () => { + const user = userEvent.setup(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + const toggle = screen.getByTestId("leaderboard-price-toggle"); + expect(screen.getByTestId("leaderboard-price-panel")).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await user.click(toggle); + await waitFor(() => + expect( + screen.queryByTestId("leaderboard-price-panel") + ).not.toBeInTheDocument() + ); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + await waitFor(() => + expect(screen.getByTestId("leaderboard-price-panel")).toBeInTheDocument() + ); + expect(toggle).toHaveAttribute("aria-expanded", "true"); +}); + +it("resets price input drafts when committed price props change", async () => { + const user = userEvent.setup(); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("leaderboard-price-panel")).toBeInTheDocument(); + const minInput = screen.getByTestId( + "leaderboard-price-min-input" + ) as HTMLInputElement; + const maxInput = screen.getByTestId( + "leaderboard-price-max-input" + ) as HTMLInputElement; + + await user.clear(minInput); + await user.type(minInput, "9"); + await user.clear(maxInput); + await user.type(maxInput, "10"); + expect(minInput.value).toBe("9"); + expect(maxInput.value).toBe("10"); + + rerender( + + + + ); + + expect( + (screen.getByTestId("leaderboard-price-min-input") as HTMLInputElement) + .value + ).toBe("3"); + expect( + (screen.getByTestId("leaderboard-price-max-input") as HTMLInputElement) + .value + ).toBe("4"); +}); + +it("does not render price controls for non-curation waves", () => { + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: false, + participation: { isEligible: true }, + }); + + render( + + + + ); + + expect( + screen.queryByTestId("leaderboard-price-min-input") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("leaderboard-price-max-input") + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("leaderboard-price-toggle") + ).not.toBeInTheDocument(); +}); + it("does not render curation selector when curation controls are unavailable", () => { render( { - resolveControlModesMock.mockReturnValue({ + resolveHeaderLayoutMock.mockReturnValue({ sortMode: "dropdown", curationMode: "dropdown", enableControlsScroll: true, + actionMode: "icon", + wrapActions: false, }); render( @@ -296,5 +899,264 @@ it("applies resolved modes and enables scroll fallback styling when requested", expect( screen.getByTestId("leaderboard-header-controls-row").className ).toContain("tw-overflow-x-auto"); - expect(resolveControlModesMock).toHaveBeenCalled(); + expect(resolveHeaderLayoutMock).toHaveBeenCalled(); +}); + +it("uses controls row width for non-curation layout measurements", async () => { + const clientWidthGetter = jest + .spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockImplementation(function (this: HTMLElement) { + if ( + this.getAttribute("data-testid") === "leaderboard-header-controls-row" + ) { + return 320; + } + + if (this.getAttribute("data-testid") === "leaderboard-header-row") { + return 480; + } + + return 0; + }); + + try { + render( + + + + ); + + await waitFor(() => + expect(resolveHeaderLayoutMock).toHaveBeenCalledWith( + expect.objectContaining({ + rowWidth: 320, + }) + ) + ); + } finally { + clientWidthGetter.mockRestore(); + } +}); + +it("uses full header row width when curation actions are inline", async () => { + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + const clientWidthGetter = jest + .spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockImplementation(function (this: HTMLElement) { + if ( + this.getAttribute("data-testid") === "leaderboard-header-controls-row" + ) { + return 320; + } + + if (this.getAttribute("data-testid") === "leaderboard-header-row") { + return 480; + } + + return 0; + }); + + try { + render( + + + + ); + + await waitFor(() => + expect(resolveHeaderLayoutMock).toHaveBeenCalledWith( + expect.objectContaining({ + rowWidth: 480, + }) + ) + ); + } finally { + clientWidthGetter.mockRestore(); + } +}); + +it("renders icon-only curation actions with drop glyph when layout requests compact mode", () => { + resolveHeaderLayoutMock.mockReturnValue({ + sortMode: "dropdown", + curationMode: "tabs", + enableControlsScroll: false, + actionMode: "icon", + wrapActions: false, + }); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + const actionsRow = screen.getByTestId("leaderboard-header-actions-row"); + expect(actionsRow).toHaveAttribute("data-action-mode", "icon"); + expect(actionsRow).toHaveAttribute("data-wrap", "no"); + + const createButton = screen.getByTestId("create"); + expect(createButton).toHaveAttribute("data-padding", "tw-px-2.5 tw-py-2"); + expect(createButton.querySelector('path[d^="M8.62826"]')).not.toBeNull(); +}); + +it("marks the actions row as wrapped when layout requests wrapping", () => { + resolveHeaderLayoutMock.mockReturnValue({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: false, + actionMode: "icon", + wrapActions: true, + }); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + expect(screen.getByTestId("leaderboard-header-actions-row")).toHaveAttribute( + "data-action-mode", + "icon" + ); + expect(screen.getByTestId("leaderboard-header-actions-row")).toHaveAttribute( + "data-wrap", + "yes" + ); + expect(screen.getByTestId("leaderboard-header-row").className).toContain( + "tw-flex-wrap" + ); + expect( + screen.getByTestId("leaderboard-header-controls-row").className + ).toContain("tw-basis-full"); +}); + +it("moves actions to the price row when filters are open on wrapped layouts", () => { + resolveHeaderLayoutMock.mockReturnValue({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: false, + actionMode: "icon", + wrapActions: true, + }); + + useWave.mockReturnValue({ + isMemesWave: false, + isCurationWave: true, + participation: { isEligible: true }, + }); + + render( + + + + ); + + expect(screen.getByTestId("leaderboard-price-panel")).toBeInTheDocument(); + expect( + screen.queryByTestId("leaderboard-header-actions-row") + ).not.toBeInTheDocument(); + const priceActionsRow = screen.getByTestId("leaderboard-price-actions-row"); + expect(priceActionsRow).toBeInTheDocument(); + expect(priceActionsRow.className).toContain("tw-ml-auto"); + expect(priceActionsRow).toContainElement( + screen.getByTestId("leaderboard-price-toggle") + ); + expect(screen.getByTestId("leaderboard-header-row").className).toContain( + "tw-flex-nowrap" + ); + expect( + screen.getByTestId("leaderboard-header-controls-row").className + ).not.toContain("tw-basis-full"); }); diff --git a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx index b2eec0c5c1..314f969159 100644 --- a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx +++ b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx @@ -93,4 +93,29 @@ describe("WaveleaderboardSort", () => { "Newest", ]); }); + + it("uses provided custom sort items", () => { + const customItems = [ + { + key: WaveDropsLeaderboardSort.PRICE, + label: "Price", + value: WaveDropsLeaderboardSort.PRICE, + }, + ] as const; + + render( + + ); + + expect(commonDropdownMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeItem: WaveDropsLeaderboardSort.PRICE, + items: customItems, + }) + ); + }); }); diff --git a/__tests__/components/waves/leaderboard/header/waveLeaderboardHeaderLayout.test.ts b/__tests__/components/waves/leaderboard/header/waveLeaderboardHeaderLayout.test.ts new file mode 100644 index 0000000000..40a0cbd488 --- /dev/null +++ b/__tests__/components/waves/leaderboard/header/waveLeaderboardHeaderLayout.test.ts @@ -0,0 +1,113 @@ +import { resolveWaveLeaderboardHeaderLayout } from "@/components/waves/leaderboard/header/waveLeaderboardHeaderLayout"; + +describe("resolveWaveLeaderboardHeaderLayout", () => { + const baseInput = { + viewModesWidth: 120, + sortTabsWidth: 260, + sortDropdownWidth: 140, + hasCurationControl: true, + curationTabsWidth: 280, + curationDropdownWidth: 170, + hasActions: true, + actionsFullWidth: 260, + actionsIconWidth: 90, + } as const; + + it("keeps full action buttons on one row when everything fits", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + ...baseInput, + rowWidth: 760, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: false, + actionMode: "full", + wrapActions: false, + }); + }); + + it("uses icon-only actions before controls need scroll fallback", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + ...baseInput, + rowWidth: 590, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: false, + actionMode: "icon", + wrapActions: false, + }); + }); + + it("wraps actions early when space gets tight", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + ...baseInput, + rowWidth: 450, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: false, + actionMode: "icon", + wrapActions: true, + }); + }); + + it("keeps actions inline when wrapping is disabled and falls back to scroll", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + ...baseInput, + rowWidth: 450, + allowActionWrap: false, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: true, + actionMode: "icon", + wrapActions: false, + }); + }); + + it("keeps control scroll fallback when controls still cannot fit", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + ...baseInput, + rowWidth: 300, + allowActionWrap: false, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "dropdown", + enableControlsScroll: true, + actionMode: "icon", + wrapActions: false, + }); + }); + + it("uses full controls width when actions are disabled", () => { + const result = resolveWaveLeaderboardHeaderLayout({ + rowWidth: 570, + viewModesWidth: 120, + sortTabsWidth: 260, + sortDropdownWidth: 140, + hasCurationControl: true, + curationTabsWidth: 280, + curationDropdownWidth: 170, + hasActions: false, + }); + + expect(result).toEqual({ + sortMode: "dropdown", + curationMode: "tabs", + enableControlsScroll: false, + actionMode: "full", + wrapActions: false, + }); + }); +}); diff --git a/__tests__/hooks/useWaveDropsLeaderboard.extra.test.ts b/__tests__/hooks/useWaveDropsLeaderboard.extra.test.ts index 06ac8cd462..00fc9d85b1 100644 --- a/__tests__/hooks/useWaveDropsLeaderboard.extra.test.ts +++ b/__tests__/hooks/useWaveDropsLeaderboard.extra.test.ts @@ -82,17 +82,24 @@ describe("useWaveDropsLeaderboard extra", () => { expect(call.queryKey[1].sort).toBe(WaveDropsLeaderboardSort.CREATED_AT); }); - it("includes curated_by_group in query key and request params", async () => { + it("includes curation and price params in query key and request params", async () => { renderHook(() => useWaveDropsLeaderboard({ waveId: "2", curatedByGroupId: "curation-group-1", + minPrice: 0.5, + maxPrice: 2.75, + priceCurrency: "ETH", + sort: WaveDropsLeaderboardSort.PRICE, }) ); const call = (queryClientMock.prefetchInfiniteQuery as jest.Mock).mock .calls[0][0]; expect(call.queryKey[1].curated_by_group).toBe("curation-group-1"); + expect(call.queryKey[1].min_price).toBe("0.5"); + expect(call.queryKey[1].max_price).toBe("2.75"); + expect(call.queryKey[1].price_currency).toBe("ETH"); await call.queryFn({ pageParam: null }); @@ -100,8 +107,69 @@ describe("useWaveDropsLeaderboard extra", () => { expect.objectContaining({ endpoint: "waves/2/leaderboard", params: expect.objectContaining({ - sort: WaveDropsLeaderboardSort.RANK, + sort: WaveDropsLeaderboardSort.PRICE, curated_by_group: "curation-group-1", + min_price: "0.5", + max_price: "2.75", + price_currency: "ETH", + }), + }) + ); + }); + + it("swaps inverted min and max price bounds for query key and request params", async () => { + renderHook(() => + useWaveDropsLeaderboard({ + waveId: "2", + minPrice: 2.75, + maxPrice: 0.5, + priceCurrency: "ETH", + sort: WaveDropsLeaderboardSort.PRICE, + }) + ); + + const call = (queryClientMock.prefetchInfiniteQuery as jest.Mock).mock + .calls[0][0]; + expect(call.queryKey[1].min_price).toBe("0.5"); + expect(call.queryKey[1].max_price).toBe("2.75"); + + await call.queryFn({ pageParam: null }); + + expect(commonApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "waves/2/leaderboard", + params: expect.objectContaining({ + sort: WaveDropsLeaderboardSort.PRICE, + min_price: "0.5", + max_price: "2.75", + price_currency: "ETH", + }), + }) + ); + }); + + it("normalizes whitespace-only currency to null in key and omits request param", async () => { + renderHook(() => + useWaveDropsLeaderboard({ + waveId: "2", + minPrice: 0.5, + maxPrice: 2.75, + priceCurrency: " ", + sort: WaveDropsLeaderboardSort.PRICE, + }) + ); + + const call = (queryClientMock.prefetchInfiniteQuery as jest.Mock).mock + .calls[0][0]; + expect(call.queryKey[1].price_currency).toBeNull(); + + await call.queryFn({ pageParam: null }); + + expect(commonApiFetch).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "waves/2/leaderboard", + params: expect.not.objectContaining({ + price_currency: expect.any(String), }), }) ); diff --git a/components/brain/BrainMobile.tsx b/components/brain/BrainMobile.tsx index d0e8207877..4d118c9145 100644 --- a/components/brain/BrainMobile.tsx +++ b/components/brain/BrainMobile.tsx @@ -279,7 +279,11 @@ const BrainMobile: React.FC = ({ children }) => { [BrainView.DEFAULT]: children, [BrainView.LEADERBOARD]: isRankWave && !!wave ? ( - + ) : null, [BrainView.WINNERS]: isRankWave && !!wave ? ( diff --git a/components/brain/my-stream/MyStreamWave.tsx b/components/brain/my-stream/MyStreamWave.tsx index fabb909ed2..4718d1e54a 100644 --- a/components/brain/my-stream/MyStreamWave.tsx +++ b/components/brain/my-stream/MyStreamWave.tsx @@ -117,7 +117,11 @@ const MyStreamWave: React.FC = ({ waveId }) => { /> ), [MyStreamWaveTab.LEADERBOARD]: ( - + ), [MyStreamWaveTab.WINNERS]: ( diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index e8b5b90c18..28dfb3bbba 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -15,6 +15,7 @@ import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { WaveLeaderboardTime } from "@/components/waves/leaderboard/WaveLeaderboardTime"; import { WaveLeaderboardHeader } from "@/components/waves/leaderboard/header/WaveleaderboardHeader"; import { WaveDropCreate } from "@/components/waves/leaderboard/create/WaveDropCreate"; +import { WaveLeaderboardCurationDropModal } from "@/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal"; import { WaveLeaderboardDrops } from "@/components/waves/leaderboard/drops/WaveLeaderboardDrops"; import { WaveLeaderboardGallery } from "@/components/waves/leaderboard/gallery/WaveLeaderboardGallery"; import { WaveLeaderboardGrid } from "@/components/waves/leaderboard/grid/WaveLeaderboardGrid"; @@ -62,6 +63,9 @@ const MyStreamWaveLeaderboard: React.FC = ({ const [isCreateDropOpen, setIsCreateDropOpen] = useState(false); const [isMemesCreateOpen, setIsMemesCreateOpen] = useState(false); + const [isCurationDropModalOpen, setIsCurationDropModalOpen] = useState(false); + const [minPrice, setMinPrice] = useState(undefined); + const [maxPrice, setMaxPrice] = useState(undefined); const isLoggedIn = Boolean(connectedProfile?.handle); const { canCreateDrop } = useMemo( @@ -74,8 +78,6 @@ const MyStreamWaveLeaderboard: React.FC = ({ }), [activeProfileProxy, isCurationWave, isLoggedIn, participation] ); - const showPersistentDropInput = - !isMemesWave && isCurationWave && canCreateDrop; const showToggleableDropInput = !isMemesWave && !isCurationWave && isCreateDropOpen; @@ -89,10 +91,18 @@ const MyStreamWaveLeaderboard: React.FC = ({ return; } + if (isCurationWave) { + if (!canCreateDrop) { + return; + } + setIsCurationDropModalOpen(true); + return; + } + if (!isCurationWave) { setIsCreateDropOpen(true); } - }, [isCurationWave, isMemesWave]); + }, [canCreateDrop, isCurationWave, isMemesWave]); // Generate a unique preference key for this wave const viewPreferenceKey = `waveViewMode_${wave.id}`; @@ -117,7 +127,8 @@ const MyStreamWaveLeaderboard: React.FC = ({ value === WaveDropsLeaderboardSort.RATING_PREDICTION || value === WaveDropsLeaderboardSort.TREND || value === WaveDropsLeaderboardSort.MY_REALTIME_VOTE || - value === WaveDropsLeaderboardSort.CREATED_AT + value === WaveDropsLeaderboardSort.CREATED_AT || + (isCurationWave && value === WaveDropsLeaderboardSort.PRICE) ); const { @@ -158,6 +169,17 @@ const MyStreamWaveLeaderboard: React.FC = ({ isLoadingCurationGroups, curationGroupIdSet, ]); + const priceCurrency = useMemo(() => { + const hasPriceFilter = + typeof minPrice === "number" || typeof maxPrice === "number"; + if ( + isCurationWave && + (hasPriceFilter || sort === WaveDropsLeaderboardSort.PRICE) + ) { + return "ETH"; + } + return undefined; + }, [isCurationWave, maxPrice, minPrice, sort]); const updateCurationGroupInUrl = useCallback( (groupId: string | null) => { @@ -175,6 +197,19 @@ const MyStreamWaveLeaderboard: React.FC = ({ }, [pathname, router, searchParams] ); + const updatePriceRange = useCallback( + ({ + minPrice: nextMinPrice, + maxPrice: nextMaxPrice, + }: { + readonly minPrice: number | undefined; + readonly maxPrice: number | undefined; + }) => { + setMinPrice(nextMinPrice); + setMaxPrice(nextMaxPrice); + }, + [] + ); const effectiveViewMode = useMemo(() => { if (!isMemesWave) { @@ -194,7 +229,10 @@ const MyStreamWaveLeaderboard: React.FC = ({ wave={wave} sort={sort} curatedByGroupId={curatedByGroupId} - onCreateDrop={isMemesWave || !isCurationWave ? onCreateDrop : undefined} + minPrice={minPrice} + maxPrice={maxPrice} + priceCurrency={priceCurrency} + onCreateDrop={onCreateDrop} /> ); } else if (!isMemesWave) { @@ -203,6 +241,9 @@ const MyStreamWaveLeaderboard: React.FC = ({ wave={wave} sort={sort} curatedByGroupId={curatedByGroupId} + minPrice={minPrice} + maxPrice={maxPrice} + priceCurrency={priceCurrency} mode={effectiveViewMode === "grid" ? "compact" : "content_only"} onDropClick={onDropClick} /> @@ -213,6 +254,9 @@ const MyStreamWaveLeaderboard: React.FC = ({ wave={wave} sort={sort} curatedByGroupId={curatedByGroupId} + minPrice={minPrice} + maxPrice={maxPrice} + priceCurrency={priceCurrency} onDropClick={onDropClick} /> ); @@ -229,15 +273,16 @@ const MyStreamWaveLeaderboard: React.FC = ({ viewMode={effectiveViewMode} sort={sort} onViewModeChange={(mode) => setViewMode(mode)} - onCreateDrop={ - isMemesWave || !isCurationWave ? onCreateDrop : undefined - } + onCreateDrop={onCreateDrop} onSortChange={(s) => setSort(s)} curationGroups={curationGroups} curatedByGroupId={curatedByGroupId ?? null} onCurationGroupChange={ curationGroups.length > 0 ? updateCurationGroupInUrl : undefined } + minPrice={minPrice} + maxPrice={maxPrice} + onPriceRangeChange={isCurationWave ? updatePriceRange : undefined} />
@@ -268,16 +313,6 @@ const MyStreamWaveLeaderboard: React.FC = ({ )} - {showPersistentDropInput && ( -
- {}} - isCurationLeaderboard - /> -
- )} - {isMemesWave && isMemesCreateOpen && ( = ({ onClose={() => setIsMemesCreateOpen(false)} /> )} + {isCurationWave && isCurationDropModalOpen && ( + setIsCurationDropModalOpen(false)} + /> + )} {leaderboardContent}
diff --git a/components/waves/CreateCurationDropContent.tsx b/components/waves/CreateCurationDropContent.tsx index c8bba69cc2..a82660aece 100644 --- a/components/waves/CreateCurationDropContent.tsx +++ b/components/waves/CreateCurationDropContent.tsx @@ -11,7 +11,6 @@ import type { ActiveDropState } from "@/types/dropInteractionTypes"; import { ActiveDropAction } from "@/types/dropInteractionTypes"; import dynamic from "next/dynamic"; import React, { - type ReactNode, useCallback, useContext, useEffect, @@ -19,7 +18,6 @@ import React, { useRef, useState, } from "react"; -import { createPortal } from "react-dom"; import { useSelector } from "react-redux"; import { AuthContext } from "../auth/Auth"; import { useSeizeConnectContext } from "../auth/SeizeConnectContext"; @@ -30,8 +28,8 @@ import type { DropMutationBody } from "./CreateDrop"; import CreateDropReplyingWrapper from "./CreateDropReplyingWrapper"; import { CreateDropSubmit } from "./CreateDropSubmit"; import CreateCurationDropUrlInput from "./CreateCurationDropUrlInput"; +import PrimaryButton from "../utils/button/PrimaryButton"; import type { CurationComposerVariant } from "./PrivilegedDropCreator"; -import ModalLayout from "./memes/submission/layout/ModalLayout"; import { normalizeCurationDropInput, SUPPORTED_CURATION_URL_EXAMPLES, @@ -59,90 +57,6 @@ interface CreateCurationDropContentProps { const DEFAULT_HELPER_TEXT = "Use one supported HTTPS URL only, without extra text."; -interface CurationInfoModalProps { - readonly isOpen: boolean; - readonly isApp: boolean; - readonly title: string; - readonly onClose: () => void; - readonly children: ReactNode; -} - -const CurationInfoModal: React.FC = ({ - isOpen, - isApp, - title, - onClose, - children, -}) => { - const modalRef = useRef(null); - const previousActiveElementRef = useRef(null); - - useEffect(() => { - if (!isOpen) { - return; - } - - previousActiveElementRef.current = document.activeElement as HTMLElement; - modalRef.current?.focus(); - - const originalOverflow = document.body.style.overflow; - if (!isApp) { - document.body.style.overflow = "hidden"; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("keydown", onKeyDown); - - return () => { - if (!isApp) { - document.body.style.overflow = originalOverflow; - } - document.removeEventListener("keydown", onKeyDown); - previousActiveElementRef.current?.focus(); - }; - }, [isApp, isOpen, onClose]); - - if (!isOpen) { - return null; - } - - const modalContent = ( - - - ); - - if (isApp) { - return modalContent; - } - - return createPortal(modalContent, document.body); -}; - const CreateCurationDropContent: React.FC = ({ activeDrop, onCancelReplyQuote, @@ -164,14 +78,10 @@ const CreateCurationDropContent: React.FC = ({ const [urlValue, setUrlValue] = useState(() => initialUrl ?? ""); const [submitting, setSubmitting] = useState(false); const [showLiveValidation, setShowLiveValidation] = useState(false); - const [isSupportedUrlsModalOpen, setIsSupportedUrlsModalOpen] = - useState(false); + const [isSupportedUrlsExpanded, setIsSupportedUrlsExpanded] = useState(false); const inputRef = useRef(null); const isInitialMountRef = useRef(true); const isLeaderboardVariant = curationComposerVariant === "leaderboard"; - const handleSupportedUrlsModalClose = useCallback(() => { - setIsSupportedUrlsModalOpen(false); - }, []); const curationValidation = useMemo(() => { return validateCurationDropInput(urlValue); @@ -186,8 +96,6 @@ const CreateCurationDropContent: React.FC = ({ const invalidHelperText = curationValidation?.helperText ?? DEFAULT_HELPER_TEXT; const helperText = isInvalid ? invalidHelperText : DEFAULT_HELPER_TEXT; - const showSupportedUrlAttention = - isLeaderboardVariant && isInvalid && urlValue.trim().length > 0; const getUpdatedDropRequest = useCallback( async ( @@ -430,56 +338,63 @@ const CreateCurationDropContent: React.FC = ({ dropId={dropId} /> {isLeaderboardVariant ? ( -
-
- setShowLiveValidation(true)} - onSubmit={onDrop} - /> -
- + {isSupportedUrlsExpanded && ( +
- Supported URLs - - {showSupportedUrlAttention && ( -

- Unsupported URL format. Open Supported URLs. +

+ Submit one URL only. It must match one of these formats:

- )} - {normalizedCurationUrl && - normalizedCurationUrl !== urlValue.trim() && ( -

- Will submit as: {normalizedCurationUrl} -

- )} -
-
-
- +
    + {SUPPORTED_CURATION_URL_EXAMPLES.map(({ label, example }) => ( +
  • +

    + {label} +

    + + {example} + +
  • + ))} +
+
+ )}
+ + Submit to Curation +
) : (
@@ -510,30 +425,6 @@ const CreateCurationDropContent: React.FC = ({
)} - {isLeaderboardVariant && ( - -

- Submit one URL only. It must match one of these formats: -

-
    - {SUPPORTED_CURATION_URL_EXAMPLES.map(({ label, example }) => ( -
  • -

    - {label} -

    - - {example} - -
  • - ))} -
-
- )}
); diff --git a/components/waves/CreateCurationDropUrlInput.tsx b/components/waves/CreateCurationDropUrlInput.tsx index 5a70777268..508ffd374e 100644 --- a/components/waves/CreateCurationDropUrlInput.tsx +++ b/components/waves/CreateCurationDropUrlInput.tsx @@ -10,6 +10,7 @@ interface CreateCurationDropUrlInputProps { readonly showHelperText?: boolean | undefined; readonly scrollMarginTopClassName?: string | undefined; readonly canonicalUrl: string | null; + readonly placeholder?: string | undefined; readonly onChange: (value: string) => void; readonly onBlur: () => void; readonly onSubmit: () => void; @@ -28,6 +29,7 @@ const CreateCurationDropUrlInput = forwardRef< showHelperText = true, scrollMarginTopClassName, canonicalUrl, + placeholder = "Enter supported curation URL", onChange, onBlur, onSubmit, @@ -60,7 +62,7 @@ const CreateCurationDropUrlInput = forwardRef< event.preventDefault(); onSubmit(); }} - placeholder="Enter supported curation URL" + placeholder={placeholder} className={`tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-py-2.5 tw-pl-3 tw-pr-3 tw-text-base tw-font-normal tw-leading-6 tw-text-white tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-500 focus:tw-bg-iron-950 focus:tw-outline-none sm:tw-text-sm ${inputRingClasses} ${ disabled ? "tw-cursor-default tw-opacity-50" : "" } ${scrollMarginTopClassName ?? ""}`} diff --git a/components/waves/CreateDrop.tsx b/components/waves/CreateDrop.tsx index e603a775ca..a82b4b42de 100644 --- a/components/waves/CreateDrop.tsx +++ b/components/waves/CreateDrop.tsx @@ -13,7 +13,6 @@ import { commonApiPost } from "@/services/api/common-api"; import type { ApiCreateDropRequest } from "@/generated/models/ApiCreateDropRequest"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { AuthContext } from "../auth/Auth"; -import { useProgressiveDebounce } from "@/hooks/useProgressiveDebounce"; import { useKeyPressEvent } from "react-use"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import type { CurationComposerVariant } from "./PrivilegedDropCreator"; @@ -215,27 +214,7 @@ export default function CreateDrop({ // Use refs to avoid stale closures - fixes the stream unmounting issue const queueRef = useRef([]); const isProcessingRef = useRef(false); - const [hasQueueChanged, setHasQueueChanged] = useState(false); - - useProgressiveDebounce( - () => { - if ( - queueRef.current.length === 0 && - !isProcessingRef.current && - hasQueueChanged - ) { - waitAndInvalidateDrops(); - onAllDropsAdded?.(); - } - }, - [hasQueueChanged], - { - minDelay: 1000, - maxDelay: 4000, - increaseFactor: 1.5, - decreaseFactor: 1.2, - } - ); + const hasBatchErrorsRef = useRef(false); const processNextDrop = useCallback(async () => { if (isProcessingRef.current || queueRef.current.length === 0) { @@ -249,6 +228,7 @@ export default function CreateDrop({ try { await addDropMutation.mutateAsync(dropRequest); } catch (error) { + hasBatchErrorsRef.current = true; console.error("Error processing drop:", error); } } @@ -258,14 +238,21 @@ export default function CreateDrop({ // Process next item if queue has more if (queueRef.current.length > 0) { processNextDrop(); + return; + } + + const shouldNotifyAllDropsAdded = !hasBatchErrorsRef.current; + hasBatchErrorsRef.current = false; + void waitAndInvalidateDrops(); + if (shouldNotifyAllDropsAdded) { + onAllDropsAdded?.(); } - }, [addDropMutation]); + }, [addDropMutation, onAllDropsAdded, waitAndInvalidateDrops]); const submitDrop = useCallback( (dropRequest: DropMutationBody) => { // Add to queue queueRef.current.push(dropRequest); - setHasQueueChanged(true); // Process immediately - avoids state update timing issues processNextDrop(); diff --git a/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx b/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx new file mode 100644 index 0000000000..18722f275e --- /dev/null +++ b/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx @@ -0,0 +1,221 @@ +"use client"; + +import type { ApiWave } from "@/generated/models/ApiWave"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { motion } from "framer-motion"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { WaveDropCreate } from "./WaveDropCreate"; + +interface WaveLeaderboardCurationDropModalProps { + readonly isOpen: boolean; + readonly wave: ApiWave; + readonly onClose: () => void; +} + +const FOCUSABLE_SELECTOR = + "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1']),[contenteditable='true']"; + +const getFocusableElements = (container: HTMLElement): HTMLElement[] => + Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR) + ).filter((element) => { + if (!element.isConnected) { + return false; + } + + if (element.getAttribute("aria-hidden") === "true") { + return false; + } + + if (element instanceof HTMLInputElement && element.type === "hidden") { + return false; + } + + return !element.hasAttribute("disabled"); + }); + +export function WaveLeaderboardCurationDropModal({ + isOpen, + wave, + onClose, +}: WaveLeaderboardCurationDropModalProps) { + const canUseDOM = typeof document !== "undefined"; + const panelRef = useRef(null); + const closeButtonRef = useRef(null); + const titleRef = useRef(null); + const onCloseRef = useRef(onClose); + const previouslyFocusedElementRef = useRef(null); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const activeElement = document.activeElement; + previouslyFocusedElementRef.current = + activeElement instanceof HTMLElement ? activeElement : null; + + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const focusInitialTarget = () => { + const initialTarget = + closeButtonRef.current ?? titleRef.current ?? panelRef.current; + initialTarget?.focus(); + }; + focusInitialTarget(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + const focusableElements = getFocusableElements(panel); + if (focusableElements.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const firstFocusableElement = focusableElements[0]; + const lastFocusableElement = + focusableElements[focusableElements.length - 1]; + if (!firstFocusableElement || !lastFocusableElement) { + event.preventDefault(); + panel.focus(); + return; + } + const activeFocusableElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + if (activeFocusableElement === panel) { + event.preventDefault(); + if (event.shiftKey) { + lastFocusableElement.focus(); + } else { + firstFocusableElement.focus(); + } + return; + } + + if (!activeFocusableElement || !panel.contains(activeFocusableElement)) { + event.preventDefault(); + if (event.shiftKey) { + lastFocusableElement.focus(); + } else { + firstFocusableElement.focus(); + } + return; + } + + if (event.shiftKey && activeFocusableElement === firstFocusableElement) { + event.preventDefault(); + lastFocusableElement.focus(); + return; + } + + if (!event.shiftKey && activeFocusableElement === lastFocusableElement) { + event.preventDefault(); + firstFocusableElement.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + + return () => { + document.body.style.overflow = originalOverflow; + document.removeEventListener("keydown", onKeyDown); + + const previouslyFocusedElement = previouslyFocusedElementRef.current; + if (previouslyFocusedElement?.isConnected) { + previouslyFocusedElement.focus(); + } + previouslyFocusedElementRef.current = null; + }; + }, [isOpen]); + + if (!canUseDOM || !isOpen) { + return null; + } + + return createPortal( + + +
+ +
+
+

+ Enter a supported curation URL to submit a new piece to the + leaderboard. +

+ +
+
+ + + , + document.body + ); +} diff --git a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx index 300a19bcec..5188d437c2 100644 --- a/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx +++ b/components/waves/leaderboard/drops/WaveLeaderboardDrops.tsx @@ -17,6 +17,9 @@ interface WaveLeaderboardDropsProps { readonly sort: WaveDropsLeaderboardSort; readonly onCreateDrop?: (() => void) | undefined; readonly curatedByGroupId?: string | undefined; + readonly minPrice?: number | undefined; + readonly maxPrice?: number | undefined; + readonly priceCurrency?: string | undefined; } export const WaveLeaderboardDrops: React.FC = ({ @@ -24,6 +27,9 @@ export const WaveLeaderboardDrops: React.FC = ({ sort, onCreateDrop, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }) => { const router = useRouter(); const pathname = usePathname(); @@ -33,6 +39,9 @@ export const WaveLeaderboardDrops: React.FC = ({ waveId: wave.id, sort, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }); const intersectionElementRef = useIntersectionObserver(async () => { diff --git a/components/waves/leaderboard/gallery/WaveLeaderboardGallery.tsx b/components/waves/leaderboard/gallery/WaveLeaderboardGallery.tsx index d5e26934a7..a88ab0851b 100644 --- a/components/waves/leaderboard/gallery/WaveLeaderboardGallery.tsx +++ b/components/waves/leaderboard/gallery/WaveLeaderboardGallery.tsx @@ -12,6 +12,9 @@ interface WaveLeaderboardGalleryProps { readonly sort: WaveDropsLeaderboardSort; readonly onDropClick: (drop: ExtendedDrop) => void; readonly curatedByGroupId?: string | undefined; + readonly minPrice?: number | undefined; + readonly maxPrice?: number | undefined; + readonly priceCurrency?: string | undefined; } export const WaveLeaderboardGallery: React.FC = ({ @@ -19,12 +22,18 @@ export const WaveLeaderboardGallery: React.FC = ({ sort, onDropClick, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }) => { const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useWaveDropsLeaderboard({ waveId: wave.id, sort, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }); // Track when sort changes to signal animation diff --git a/components/waves/leaderboard/grid/WaveLeaderboardGrid.tsx b/components/waves/leaderboard/grid/WaveLeaderboardGrid.tsx index 02db8898b1..0e1c35a9c5 100644 --- a/components/waves/leaderboard/grid/WaveLeaderboardGrid.tsx +++ b/components/waves/leaderboard/grid/WaveLeaderboardGrid.tsx @@ -15,6 +15,9 @@ interface WaveLeaderboardGridProps { readonly mode: WaveLeaderboardGridMode; readonly onDropClick: (drop: ExtendedDrop) => void; readonly curatedByGroupId?: string | undefined; + readonly minPrice?: number | undefined; + readonly maxPrice?: number | undefined; + readonly priceCurrency?: string | undefined; } export const WaveLeaderboardGrid: React.FC = ({ @@ -23,12 +26,18 @@ export const WaveLeaderboardGrid: React.FC = ({ mode, onDropClick, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }) => { const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = useWaveDropsLeaderboard({ waveId: wave.id, sort, curatedByGroupId, + minPrice, + maxPrice, + priceCurrency, }); if (isFetching && drops.length === 0) { @@ -42,8 +51,8 @@ export const WaveLeaderboardGrid: React.FC = ({ >
-
-
+
+
))} diff --git a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx index a50bc0f81f..c967581f84 100644 --- a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx @@ -6,18 +6,32 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveCurationGroup } from "@/generated/models/ApiWaveCurationGroup"; import { useWave } from "@/hooks/useWave"; import type { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AdjustmentsHorizontalIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/solid"; -import React, { useContext, useMemo } from "react"; +import React, { + useCallback, + useContext, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { Tooltip } from "react-tooltip"; +import { useDebounce } from "react-use"; import { getWaveDropEligibility } from "../dropEligibility"; import type { LeaderboardViewMode } from "../types"; import { WaveLeaderboardCurationGroupSelect } from "./WaveLeaderboardCurationGroupSelect"; import { useLeaderboardHeaderControlMeasurements } from "./useLeaderboardHeaderControlMeasurements"; import { + WAVE_LEADERBOARD_CURATION_SORT_ITEMS, WAVE_LEADERBOARD_SORT_ITEMS, WaveleaderboardSort, } from "./WaveleaderboardSort"; -import { resolveWaveLeaderboardHeaderControlModes } from "./waveLeaderboardHeaderControls"; +import { resolveWaveLeaderboardHeaderLayout } from "./waveLeaderboardHeaderLayout"; interface WaveLeaderboardHeaderProps { readonly wave: ApiWave; @@ -31,8 +45,264 @@ interface WaveLeaderboardHeaderProps { readonly onCurationGroupChange?: | ((groupId: string | null) => void) | undefined; + readonly minPrice?: number | undefined; + readonly maxPrice?: number | undefined; + readonly onPriceRangeChange?: + | ((values: { + readonly minPrice: number | undefined; + readonly maxPrice: number | undefined; + }) => void) + | undefined; +} + +interface WaveLeaderboardPriceFiltersProps { + readonly waveId: string; + readonly minPrice?: number | undefined; + readonly maxPrice?: number | undefined; + readonly onPriceRangeChange?: + | ((values: { + readonly minPrice: number | undefined; + readonly maxPrice: number | undefined; + }) => void) + | undefined; + readonly onFiltersActivated: () => void; + readonly onFiltersCleared: () => void; + readonly trailingActions?: React.ReactNode | undefined; } +const toPriceInputValue = (value?: number): string => + typeof value === "number" ? value.toString() : ""; + +const parsePriceInput = (rawValue: string): number | undefined => { + if (!rawValue.trim()) { + return undefined; + } + const numericValue = Number.parseFloat(rawValue); + if (!Number.isFinite(numericValue) || numericValue < 0) { + return undefined; + } + return numericValue; +}; + +const isZeroDraftInput = (rawValue: string): boolean => { + const trimmedValue = rawValue.trim(); + return trimmedValue === "0" || trimmedValue === "0."; +}; + +const PRICE_FILTER_LONG_PLACEHOLDER_WIDTH = 576; + +const WaveLeaderboardPriceFilters: React.FC< + WaveLeaderboardPriceFiltersProps +> = ({ + waveId, + minPrice, + maxPrice, + onPriceRangeChange, + onFiltersActivated, + onFiltersCleared, + trailingActions, +}) => { + const [minPriceInput, setMinPriceInput] = useState(() => + toPriceInputValue(minPrice) + ); + const [maxPriceInput, setMaxPriceInput] = useState(() => + toPriceInputValue(maxPrice) + ); + const hasDraftInputEditsRef = useRef(false); + const priceFiltersContainerRef = useRef(null); + const subscribePlaceholderMode = useCallback((onStoreChange: () => void) => { + const container = priceFiltersContainerRef.current; + const notify = () => onStoreChange(); + + notify(); + + if (typeof ResizeObserver !== "undefined" && container) { + const resizeObserver = new ResizeObserver(() => { + notify(); + }); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + } + + if (typeof window === "undefined") { + return () => undefined; + } + + window.addEventListener("resize", notify); + return () => window.removeEventListener("resize", notify); + }, []); + const getPlaceholderSnapshot = useCallback(() => { + const container = priceFiltersContainerRef.current; + if (!container) { + return false; + } + return ( + container.getBoundingClientRect().width >= + PRICE_FILTER_LONG_PLACEHOLDER_WIDTH + ); + }, []); + const useLongPlaceholders = useSyncExternalStore( + subscribePlaceholderMode, + getPlaceholderSnapshot, + () => false + ); + + const commitPriceRange = () => { + if (!onPriceRangeChange) { + return; + } + + const nextMinPrice = parsePriceInput(minPriceInput); + const nextMaxPrice = parsePriceInput(maxPriceInput); + const hasSameCommittedRange = + nextMinPrice === minPrice && nextMaxPrice === maxPrice; + + if (hasSameCommittedRange) { + return; + } + + const hasActivePriceFilters = + typeof nextMinPrice === "number" || typeof nextMaxPrice === "number"; + + if (hasActivePriceFilters) { + onFiltersActivated(); + } + + onPriceRangeChange({ + minPrice: nextMinPrice, + maxPrice: nextMaxPrice, + }); + }; + + useDebounce( + () => { + if (!hasDraftInputEditsRef.current) { + return; + } + if (isZeroDraftInput(minPriceInput) || isZeroDraftInput(maxPriceInput)) { + return; + } + commitPriceRange(); + }, + 350, + [ + minPriceInput, + maxPriceInput, + minPrice, + maxPrice, + onPriceRangeChange, + onFiltersActivated, + ] + ); + + const hasAnyPriceInput = + minPriceInput.trim().length > 0 || maxPriceInput.trim().length > 0; + + return ( +
+
+ { + hasDraftInputEditsRef.current = true; + setMinPriceInput(event.target.value); + }} + onBlur={commitPriceRange} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitPriceRange(); + } + }} + className="tw-form-input tw-block tw-h-9 tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-text-sm tw-font-normal tw-text-iron-50 tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 tw-transition tw-duration-300 tw-ease-out [appearance:textfield] placeholder:tw-text-iron-400 hover:tw-ring-iron-600 focus:tw-bg-transparent focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 [&::-webkit-inner-spin-button]:tw-appearance-none [&::-webkit-outer-spin-button]:tw-appearance-none" + /> +
+
+ { + hasDraftInputEditsRef.current = true; + setMaxPriceInput(event.target.value); + }} + onBlur={commitPriceRange} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitPriceRange(); + } + }} + className="tw-form-input tw-block tw-h-9 tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-text-sm tw-font-normal tw-text-iron-50 tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 tw-transition tw-duration-300 tw-ease-out [appearance:textfield] placeholder:tw-text-iron-400 hover:tw-ring-iron-600 focus:tw-bg-transparent focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 [&::-webkit-inner-spin-button]:tw-appearance-none [&::-webkit-outer-spin-button]:tw-appearance-none" + /> +
+ {hasAnyPriceInput && ( + + )} + {trailingActions} +
+ ); +}; + +const DropModeGlyphIcon: React.FC<{ + readonly className?: string | undefined; +}> = ({ className = "tw-size-4 tw-flex-shrink-0" }) => ( + +); + export const WaveLeaderboardHeader: React.FC = ({ wave, onCreateDrop, @@ -43,6 +313,9 @@ export const WaveLeaderboardHeader: React.FC = ({ curationGroups = [], curatedByGroupId = null, onCurationGroupChange, + minPrice, + maxPrice, + onPriceRangeChange, }) => { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); const { isMemesWave, isCurationWave, participation } = useWave(wave); @@ -56,16 +329,29 @@ export const WaveLeaderboardHeader: React.FC = ({ const showCurationGroupSelect = Boolean( onCurationGroupChange && curationGroups.length > 0 ); + const showPriceControls = Boolean(isCurationWave && onPriceRangeChange); + const sortItems = useMemo( + () => + isCurationWave + ? WAVE_LEADERBOARD_CURATION_SORT_ITEMS + : WAVE_LEADERBOARD_SORT_ITEMS, + [isCurationWave] + ); const viewModes: LeaderboardViewMode[] = isMemesWave ? ["list", "grid"] : ["list", "grid", "grid_content_only"]; + const hasActivePriceFilters = + typeof minPrice === "number" || typeof maxPrice === "number"; + const [isManualFiltersOpen, setIsManualFiltersOpen] = useState(false); + const [isActiveFiltersCollapsed, setIsActiveFiltersCollapsed] = + useState(false); + const isPriceFiltersOpen = hasActivePriceFilters + ? !isActiveFiltersCollapsed + : isManualFiltersOpen; const sortLabelByValue = useMemo( - () => - new Map( - WAVE_LEADERBOARD_SORT_ITEMS.map((item) => [item.value, item.label]) - ), - [] + () => new Map(sortItems.map((item) => [item.value, item.label])), + [sortItems] ); const activeSortLabel = sortLabelByValue.get(sort) ?? "Current Vote"; @@ -84,32 +370,51 @@ export const WaveLeaderboardHeader: React.FC = ({ () => curationGroups.map((group) => `${group.id}:${group.name}`).join("|"), [curationGroups] ); + const showCurationActions = showPriceControls; + const showCreateAction = Boolean(isLoggedIn && canCreateDrop && onCreateDrop); + let actionsRemeasureVariant = "none"; + + if (showCurationActions && showCreateAction) { + actionsRemeasureVariant = "with-drop"; + } else if (showCurationActions) { + actionsRemeasureVariant = "filter-only"; + } + + const remeasureKey = `${activeSortLabel}|${activeCurationLabel}|${curationProbeKey}|actions:${actionsRemeasureVariant}`; const { + headerRowRef, controlsRowRef, viewModeTabsRef, sortTabsProbeRef, sortDropdownProbeRef, curationTabsProbeRef, curationDropdownProbeRef, + actionsFullProbeRef, + actionsIconProbeRef, measurements, } = useLeaderboardHeaderControlMeasurements({ showCurationGroupSelect, - remeasureKey: `${activeSortLabel}|${activeCurationLabel}|${curationProbeKey}`, + measureAgainstHeaderRow: showCurationActions, + remeasureKey, }); - const controlModes = useMemo( + const layout = useMemo( () => - resolveWaveLeaderboardHeaderControlModes({ - availableWidth: measurements.availableWidth, + resolveWaveLeaderboardHeaderLayout({ + rowWidth: measurements.rowWidth, viewModesWidth: measurements.viewModesWidth, sortTabsWidth: measurements.sortTabsWidth, sortDropdownWidth: measurements.sortDropdownWidth, hasCurationControl: showCurationGroupSelect, curationTabsWidth: measurements.curationTabsWidth, curationDropdownWidth: measurements.curationDropdownWidth, + hasActions: showCurationActions, + actionsFullWidth: measurements.actionsFullWidth, + actionsIconWidth: measurements.actionsIconWidth, + wrapEarlyThresholdPx: 20, }), - [measurements, showCurationGroupSelect] + [measurements, showCurationActions, showCurationGroupSelect] ); const getViewModeLabel = (mode: LeaderboardViewMode) => { @@ -196,14 +501,95 @@ export const WaveLeaderboardHeader: React.FC = ({ ); }; + const onTogglePriceFilters = () => { + if (hasActivePriceFilters) { + setIsActiveFiltersCollapsed((current) => !current); + return; + } + setIsManualFiltersOpen((current) => !current); + }; + + const isCompactActions = layout.actionMode === "icon"; + const shouldRenderActionsInPriceRow = + showCurationActions && isPriceFiltersOpen && layout.wrapActions; + let headerRowFlexClass = "tw-flex-wrap"; + if (showCurationActions) { + if (shouldRenderActionsInPriceRow) { + headerRowFlexClass = "tw-flex-nowrap"; + } else { + headerRowFlexClass = layout.wrapActions + ? "tw-flex-wrap" + : "tw-flex-nowrap"; + } + } + const controlsRowBasisClass = + layout.wrapActions && !shouldRenderActionsInPriceRow ? "tw-basis-full" : ""; + const showHeaderActions = + showCurationActions && !shouldRenderActionsInPriceRow; + const showDefaultCreateRow = !showCurationActions && isLoggedIn; + const curationActionControls = ( + <> + + {showCreateAction && ( + onCreateDrop?.()} + padding={isCompactActions ? "tw-px-2.5 tw-py-2" : "tw-px-3.5 tw-py-2"} + > + {isCompactActions ? ( + <> + + Drop Art + + ) : ( + <> + + Drop Art + + )} + + )} + + ); + return (
-
+
= ({
{showCurationGroupSelect && onCurationGroupChange && ( @@ -264,12 +651,24 @@ export const WaveLeaderboardHeader: React.FC = ({ groups={curationGroups} selectedGroupId={curatedByGroupId} onChange={onCurationGroupChange} - mode={controlModes.curationMode} + mode={layout.curationMode} />
)}
- {isLoggedIn && ( + {showHeaderActions && ( +
+ {curationActionControls} +
+ )} + {showDefaultCreateRow && (
@@ -288,6 +687,45 @@ export const WaveLeaderboardHeader: React.FC = ({ )}
+ {showPriceControls && ( + + {isPriceFiltersOpen && ( + + setIsActiveFiltersCollapsed(false)} + onFiltersCleared={() => { + setIsManualFiltersOpen(false); + setIsActiveFiltersCollapsed(false); + }} + trailingActions={ + shouldRenderActionsInPriceRow ? ( +
+ {curationActionControls} +
+ ) : undefined + } + /> +
+ )} +
+ )} +
); diff --git a/components/waves/leaderboard/header/WaveleaderboardSort.tsx b/components/waves/leaderboard/header/WaveleaderboardSort.tsx index fc58e3d5a8..b7fdc5f86c 100644 --- a/components/waves/leaderboard/header/WaveleaderboardSort.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardSort.tsx @@ -9,6 +9,9 @@ interface WaveleaderboardSortProps { readonly sort: WaveDropsLeaderboardSort; readonly onSortChange: (sort: WaveDropsLeaderboardSort) => void; readonly mode?: WaveleaderboardSortMode; + readonly items?: + | readonly CommonSelectItem[] + | undefined; } type WaveleaderboardSortMode = "tabs" | "dropdown"; @@ -37,10 +40,21 @@ export const WAVE_LEADERBOARD_SORT_ITEMS: readonly CommonSelectItem[] = + [ + ...WAVE_LEADERBOARD_SORT_ITEMS, + { + key: WaveDropsLeaderboardSort.PRICE, + label: "Price", + value: WaveDropsLeaderboardSort.PRICE, + }, + ]; + export const WaveleaderboardSort: React.FC = ({ sort, onSortChange, mode = "dropdown", + items = WAVE_LEADERBOARD_SORT_ITEMS, }) => { const getTabClassName = (value: WaveDropsLeaderboardSort): string => { const baseClass = @@ -60,7 +74,7 @@ export const WaveleaderboardSort: React.FC = ({ aria-label="Sort options" className="tw-flex tw-items-center tw-gap-x-1 tw-rounded-lg tw-bg-iron-950 tw-p-1 tw-ring-1 tw-ring-inset tw-ring-iron-700" > - {WAVE_LEADERBOARD_SORT_ITEMS.map((item) => ( + {items.map((item) => (