From 152d91ff87278004e2482d7ebba40b5db89e3688 Mon Sep 17 00:00:00 2001 From: ragnep Date: Wed, 11 Mar 2026 11:35:59 +0200 Subject: [PATCH 1/8] wip Signed-off-by: ragnep --- .../CollectedStatsSeasonTile.test.tsx | 55 +++++++- .../user/collected/UserPageCollected.test.tsx | 119 +++++++++++++++++- .../collected/UserPageCollectedStats.test.tsx | 104 +++++++++++++++ .../user/collected/UserPageCollected.tsx | 73 +++++++++-- .../user/collected/UserPageCollectedStats.tsx | 70 ++++++++++- components/user/collected/stats/helpers.ts | 6 + .../subcomponents/CollectedStatsHeader.tsx | 98 ++++++++++++--- .../CollectedStatsSeasonTile.tsx | 62 +++++---- .../subcomponents/CollectedStatsSeasons.tsx | 13 +- components/user/collected/stats/types.ts | 3 + 10 files changed, 542 insertions(+), 61 deletions(-) diff --git a/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx b/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx index 8840957965..619b9bbb59 100644 --- a/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx +++ b/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx @@ -1,6 +1,7 @@ import { CollectedStatsSeasonTile } from "@/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile"; import type { DisplaySeason } from "@/components/user/collected/stats/types"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; let shouldReduceMotion = false; @@ -16,6 +17,7 @@ const buildSeason = ( ): DisplaySeason => ({ id: "Season 2", label: "SZN2", + seasonNumber: 2, totalCards: 39, setsHeld: 1, nextSetCards: 26, @@ -34,11 +36,11 @@ const renderTile = ( render( ); @@ -116,4 +118,51 @@ describe("CollectedStatsSeasonTile", () => { ); expect(progressCircle?.querySelector("animate")).not.toBeInTheDocument(); }); + + it("does not trigger the selection handler on hover", async () => { + const user = userEvent.setup(); + const onPreview = jest.fn(); + const onSelect = jest.fn(); + + render( + + ); + + const button = screen.getByRole("button", { name: /szn2/i }); + + await user.hover(button); + + expect(onPreview).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows a subtle selected state on the whole tile", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /szn2/i })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(screen.getByRole("button", { name: /szn2/i })).toHaveClass( + "tw-border-iron-700", + "tw-bg-iron-950/80" + ); + }); }); diff --git a/__tests__/components/user/collected/UserPageCollected.test.tsx b/__tests__/components/user/collected/UserPageCollected.test.tsx index 4cd5c126f7..57fa1105c2 100644 --- a/__tests__/components/user/collected/UserPageCollected.test.tsx +++ b/__tests__/components/user/collected/UserPageCollected.test.tsx @@ -3,6 +3,7 @@ import UserPageCollected from "@/components/user/collected/UserPageCollected"; import { CollectedCollectionType } from "@/entities/IProfile"; import { useQuery } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { useParams, usePathname, @@ -58,8 +59,25 @@ jest.mock( jest.mock( "@/components/user/collected/UserPageCollectedStats", () => - function MockCollectedStats() { - return
; + function MockCollectedStats(props: any) { + return ( +
+ + +
+ ); } ); @@ -93,12 +111,18 @@ describe("UserPageCollected", () => { toString: jest.fn(() => ""), entries: jest.fn(() => [].values()), }; + const getLastReplaceParams = () => { + const path = routerReplace.mock.calls.at(-1)?.[0] as string | undefined; + const query = path?.split("?")[1] ?? ""; + return new URLSearchParams(query); + }; beforeEach(() => { useParamsMock.mockReturnValue({ user: "testuser" }); usePathnameMock.mockReturnValue("/testuser/collected"); useSearchParamsMock.mockReturnValue(mockSearchParams); mockSearchParams.get.mockReturnValue(null); + mockSearchParams.toString.mockReturnValue(""); // Provide router with replace/push so component calls during mount don't crash useRouterMock.mockReturnValue({ @@ -308,4 +332,95 @@ describe("UserPageCollected", () => { }) ); }); + + it("applies collection shortcuts through the existing url filter flow", async () => { + const user = userEvent.setup(); + + mockSearchParams.get.mockImplementation((key: string) => { + if (key === "collection") return "network"; + if (key === "sort-by") return "xtdh"; + if (key === "sort-direction") return "desc"; + return null; + }); + mockSearchParams.toString.mockReturnValue( + "collection=network&sort-by=xtdh&sort-direction=desc" + ); + + renderWithTransferProvider(); + + await user.click(screen.getByTestId("stats-collection-shortcut")); + + const params = getLastReplaceParams(); + + expect(params.get("collection")).toBe("nextgen"); + expect(params.get("page")).toBe("1"); + expect(params.get("seized")).toBe("seized"); + expect(params.get("szn")).toBeNull(); + expect(params.get("subcollection")).toBeNull(); + expect(params.get("sort-by")).toBe("token_id"); + expect(params.get("sort-direction")).toBe("desc"); + }); + + it("applies season shortcuts through the existing url filter flow", async () => { + const user = userEvent.setup(); + + renderWithTransferProvider(); + + await user.click(screen.getByTestId("stats-season-shortcut")); + + const params = getLastReplaceParams(); + + expect(params.get("collection")).toBe("memes"); + expect(params.get("page")).toBe("1"); + expect(params.get("seized")).toBe("seized"); + expect(params.get("szn")).toBe("2"); + expect(params.get("subcollection")).toBeNull(); + }); + + it("clears the collection shortcut when the same metric is clicked again", async () => { + const user = userEvent.setup(); + + mockSearchParams.get.mockImplementation((key: string) => { + if (key === "collection") return "nextgen"; + if (key === "seized") return "seized"; + return null; + }); + mockSearchParams.toString.mockReturnValue( + "collection=nextgen&seized=seized" + ); + + renderWithTransferProvider(); + + await user.click(screen.getByTestId("stats-collection-shortcut")); + + const params = getLastReplaceParams(); + + expect(params.get("collection")).toBeNull(); + expect(params.get("szn")).toBeNull(); + expect(params.get("page")).toBe("1"); + }); + + it("clears the season shortcut when the same season is clicked again", async () => { + const user = userEvent.setup(); + + mockSearchParams.get.mockImplementation((key: string) => { + if (key === "collection") return "memes"; + if (key === "szn") return "2"; + if (key === "seized") return "seized"; + return null; + }); + mockSearchParams.toString.mockReturnValue( + "collection=memes&szn=2&seized=seized" + ); + + renderWithTransferProvider(); + + await user.click(screen.getByTestId("stats-season-shortcut")); + + const params = getLastReplaceParams(); + + expect(params.get("collection")).toBeNull(); + expect(params.get("szn")).toBeNull(); + expect(params.get("page")).toBe("1"); + }); }); diff --git a/__tests__/components/user/collected/UserPageCollectedStats.test.tsx b/__tests__/components/user/collected/UserPageCollectedStats.test.tsx index 2c95927047..e637ab7c78 100644 --- a/__tests__/components/user/collected/UserPageCollectedStats.test.tsx +++ b/__tests__/components/user/collected/UserPageCollectedStats.test.tsx @@ -1,10 +1,12 @@ import UserPageCollectedStats from "@/components/user/collected/UserPageCollectedStats"; import type { UserPageStatsInitialData } from "@/components/user/stats/userPageStats.types"; +import { CollectedCollectionType } from "@/entities/IProfile"; import { commonApiFetch } from "@/services/api/common-api"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; +import { useDesktopSeasonRowCapacity } from "@/components/user/collected/stats/useDesktopSeasonRowCapacity"; jest.mock("@/components/user/stats/UserPageStatsDetailsContent", () => ({ __esModule: true, @@ -28,7 +30,16 @@ jest.mock("@/services/api/common-api", () => ({ commonApiFetch: jest.fn(), })); +jest.mock( + "@/components/user/collected/stats/useDesktopSeasonRowCapacity", + () => ({ + useDesktopSeasonRowCapacity: jest.fn(), + }) +); + const apiMock = commonApiFetch as jest.Mock; +const useDesktopSeasonRowCapacityMock = + useDesktopSeasonRowCapacity as jest.Mock; const profile = { handle: "punk6529", @@ -109,6 +120,11 @@ describe("UserPageCollectedStats", () => { beforeEach(() => { apiMock.mockClear(); apiMock.mockResolvedValue({}); + useDesktopSeasonRowCapacityMock.mockReturnValue({ + containerRef: { current: null }, + visibleSeasonCount: null, + isDesktopLayout: false, + }); }); it("renders the new collected header metrics and season states", async () => { @@ -140,6 +156,89 @@ describe("UserPageCollectedStats", () => { expect(screen.getByText("26/39 to set 2")).toBeInTheDocument(); }); + it("uses collection shortcuts for collection-backed metrics and keeps boost informational", async () => { + const user = userEvent.setup(); + const onCollectionShortcut = jest.fn(); + + renderWithQueryClient( + + ); + + const nextGenButton = screen.getByRole("button", { name: /nextgen/i }); + + expect(nextGenButton).toHaveAttribute("aria-pressed", "true"); + + await user.click(nextGenButton); + + expect(onCollectionShortcut).toHaveBeenCalledWith( + CollectedCollectionType.NEXTGEN + ); + expect( + screen.queryByRole("button", { name: /boost/i }) + ).not.toBeInTheDocument(); + }); + + it("only applies the season shortcut on click while hover still previews details", async () => { + const user = userEvent.setup(); + const onSeasonShortcut = jest.fn(); + + renderWithQueryClient( + + ); + + const seasonButton = screen.getByRole("button", { name: /szn2/i }); + + expect(seasonButton).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("26/39 to set 2")).toBeInTheDocument(); + + await user.hover(seasonButton); + + expect(onSeasonShortcut).not.toHaveBeenCalled(); + expect(screen.getByText("26/39 to set 2")).toBeInTheDocument(); + + await user.click(seasonButton); + + expect(onSeasonShortcut).toHaveBeenCalledWith(2); + }); + + it("keeps the filtered season visible in the collapsed desktop row", () => { + useDesktopSeasonRowCapacityMock.mockReturnValue({ + containerRef: { current: null }, + visibleSeasonCount: 1, + isDesktopLayout: true, + }); + + renderWithQueryClient( + + ); + + expect(screen.getByRole("button", { name: /szn2/i })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /szn1/i }) + ).not.toBeInTheDocument(); + expect(screen.getByText("26/39 to set 2")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Show 1 more started seasons" }) + ).toBeInTheDocument(); + }); + it("derives Meme Sets as the minimum full-set count across valid seasons", () => { renderWithQueryClient( { it("collapses overflowing started seasons behind a see more control on desktop", async () => { const user = userEvent.setup(); + useDesktopSeasonRowCapacityMock.mockReturnValue({ + containerRef: { current: null }, + visibleSeasonCount: 4, + isDesktopLayout: true, + }); const originalInnerWidth = globalThis.innerWidth; const originalGetComputedStyle = globalThis.getComputedStyle; const clientWidthSpy = jest diff --git a/components/user/collected/UserPageCollected.tsx b/components/user/collected/UserPageCollected.tsx index 7110fe43c3..bd6fc1b210 100644 --- a/components/user/collected/UserPageCollected.tsx +++ b/components/user/collected/UserPageCollected.tsx @@ -41,6 +41,7 @@ import UserPageCollectedFilters from "./filters/UserPageCollectedFilters"; import { useXtdhTokensQuery } from "./hooks/useXtdhTokensQuery"; import UserPageCollectedFirstLoading from "./UserPageCollectedFirstLoading"; import UserPageCollectedStats from "./UserPageCollectedStats"; + export interface ProfileCollectedFilters { readonly handleOrWallet: string; readonly accountForConsolidations: boolean; @@ -234,14 +235,19 @@ export default function UserPageCollected({ const { enabled: transferEnabled } = useTransfer(); - const setCollection = async ( - collection: CollectedCollectionType | null - ): Promise => { - if (!filters.collection && !collection) return; + const getCollectionUpdateItems = ({ + collection, + allowToggle, + }: { + readonly collection: CollectedCollectionType | null; + readonly allowToggle: boolean; + }): QueryUpdateInput[] => { + const nextCollection = + allowToggle && filters.collection === collection ? null : collection; const items: QueryUpdateInput[] = [ { name: "collection", - value: filters.collection === collection ? null : (collection ?? null), + value: nextCollection, }, { name: "page", @@ -262,12 +268,13 @@ export default function UserPageCollected({ ]; const isSwitchingFromNetwork = - filters.collection === CollectedCollectionType.NETWORK; + filters.collection === CollectedCollectionType.NETWORK && + nextCollection !== CollectedCollectionType.NETWORK; if ( - (collection && - collection !== CollectedCollectionType.NETWORK && - !COLLECTED_COLLECTIONS_META[collection].filters.sort.includes( + (nextCollection !== null && + nextCollection !== CollectedCollectionType.NETWORK && + !COLLECTED_COLLECTIONS_META[nextCollection].filters.sort.includes( filters.sortBy )) || isSwitchingFromNetwork @@ -280,7 +287,7 @@ export default function UserPageCollected({ name: "sortDirection", value: SortDirection.DESC, }); - } else if (collection === CollectedCollectionType.NETWORK) { + } else if (nextCollection === CollectedCollectionType.NETWORK) { items.push({ name: "sortBy", value: CollectionSort.XTDH, @@ -291,6 +298,48 @@ export default function UserPageCollected({ }); } + return items; + }; + + const setCollection = async ( + collection: CollectedCollectionType | null + ): Promise => { + if (filters.collection === null && collection === null) return; + await updateFields( + getCollectionUpdateItems({ + collection, + allowToggle: true, + }) + ); + }; + + const setCollectionShortcut = async ( + collection: CollectedCollectionType + ): Promise => { + await updateFields( + getCollectionUpdateItems({ + collection, + allowToggle: true, + }) + ); + }; + + const setSeasonShortcut = async (seasonNumber: number): Promise => { + const isActiveSeasonShortcut = + filters.collection === CollectedCollectionType.MEMES && + filters.initialSznId === seasonNumber; + const items = getCollectionUpdateItems({ + collection: isActiveSeasonShortcut ? null : CollectedCollectionType.MEMES, + allowToggle: false, + }).map((item) => + item.name === "szn" + ? { + ...item, + value: isActiveSeasonShortcut ? null : seasonNumber.toString(), + } + : item + ); + await updateFields(items); }; @@ -538,6 +587,10 @@ export default function UserPageCollected({ filters.accountForConsolidations ? null : filters.handleOrWallet } initialStatsData={initialStatsData} + activeCollection={filters.collection} + activeSeasonNumber={filters.initialSznId} + onCollectionShortcut={setCollectionShortcut} + onSeasonShortcut={setSeasonShortcut} /> {isLoading ? ( diff --git a/components/user/collected/UserPageCollectedStats.tsx b/components/user/collected/UserPageCollectedStats.tsx index 6836f40d34..a33fc5b472 100644 --- a/components/user/collected/UserPageCollectedStats.tsx +++ b/components/user/collected/UserPageCollectedStats.tsx @@ -1,6 +1,7 @@ "use client"; import type { UserPageStatsInitialData } from "@/components/user/stats/userPageStats.types"; +import type { CollectedCollectionType } from "@/entities/IProfile"; import type { ApiIdentity } from "@/generated/models/ObjectSerializer"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useId, useMemo, useState } from "react"; @@ -8,19 +9,64 @@ import { buildCollectedStatsViewModel } from "./stats/helpers"; import { CollectedStatsDetailsPanel } from "./stats/subcomponents/CollectedStatsDetailsPanel"; import { CollectedStatsHeader } from "./stats/subcomponents/CollectedStatsHeader"; import { CollectedStatsSeasons } from "./stats/subcomponents/CollectedStatsSeasons"; +import type { DisplaySeason } from "./stats/types"; import { useCollectedStatsData } from "./stats/useCollectedStatsData"; import { useDesktopSeasonRowCapacity } from "./stats/useDesktopSeasonRowCapacity"; +const getCollapsedStartedSeasons = ({ + startedSeasons, + visibleSeasonCount, + activeSeasonFilterId, +}: { + readonly startedSeasons: readonly DisplaySeason[]; + readonly visibleSeasonCount: number | null; + readonly activeSeasonFilterId: string | null; +}): DisplaySeason[] => { + if (visibleSeasonCount === null) { + return [...startedSeasons]; + } + + const collapsedStartedSeasons = startedSeasons.slice(0, visibleSeasonCount); + if ( + activeSeasonFilterId === null || + collapsedStartedSeasons.some((season) => season.id === activeSeasonFilterId) + ) { + return collapsedStartedSeasons; + } + + const activeSeason = startedSeasons.find( + (season) => season.id === activeSeasonFilterId + ); + if (activeSeason === undefined || visibleSeasonCount <= 0) { + return collapsedStartedSeasons; + } + + return [ + ...collapsedStartedSeasons.slice(0, visibleSeasonCount - 1), + activeSeason, + ]; +}; + interface UserPageCollectedStatsProps { readonly profile: ApiIdentity; readonly activeAddress: string | null; readonly initialStatsData: UserPageStatsInitialData; + readonly activeCollection?: CollectedCollectionType | null | undefined; + readonly activeSeasonNumber?: number | null | undefined; + readonly onCollectionShortcut?: + | ((collection: CollectedCollectionType) => void) + | undefined; + readonly onSeasonShortcut?: ((seasonNumber: number) => void) | undefined; } export default function UserPageCollectedStats({ profile, activeAddress, initialStatsData, + activeCollection = null, + activeSeasonNumber = null, + onCollectionShortcut, + onSeasonShortcut, }: Readonly) { const { hasTouchScreen } = useDeviceInfo(); const [isDetailsOpen, setIsDetailsOpen] = useState(false); @@ -60,25 +106,37 @@ export default function UserPageCollectedStats({ (desktopVisibleSeasonCount ?? startedSeasons.length), 0 ); + const activeSeasonFilterId = + startedSeasons.find((season) => season.seasonNumber === activeSeasonNumber) + ?.id ?? null; const shouldShowAllStartedSeasons = !isDesktopSeasonsLayout || isDesktopSeasonListExpanded || hiddenStartedSeasonCount === 0; const visibleStartedSeasons = shouldShowAllStartedSeasons ? startedSeasons - : startedSeasons.slice(0, desktopVisibleSeasonCount ?? 0); - - const defaultActiveSeasonId = startedSeasons[0]?.id ?? null; + : getCollapsedStartedSeasons({ + startedSeasons, + visibleSeasonCount: desktopVisibleSeasonCount, + activeSeasonFilterId, + }); + const defaultActiveSeasonId = + activeSeasonFilterId ?? startedSeasons[0]?.id ?? null; const selectedActiveSeasonId = startedSeasons.some( (season) => season.id === preferredActiveSeasonId ) ? preferredActiveSeasonId : defaultActiveSeasonId; + const fallbackVisibleActiveSeasonId = + visibleStartedSeasons.find((season) => season.id === activeSeasonFilterId) + ?.id ?? + visibleStartedSeasons[0]?.id ?? + selectedActiveSeasonId; const activeSeasonId = shouldShowAllStartedSeasons || visibleStartedSeasons.some((season) => season.id === selectedActiveSeasonId) ? selectedActiveSeasonId - : (visibleStartedSeasons[0]?.id ?? selectedActiveSeasonId); + : fallbackVisibleActiveSeasonId; return (
@@ -86,9 +144,11 @@ export default function UserPageCollectedStats({
setIsDetailsOpen((current) => !current)} + onCollectionShortcut={onCollectionShortcut} /> setIsDesktopSeasonListExpanded((current) => !current) } diff --git a/components/user/collected/stats/helpers.ts b/components/user/collected/stats/helpers.ts index 46a851cb87..a6fcdf8f19 100644 --- a/components/user/collected/stats/helpers.ts +++ b/components/user/collected/stats/helpers.ts @@ -2,6 +2,7 @@ import { getCollectedStatsIdentityKey, getStatsPath, } from "@/components/user/stats/userPageStats.helpers"; +import { CollectedCollectionType } from "@/entities/IProfile"; import type { ApiCollectedStats } from "@/generated/models/ApiCollectedStats"; import type { ApiCollectedStatsSeason } from "@/generated/models/ApiCollectedStatsSeason"; import type { ApiIdentity } from "@/generated/models/ObjectSerializer"; @@ -78,6 +79,7 @@ const buildMainMetrics = ( id: "nextgen", label: "NextGen", val: formatMetricValue(collectedStats.nextgen_balance), + collection: CollectedCollectionType.NEXTGEN, }); } @@ -86,6 +88,7 @@ const buildMainMetrics = ( id: "memes_sets", label: "Meme Sets", val: formatMetricValue(memeSets), + collection: CollectedCollectionType.MEMES, }); } @@ -99,6 +102,7 @@ const buildMainMetrics = ( id: "memes", label: "Memes", val: formatMetricValue(collectedStats.memes_balance), + collection: CollectedCollectionType.MEMES, ...(uniqueSub ? { sub: uniqueSub } : {}), }); } @@ -108,6 +112,7 @@ const buildMainMetrics = ( id: "gradients", label: "Gradients", val: formatMetricValue(collectedStats.gradients_balance), + collection: CollectedCollectionType.GRADIENTS, }); } @@ -156,6 +161,7 @@ const buildDisplaySeason = ( return { id: season.season, label: `SZN${seasonNumber}`, + seasonNumber, totalCards, setsHeld, nextSetCards, diff --git a/components/user/collected/stats/subcomponents/CollectedStatsHeader.tsx b/components/user/collected/stats/subcomponents/CollectedStatsHeader.tsx index 9b05f54c91..55e09c53d0 100644 --- a/components/user/collected/stats/subcomponents/CollectedStatsHeader.tsx +++ b/components/user/collected/stats/subcomponents/CollectedStatsHeader.tsx @@ -1,18 +1,25 @@ import { ChartBarIcon } from "@heroicons/react/24/outline"; +import type { CollectedCollectionType } from "@/entities/IProfile"; import type { CollectedHeaderMetric } from "../types"; interface CollectedStatsHeaderProps { readonly metrics: CollectedHeaderMetric[]; + readonly activeCollection: CollectedCollectionType | null; readonly isDetailsOpen: boolean; readonly detailsId: string; readonly onToggleDetails: () => void; + readonly onCollectionShortcut?: + | ((collection: CollectedCollectionType) => void) + | undefined; } export function CollectedStatsHeader({ metrics, + activeCollection, isDetailsOpen, detailsId, onToggleDetails, + onCollectionShortcut, }: Readonly) { return (
@@ -20,31 +27,94 @@ export function CollectedStatsHeader({ {metrics.length > 0 && (
- {metrics.map((metric, index) => ( -
-
- + {metrics.map((metric, index) => { + const metricCollection = metric.collection; + const isInteractive = + metricCollection !== undefined && + onCollectionShortcut !== undefined; + const isSelected = + metricCollection !== undefined && + metricCollection === activeCollection; + let labelToneClass: string | undefined; + let valueToneClass: string | undefined; + let subToneClass: string | undefined; + + if (isSelected) { + labelToneClass = "tw-text-iron-300"; + valueToneClass = "tw-text-white"; + subToneClass = "tw-text-iron-400"; + } else if (isInteractive) { + labelToneClass = "group-hover:tw-text-iron-300"; + valueToneClass = "group-hover:tw-text-white"; + subToneClass = "group-hover:tw-text-iron-400"; + } + const metricContent = ( + <> + {metric.label}
- + {metric.val} {metric.sub && ( - + {metric.sub} )}
+ + ); + + return ( +
+ {isInteractive ? ( + + ) : ( +
{metricContent}
+ )} + {index < metrics.length - 1 && ( +
+ )}
- {index < metrics.length - 1 && ( -
- )} -
- ))} + ); + })}
)} diff --git a/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile.tsx b/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile.tsx index 8e7c8d90e9..a85cdcf9ba 100644 --- a/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile.tsx +++ b/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile.tsx @@ -4,11 +4,12 @@ import type { DisplaySeason } from "../types"; interface CollectedStatsSeasonTileProps { readonly season: DisplaySeason; - readonly isActive: boolean; + readonly isSelected: boolean; readonly showDetailText: boolean; readonly hasTouchScreen: boolean; readonly shouldAnimateProgressOnMount: boolean; - readonly onActivate: () => void; + readonly onPreview: () => void; + readonly onSelect?: (() => void) | undefined; } const PROGRESS_RADIUS = 24; @@ -22,35 +23,35 @@ const PROGRESS_COLOR_TRANSITION_STYLE = { const getBaseTrackClass = ( season: DisplaySeason, - isActive: boolean + isSelected: boolean ): string => { if (!season.isStarted) { return "tw-text-iron-900"; } - return isActive ? "tw-text-iron-700" : "tw-text-iron-800"; + return isSelected ? "tw-text-iron-650" : "tw-text-iron-800"; }; const getProgressStrokeClass = ( season: DisplaySeason, - isActive: boolean + isSelected: boolean ): string => { if (season.progressPct >= 1) { return "tw-text-iron-200"; } - return isActive ? "tw-text-iron-300" : "tw-text-iron-500"; + return isSelected ? "tw-text-iron-300" : "tw-text-iron-500"; }; const getLabelClassName = ( season: DisplaySeason, - isActive: boolean + isSelected: boolean ): string => { if (!season.isStarted) { return "tw-text-iron-600"; } - return isActive ? "tw-text-iron-100" : "tw-text-iron-300"; + return isSelected ? "tw-text-iron-100" : "tw-text-iron-300"; }; const getSetsHeldClassName = (season: DisplaySeason): string => { @@ -67,19 +68,19 @@ const getSetsHeldClassName = (season: DisplaySeason): string => { const getDetailTextClassName = ( showDetailText: boolean, - isActive: boolean + isSelected: boolean ): string => { if (!showDetailText) { return "tw-text-transparent"; } - return isActive ? "tw-text-iron-400" : "tw-text-iron-500"; + return isSelected ? "tw-text-iron-400" : "tw-text-iron-500"; }; -const getScaleClassName = (isActive: boolean): string => - isActive - ? "tw-scale-[1.03]" - : "hover:tw-scale-[1.01] focus-visible:tw-scale-[1.01]"; +const getButtonClassName = (isSelected: boolean): string => + isSelected + ? "tw-border-iron-700 tw-bg-iron-950/80" + : "tw-border-transparent tw-bg-transparent hover:tw-border-iron-900 hover:tw-bg-iron-950/60"; const getSetsHeldLabel = (setsHeld: number): string => { if (setsHeld <= 0) { @@ -91,11 +92,12 @@ const getSetsHeldLabel = (setsHeld: number): string => { export function CollectedStatsSeasonTile({ season, - isActive, + isSelected, showDetailText, hasTouchScreen, shouldAnimateProgressOnMount, - onActivate, + onPreview, + onSelect, }: Readonly) { const shouldReduceMotion = useReducedMotion() ?? false; const strokeDashoffset = @@ -109,23 +111,31 @@ export function CollectedStatsSeasonTile({ } : null ); - const baseTrackClass = getBaseTrackClass(season, isActive); - const progressStrokeClass = getProgressStrokeClass(season, isActive); - const labelClassName = getLabelClassName(season, isActive); + const baseTrackClass = getBaseTrackClass(season, isSelected); + const progressStrokeClass = getProgressStrokeClass(season, isSelected); + const labelClassName = getLabelClassName(season, isSelected); const setsHeldClassName = getSetsHeldClassName(season); - const detailTextClassName = getDetailTextClassName(showDetailText, isActive); + const detailTextClassName = getDetailTextClassName( + showDetailText, + isSelected + ); + const handleClick = () => { + onPreview(); + onSelect?.(); + }; return ( -) })); +jest.mock("@/components/user/stats/activity/tabs/UserPageActivityTab", () => ({ + __esModule: true, + default: (props: any) => ( + + ), +})); -describe('UserPageActivityTabs', () => { - it('renders all tabs and handles click', async () => { +describe("UserPageActivityTabs", () => { + it("renders all tabs and handles click", async () => { const user = userEvent.setup(); const setActive = jest.fn(); - render(); - const walletBtn = screen.getByTestId(USER_PAGE_ACTIVITY_TAB.WALLET_ACTIVITY); + render( + + ); + const walletBtn = screen.getByTestId( + USER_PAGE_ACTIVITY_TAB.WALLET_ACTIVITY + ); expect(walletBtn).toBeInTheDocument(); await user.click(walletBtn); - expect(setActive).toHaveBeenCalledWith(USER_PAGE_ACTIVITY_TAB.WALLET_ACTIVITY); + expect(setActive).toHaveBeenCalledWith( + USER_PAGE_ACTIVITY_TAB.WALLET_ACTIVITY + ); }); }); diff --git a/components/user/stats/activity/UserPageActivityWrapper.tsx b/components/user/stats/activity/UserPageActivityWrapper.tsx index 8ad0ab6166..f0b65ed408 100644 --- a/components/user/stats/activity/UserPageActivityWrapper.tsx +++ b/components/user/stats/activity/UserPageActivityWrapper.tsx @@ -14,8 +14,6 @@ import UserPageActivityTabs from "./tabs/UserPageActivityTabs"; import UserPageStatsActivityTDHHistory from "./tdh-history/UserPageStatsActivityTDHHistory"; import UserPageStatsActivityWallet from "./wallet/UserPageStatsActivityWallet"; -export { USER_PAGE_ACTIVITY_TAB } from "./activity.types"; - const ENUM_AND_PATH: { type: USER_PAGE_ACTIVITY_TAB; path: string }[] = [ { type: USER_PAGE_ACTIVITY_TAB.WALLET_ACTIVITY, path: "wallet-activity" }, { type: USER_PAGE_ACTIVITY_TAB.DISTRIBUTIONS, path: "distributions" }, diff --git a/components/user/stats/activity/activity.helpers.ts b/components/user/stats/activity/activity.helpers.ts index 23b6c2340c..3d86a57763 100644 --- a/components/user/stats/activity/activity.helpers.ts +++ b/components/user/stats/activity/activity.helpers.ts @@ -1,5 +1,4 @@ import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { useCallback, useEffect, useReducer } from "react"; export const ACTIVITY_PAGE_SIZE = 10; export const SEARCH_PARAM_ACTIVITY = "activity"; @@ -12,32 +11,6 @@ const getTotalPages = (count: number | undefined, pageSize: number) => ? Math.max(1, Math.ceil(count / pageSize)) : 1; -type PageFilterAction = - | { - readonly type: "set"; - readonly page: number; - } - | { - readonly type: "sync"; - readonly count: number; - readonly pageSize: number; - }; - -const pageFilterReducer = (state: number, action: PageFilterAction): number => { - switch (action.type) { - case "set": - return action.page; - case "sync": { - if (action.count === 0) { - return 1; - } - - const totalPages = getTotalPages(action.count, action.pageSize); - return Math.min(state, totalPages); - } - } -}; - export const getActivityWalletsParam = ({ activeAddress, wallets, @@ -52,81 +25,6 @@ export const getActivityWalletsParam = ({ return (wallets ?? []).map((wallet) => wallet.wallet.toLowerCase()).join(","); }; -export function useActivityPageFilter() { - const [pageFilter, dispatchPageFilter] = useReducer(pageFilterReducer, 1); - - const setPage = useCallback((nextPage: number) => { - dispatchPageFilter({ - type: "set", - page: nextPage, - }); - }, []); - - const syncPageFilter = useCallback( - ({ - count, - pageSize, - }: { - readonly count: number; - readonly pageSize: number; - }) => { - dispatchPageFilter({ - type: "sync", - count, - pageSize, - }); - }, - [] - ); - - return { - pageFilter, - setPage, - syncPageFilter, - }; -} - -export function useSyncActivityPageFilter({ - count, - isFetching, - pageFilter, - pageSize, - syncPageFilter, -}: { - readonly count: number | undefined; - readonly isFetching: boolean; - readonly pageFilter: number; - readonly pageSize: number; - readonly syncPageFilter: (args: { - readonly count: number; - readonly pageSize: number; - }) => void; -}) { - useEffect(() => { - if (isFetching || count === undefined) { - return; - } - - if (count === 0) { - if (pageFilter !== 1) { - syncPageFilter({ - count, - pageSize, - }); - } - return; - } - - const totalPages = getTotalPages(count, pageSize); - if (pageFilter > totalPages) { - syncPageFilter({ - count, - pageSize, - }); - } - }, [count, isFetching, pageFilter, pageSize, syncPageFilter]); -} - export const getActivityPaginationState = ({ count, page, From ed4fe97015fd173218a3745071063db8c21c1606 Mon Sep 17 00:00:00 2001 From: ragnep Date: Wed, 11 Mar 2026 12:06:40 +0200 Subject: [PATCH 3/8] fixed issues Signed-off-by: ragnep --- .../CollectedStatsSeasonTile.test.tsx | 24 ++++++++++- .../user/collected/UserPageCollected.test.tsx | 34 ++++++++++++++- .../collected/UserPageCollectedStats.test.tsx | 42 +++++++++++++++++++ .../user/collected/UserPageCollected.tsx | 2 +- .../user/collected/UserPageCollectedStats.tsx | 30 +++++++++---- .../CollectedStatsSeasonTile.tsx | 6 +-- 6 files changed, 120 insertions(+), 18 deletions(-) diff --git a/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx b/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx index 619b9bbb59..18574b7ccc 100644 --- a/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx +++ b/__tests__/components/user/collected/CollectedStatsSeasonTile.test.tsx @@ -1,6 +1,6 @@ import { CollectedStatsSeasonTile } from "@/components/user/collected/stats/subcomponents/CollectedStatsSeasonTile"; import type { DisplaySeason } from "@/components/user/collected/stats/types"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; let shouldReduceMotion = false; @@ -165,4 +165,26 @@ describe("CollectedStatsSeasonTile", () => { "tw-bg-iron-950/80" ); }); + + it("does not trigger preview when selection is activated by click", () => { + const onPreview = jest.fn(); + const onSelect = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /szn2/i })); + + expect(onPreview).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/user/collected/UserPageCollected.test.tsx b/__tests__/components/user/collected/UserPageCollected.test.tsx index 57fa1105c2..9255fa7d51 100644 --- a/__tests__/components/user/collected/UserPageCollected.test.tsx +++ b/__tests__/components/user/collected/UserPageCollected.test.tsx @@ -27,7 +27,14 @@ jest.mock( () => function MockFilters(props: any) { return ( -
+
+ +
); } ); @@ -61,7 +68,10 @@ jest.mock( () => function MockCollectedStats(props: any) { return ( -
+
- )}
- {hiddenStartedSeasonCount > 0 && isDesktopSeasonListExpanded && ( -
- + {hiddenStartedSeasonCount > 0 && ( +
+ {isDesktopSeasonListExpanded ? ( + + ) : ( + + )}
)} diff --git a/components/utils/select/dropdown/SeasonsGridDropdown.tsx b/components/utils/select/dropdown/SeasonsGridDropdown.tsx index f298d05bec..78caa7c00a 100644 --- a/components/utils/select/dropdown/SeasonsGridDropdown.tsx +++ b/components/utils/select/dropdown/SeasonsGridDropdown.tsx @@ -92,7 +92,7 @@ export default function SeasonsGridDropdown({ ? "tw-opacity-50 tw-text-iron-400" : "hover:tw-ring-iron-600 tw-text-iron-300" } tw-bg-iron-800 lg:tw-bg-iron-900 tw-py-3 tw-w-full tw-truncate tw-text-left tw-relative tw-block tw-whitespace-nowrap tw-rounded-lg tw-border-0 tw-pl-3.5 tw-pr-10 tw-font-semibold tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 - focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 tw-text-sm hover:tw-bg-iron-800 tw-transition tw-duration-300 tw-ease-out tw-justify-between`}> + focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 tw-text-xs hover:tw-bg-iron-800 tw-transition tw-duration-300 tw-ease-out tw-justify-between`}> {getLabel()}
Date: Wed, 11 Mar 2026 14:19:21 +0200 Subject: [PATCH 5/8] wip Signed-off-by: ragnep --- .../user/collected/UserPageCollected.test.tsx | 119 +++++++++++++++++- .../user/collected/UserPageCollected.tsx | 95 +++++++++----- 2 files changed, 184 insertions(+), 30 deletions(-) diff --git a/__tests__/components/user/collected/UserPageCollected.test.tsx b/__tests__/components/user/collected/UserPageCollected.test.tsx index 3ec77569ca..7b11d1de7d 100644 --- a/__tests__/components/user/collected/UserPageCollected.test.tsx +++ b/__tests__/components/user/collected/UserPageCollected.test.tsx @@ -1,6 +1,7 @@ import { TransferProvider } from "@/components/nft-transfer/TransferState"; import UserPageCollected from "@/components/user/collected/UserPageCollected"; import { CollectedCollectionType } from "@/entities/IProfile"; +import { commonApiFetch } from "@/services/api/common-api"; import { useQuery } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -21,6 +22,9 @@ jest.mock("@tanstack/react-query", () => ({ useQuery: jest.fn(), keepPreviousData: jest.fn(), })); +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); jest.mock( "@/components/user/collected/filters/UserPageCollectedFilters", @@ -70,6 +74,7 @@ jest.mock( return (
+
); } @@ -624,6 +630,21 @@ describe("UserPageCollected", () => { expect(params.get("szn")).toBe("2"); }); + it("preserves the local collection and season when another filter changes before the url sync catches up", async () => { + const user = userEvent.setup(); + + renderWithTransferProvider(); + + await user.click(screen.getByTestId("stats-season-shortcut")); + await user.click(screen.getByTestId("filters-set-sort")); + + const params = getLastReplaceParams(); + + expect(params.get("collection")).toBe("memes"); + expect(params.get("szn")).toBe("2"); + expect(params.get("sort-by")).toBe("rank"); + }); + it("passes the optimistic season selection to the stats summary", async () => { const user = userEvent.setup(); diff --git a/components/user/collected/UserPageCollected.tsx b/components/user/collected/UserPageCollected.tsx index 4fe7f7a202..1a65997e09 100644 --- a/components/user/collected/UserPageCollected.tsx +++ b/components/user/collected/UserPageCollected.tsx @@ -62,14 +62,14 @@ interface QueryUpdateInput { } interface NormalizedCollectedQueryState { - readonly address: string | null; - readonly collection: CollectedCollectionType | null; - readonly subcollection: string | null; - readonly seized: CollectionSeized | null; - readonly sznId: number | null; - readonly page: number; - readonly sortBy: CollectionSort; - readonly sortDirection: SortDirection; + address: string | null; + collection: CollectedCollectionType | null; + subcollection: string | null; + seized: CollectionSeized | null; + sznId: number | null; + page: number; + sortBy: CollectionSort; + sortDirection: SortDirection; } const SEARCH_PARAMS_FIELDS = { @@ -171,42 +171,8 @@ const convertSortDirection = (sortDirection: string | null): SortDirection => { : DEFAULT_SORT_DIRECTION; }; -const getCanonicalCollectedQueryState = ( - queryParams: URLSearchParams -): NormalizedCollectedQueryState => { - const collection = convertCollection( - queryParams.get(SEARCH_PARAMS_FIELDS.collection) - ); - const pageParam = queryParams.get(SEARCH_PARAMS_FIELDS.page); - const parsedPage = pageParam ? Number.parseInt(pageParam, 10) : 1; - - return { - address: convertAddressToLowerCase( - queryParams.get(SEARCH_PARAMS_FIELDS.address) - ), - collection, - subcollection: - collection === CollectedCollectionType.NETWORK - ? queryParams.get(SEARCH_PARAMS_FIELDS.subcollection) - : null, - seized: convertSeized({ - seized: queryParams.get(SEARCH_PARAMS_FIELDS.seized), - collection, - }), - sznId: convertSznId({ - szn: queryParams.get(SEARCH_PARAMS_FIELDS.szn), - collection, - }), - page: Number.isNaN(parsedPage) ? 1 : parsedPage, - sortBy: convertSortedBy({ - sortBy: queryParams.get(SEARCH_PARAMS_FIELDS.sortBy), - collection, - }), - sortDirection: convertSortDirection( - queryParams.get(SEARCH_PARAMS_FIELDS.sortDirection) - ), - }; -}; +const normalizePageNumber = (page: number): number => + Number.isFinite(page) && page > 0 ? page : 1; const setCanonicalCollectedQueryParams = ({ normalizedParams, @@ -264,6 +230,102 @@ const setCanonicalCollectedQueryParams = ({ } }; +const getNormalizedCollectedQueryStateFromFilters = ( + filters: ProfileCollectedFilters +): NormalizedCollectedQueryState => ({ + address: filters.accountForConsolidations + ? null + : convertAddressToLowerCase(filters.handleOrWallet), + collection: filters.collection, + subcollection: + filters.collection === CollectedCollectionType.NETWORK + ? filters.subcollection + : null, + seized: convertSeized({ + seized: filters.seized, + collection: filters.collection, + }), + sznId: filters.szn?.id ?? filters.initialSznId, + page: normalizePageNumber(filters.page), + sortBy: convertSortedBy({ + sortBy: filters.sortBy, + collection: filters.collection, + }), + sortDirection: convertSortDirection(filters.sortDirection), +}); + +const applyQueryUpdateItemsToState = ({ + state, + updateItems, +}: { + readonly state: NormalizedCollectedQueryState; + readonly updateItems: QueryUpdateInput[]; +}): NormalizedCollectedQueryState => { + const nextState: NormalizedCollectedQueryState = { ...state }; + + for (const { name, value } of updateItems) { + switch (name) { + case "address": + nextState.address = convertAddressToLowerCase(value); + break; + case "collection": { + nextState.collection = convertCollection(value); + nextState.subcollection = + nextState.collection === CollectedCollectionType.NETWORK + ? nextState.subcollection + : null; + nextState.seized = convertSeized({ + seized: nextState.seized, + collection: nextState.collection, + }); + nextState.sznId = convertSznId({ + szn: nextState.sznId?.toString() ?? null, + collection: nextState.collection, + }); + nextState.sortBy = convertSortedBy({ + sortBy: nextState.sortBy, + collection: nextState.collection, + }); + break; + } + case "subcollection": + nextState.subcollection = + nextState.collection === CollectedCollectionType.NETWORK ? value : null; + break; + case "seized": + nextState.seized = convertSeized({ + seized: value, + collection: nextState.collection, + }); + break; + case "szn": + nextState.sznId = convertSznId({ + szn: value, + collection: nextState.collection, + }); + break; + case "page": { + const parsedPage = value ? Number.parseInt(value, 10) : 1; + nextState.page = normalizePageNumber(parsedPage); + break; + } + case "sortBy": + nextState.sortBy = convertSortedBy({ + sortBy: value, + collection: nextState.collection, + }); + break; + case "sortDirection": + nextState.sortDirection = convertSortDirection(value); + break; + default: + break; + } + } + + return nextState; +}; + export default function UserPageCollected({ profile, initialStatsData = EMPTY_USER_PAGE_STATS_INITIAL_DATA, @@ -313,7 +375,9 @@ export default function UserPageCollected({ szn: sznParam ?? null, collection: convertedCollection, }), - page: pageParam ? Number.parseInt(pageParam, 10) : 1, + page: normalizePageNumber( + pageParam ? Number.parseInt(pageParam, 10) : 1 + ), pageSize: PAGE_SIZE, sortBy: convertSortedBy({ sortBy: sortByParam ?? null, @@ -323,18 +387,16 @@ export default function UserPageCollected({ }; }, [searchParams, profile.handle, user]); + const [filters, setFilters] = useState(getFilters()); + const effectiveSeasonId = filters.szn?.id ?? filters.initialSznId; + const createQueryString = useCallback( (updateItems: QueryUpdateInput[]): string => { const queryParams = new URLSearchParams(searchParams.toString()); - for (const { name, value } of updateItems) { - const key = SEARCH_PARAMS_FIELDS[name]; - if (!value) { - queryParams.delete(key); - } else { - queryParams.set(key, value.toLowerCase()); - } - } - const state = getCanonicalCollectedQueryState(queryParams); + const state = applyQueryUpdateItemsToState({ + state: getNormalizedCollectedQueryStateFromFilters(filters), + updateItems, + }); const normalizedParams = new URLSearchParams(); const knownParamKeys = new Set( Object.values(SEARCH_PARAMS_FIELDS) @@ -349,7 +411,7 @@ export default function UserPageCollected({ return normalizedParams.toString(); }, - [searchParams] + [searchParams, filters] ); const updateFields = useCallback( @@ -365,9 +427,6 @@ export default function UserPageCollected({ [pathname, router, createQueryString] ); - const [filters, setFilters] = useState(getFilters()); - const effectiveSeasonId = filters.szn?.id ?? filters.initialSznId; - const { enabled: transferEnabled } = useTransfer(); const getCollectionSortUpdate = (