From 5e2a3f76e94a0033a9a3a96907fb32bb72009589 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 2 Mar 2026 12:06:15 -0400 Subject: [PATCH 1/7] wip Signed-off-by: Simo --- .../MyStreamWaveLeaderboard.test.tsx | 106 +++++- .../WaveLeaderboardCurationDropModal.test.tsx | 108 ++++++ .../header/WaveleaderboardHeader.test.tsx | 279 ++++++++++++++ .../header/WaveleaderboardSort.test.tsx | 25 ++ .../useWaveDropsLeaderboard.extra.test.ts | 14 +- .../my-stream/MyStreamWaveLeaderboard.tsx | 114 +++++- .../waves/CreateCurationDropContent.tsx | 90 +++-- .../waves/CreateCurationDropUrlInput.tsx | 4 +- .../WaveLeaderboardCurationDropModal.tsx | 103 +++++ .../drops/WaveLeaderboardDrops.tsx | 9 + .../gallery/WaveLeaderboardGallery.tsx | 9 + .../leaderboard/grid/WaveLeaderboardGrid.tsx | 13 +- .../header/WaveleaderboardHeader.tsx | 267 ++++++++++++- .../header/WaveleaderboardSort.tsx | 18 +- generated/models/ApiCicContributor.ts | 45 +++ generated/models/ApiCicContributorsPage.ts | 52 +++ generated/models/ApiCicOverview.ts | 59 +++ generated/models/ApiRepCategoriesPage.ts | 52 +++ generated/models/ApiRepCategory.ts | 66 ++++ generated/models/ApiRepContributor.ts | 45 +++ generated/models/ApiRepContributorsPage.ts | 52 +++ generated/models/ApiRepDirection.ts | 19 + generated/models/ApiRepOverview.ts | 59 +++ generated/models/ObjectSerializer.ts | 27 ++ hooks/useWaveDropsLeaderboard.ts | 55 ++- openapi.yaml | 351 ++++++++++++++++++ 26 files changed, 1942 insertions(+), 99 deletions(-) create mode 100644 __tests__/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.test.tsx create mode 100644 components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx create mode 100644 generated/models/ApiCicContributor.ts create mode 100644 generated/models/ApiCicContributorsPage.ts create mode 100644 generated/models/ApiCicOverview.ts create mode 100644 generated/models/ApiRepCategoriesPage.ts create mode 100644 generated/models/ApiRepCategory.ts create mode 100644 generated/models/ApiRepContributor.ts create mode 100644 generated/models/ApiRepContributorsPage.ts create mode 100644 generated/models/ApiRepDirection.ts create mode 100644 generated/models/ApiRepOverview.ts diff --git a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx index 752d5d9600..107669ee67 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx @@ -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 ? ( +
@@ -268,16 +349,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 a93cc37be3..ebe913d34e 100644 --- a/components/waves/CreateCurationDropContent.tsx +++ b/components/waves/CreateCurationDropContent.tsx @@ -30,6 +30,7 @@ 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 { @@ -428,56 +429,53 @@ const CreateCurationDropContent: React.FC = ({ dropId={dropId} /> {isLeaderboardVariant ? ( -
-
- setShowLiveValidation(true)} - onSubmit={onDrop} - /> -
- + {showSupportedUrlAttention && ( +

- Supported URLs - - {showSupportedUrlAttention && ( -

- Unsupported URL format. Open Supported URLs. + Unsupported URL format. Open Supported URLs. +

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

+ Will submit as: {normalizedCurationUrl}

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

- Will submit as: {normalizedCurationUrl} -

- )} -
-
-
-
+ + Submit to Curation +
) : (
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/leaderboard/create/WaveLeaderboardCurationDropModal.tsx b/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx new file mode 100644 index 0000000000..2b4b4ebb28 --- /dev/null +++ b/components/waves/leaderboard/create/WaveLeaderboardCurationDropModal.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { ApiWave } from "@/generated/models/ApiWave"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { motion } from "framer-motion"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { WaveDropCreate } from "./WaveDropCreate"; + +interface WaveLeaderboardCurationDropModalProps { + readonly isOpen: boolean; + readonly wave: ApiWave; + readonly onClose: () => void; +} + +export function WaveLeaderboardCurationDropModal({ + isOpen, + wave, + onClose, +}: WaveLeaderboardCurationDropModalProps) { + const canUseDOM = typeof document !== "undefined"; + + useEffect(() => { + if (!isOpen) { + return; + } + + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + document.addEventListener("keydown", onKeyDown); + + return () => { + document.body.style.overflow = originalOverflow; + document.removeEventListener("keydown", onKeyDown); + }; + }, [isOpen, onClose]); + + if (!canUseDOM || !isOpen) { + return null; + } + + return createPortal( + + +
+ + + + + , + 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..673f9db416 100644 --- a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx @@ -6,14 +6,17 @@ 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 } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/solid"; -import React, { useContext, useMemo } from "react"; +import React, { useContext, useMemo, useState } from "react"; import { Tooltip } from "react-tooltip"; 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"; @@ -31,8 +34,155 @@ 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; } +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 WaveLeaderboardPriceFilters: React.FC< + WaveLeaderboardPriceFiltersProps +> = ({ + waveId, + minPrice, + maxPrice, + onPriceRangeChange, + onFiltersActivated, + onFiltersCleared, +}) => { + const [minPriceInput, setMinPriceInput] = useState(() => + toPriceInputValue(minPrice) + ); + const [maxPriceInput, setMaxPriceInput] = useState(() => + toPriceInputValue(maxPrice) + ); + + const commitPriceRange = () => { + if (!onPriceRangeChange) { + return; + } + + const nextMinPrice = parsePriceInput(minPriceInput); + const nextMaxPrice = parsePriceInput(maxPriceInput); + const hasActivePriceFilters = + typeof nextMinPrice === "number" || typeof nextMaxPrice === "number"; + + if (hasActivePriceFilters) { + onFiltersActivated(); + } + + onPriceRangeChange({ + minPrice: nextMinPrice, + maxPrice: nextMaxPrice, + }); + }; + + return ( +
+
+ + setMinPriceInput(event.target.value)} + onBlur={commitPriceRange} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitPriceRange(); + } + }} + className="tw-rounded-xl tw-border-0 tw-bg-black tw-px-4 tw-py-3 tw-text-xl tw-font-medium tw-text-iron-100 tw-ring-1 tw-ring-inset tw-ring-iron-700 placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-primary-400" + /> +
+
+ + setMaxPriceInput(event.target.value)} + onBlur={commitPriceRange} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitPriceRange(); + } + }} + className="tw-rounded-xl tw-border-0 tw-bg-black tw-px-4 tw-py-3 tw-text-xl tw-font-medium tw-text-iron-100 tw-ring-1 tw-ring-inset tw-ring-iron-700 placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-primary-400" + /> +
+ +
+ ); +}; + export const WaveLeaderboardHeader: React.FC = ({ wave, onCreateDrop, @@ -43,6 +193,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 +209,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"; @@ -196,6 +362,16 @@ export const WaveLeaderboardHeader: React.FC = ({ ); }; + const onTogglePriceFilters = () => { + if (hasActivePriceFilters) { + setIsActiveFiltersCollapsed((current) => !current); + return; + } + setIsManualFiltersOpen((current) => !current); + }; + + const showCurationActions = showPriceControls; + return (
@@ -256,6 +432,7 @@ export const WaveLeaderboardHeader: React.FC = ({ sort={sort} onSortChange={onSortChange} mode={controlModes.sortMode} + items={sortItems} />
{showCurationGroupSelect && onCurationGroupChange && ( @@ -269,25 +446,85 @@ export const WaveLeaderboardHeader: React.FC = ({
)}
- {isLoggedIn && ( -
- {canCreateDrop && onCreateDrop && ( + {showCurationActions ? ( +
+ + {isLoggedIn && canCreateDrop && onCreateDrop && ( - - Drop + + Drop Art )}
+ ) : ( + isLoggedIn && ( +
+ {canCreateDrop && onCreateDrop && ( + + + Drop + + )} +
+ ) )}
+ {showPriceControls && ( + + {isPriceFiltersOpen && ( + + setIsActiveFiltersCollapsed(false)} + onFiltersCleared={() => { + setIsManualFiltersOpen(false); + setIsActiveFiltersCollapsed(false); + }} + /> + + )} + + )} +