diff --git a/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx b/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx index caccd48396..31c9c43d0c 100644 --- a/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx +++ b/__tests__/components/user/brain/UserPageBrainWrapper.test.tsx @@ -52,7 +52,7 @@ describe("UserPageBrainWrapper", () => { }, identity: { id: "1" }, }); - expect(routerPush).toHaveBeenCalledWith("/alice/rep"); + expect(routerPush).toHaveBeenCalledWith("/alice/identity"); }); it("shows drops when waves enabled", () => { diff --git a/__tests__/components/user/identity/UserPageIdentity.test.tsx b/__tests__/components/user/identity/UserPageIdentity.test.tsx deleted file mode 100644 index 78ba1c3c6d..0000000000 --- a/__tests__/components/user/identity/UserPageIdentity.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import UserPageIdentity from '@/components/user/identity/UserPageIdentity'; - -let headerProps: any; -let statementsProps: any; -let tableParams: any[] = []; -let activityProps: any; - -jest.mock('@/components/user/identity/header/UserPageIdentityHeader', () => (props: any) => { headerProps = props; return
; }); -jest.mock('@/components/user/identity/statements/UserPageIdentityStatements', () => (props: any) => { statementsProps = props; return
; }); -jest.mock('@/components/user/utils/raters-table/wrapper/ProfileRatersTableWrapper', () => (props: any) => { tableParams.push(props.initialParams); return
; }); -jest.mock('@/components/user/identity/activity/UserPageIdentityActivityLog', () => (props: any) => { activityProps = props; return
; }); - -describe('UserPageIdentity', () => { - beforeEach(() => { headerProps = undefined; statementsProps = undefined; tableParams = []; activityProps = undefined; }); - it('passes props to children', () => { - const profile = { id: '1' } as any; - const params = { p: 1 } as any; - render( - - ); - expect(headerProps.profile).toBe(profile); - expect(statementsProps.profile).toBe(profile); - expect(tableParams).toEqual([params, params]); - expect(activityProps.initialActivityLogParams).toBe(params); - }); -}); diff --git a/__tests__/components/user/identity/UserPageIdentityWrapper.test.tsx b/__tests__/components/user/identity/UserPageIdentityWrapper.test.tsx deleted file mode 100644 index b4a6e27141..0000000000 --- a/__tests__/components/user/identity/UserPageIdentityWrapper.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import UserPageIdentityWrapper from "@/components/user/identity/UserPageIdentityWrapper"; -import { useIdentity } from "@/hooks/useIdentity"; -import { render } from "@testing-library/react"; -import { useParams, useRouter } from "next/navigation"; - -jest.mock("next/navigation", () => ({ - useRouter: jest.fn(), - useParams: jest.fn(), -})); -jest.mock("@/hooks/useIdentity", () => ({ useIdentity: jest.fn() })); - -let wrapperProfile: any; -let identityProps: any; - -jest.mock( - "@/components/user/utils/set-up-profile/UserPageSetUpProfileWrapper", - () => (props: any) => { - wrapperProfile = props.profile; - return
{props.children}
; - } -); -jest.mock("@/components/user/identity/UserPageIdentity", () => (props: any) => { - identityProps = props; - return
; -}); - -describe("UserPageIdentityWrapper", () => { - const routerMock = useRouter as jest.Mock; - const useIdentityMock = useIdentity as jest.Mock; - const useParamsMock = useParams as jest.Mock; - beforeEach(() => { - wrapperProfile = null; - identityProps = null; - }); - - it("uses profile from hook when available", () => { - useParamsMock.mockReturnValue({ user: "alice" }); - const profile: any = { handle: "alice" }; - useIdentityMock.mockReturnValue({ profile }); - render( - - ); - expect(useIdentityMock).toHaveBeenCalledWith({ - handleOrWallet: "alice", - initialProfile: profile, - }); - expect(wrapperProfile).toBe(profile); - expect(identityProps.profile).toBe(profile); - }); - - it("falls back to initial profile when hook returns null", () => { - routerMock.mockReturnValue({ query: { user: "bob" } }); - const profile: any = { handle: "bob" }; - useIdentityMock.mockReturnValue({ profile: null }); - render( - - ); - expect(wrapperProfile).toBe(profile); - expect(identityProps.profile).toBe(profile); - }); -}); diff --git a/__tests__/components/user/identity/activity/UserPageIdentityActivityLog.test.tsx b/__tests__/components/user/identity/activity/UserPageIdentityActivityLog.test.tsx deleted file mode 100644 index fea8d18c7d..0000000000 --- a/__tests__/components/user/identity/activity/UserPageIdentityActivityLog.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import UserPageIdentityActivityLog from '@/components/user/identity/activity/UserPageIdentityActivityLog'; - -jest.mock('@/components/profile-activity/ProfileActivityLogs', () => (props: any) => ( -
-)); - -jest.mock('@/components/profile-activity/ProfileName', () => ({ - __esModule: true, - ProfileNameType: { POSSESSION: 'POSSESSION', DEFAULT: 'DEFAULT' }, - default: (props: any) => {props.type}, -})); - -jest.mock('@/components/user/utils/UserTableHeaderWrapper', () => (props: any) => ( -
{props.children}
-)); - -describe('UserPageIdentityActivityLog', () => { - it('passes initial params to activity logs', () => { - const params = { limit: 5 } as any; - render(); - const activity = screen.getByTestId('activity'); - const props = JSON.parse(activity.getAttribute('data-props') || '{}'); - expect(props.initialParams).toEqual(params); - expect(props.withFilters).toBe(true); - expect(screen.getByText('NIC Activity Log')).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/user/identity/header/UserPageIdentityHeaderCIC.test.tsx b/__tests__/components/user/identity/header/UserPageIdentityHeaderCIC.test.tsx index edf88198ac..1d50ad37da 100644 --- a/__tests__/components/user/identity/header/UserPageIdentityHeaderCIC.test.tsx +++ b/__tests__/components/user/identity/header/UserPageIdentityHeaderCIC.test.tsx @@ -2,30 +2,41 @@ import { render, screen } from '@testing-library/react'; import UserPageIdentityHeaderCIC from '@/components/user/identity/header/UserPageIdentityHeaderCIC'; import type { ApiIdentity } from '@/generated/models/ApiIdentity'; +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(() => ({ data: { count: 2 } })), +})); + jest.mock('@/components/user/utils/user-cic-type/UserCICTypeIconWrapper', () => ({ __esModule: true, default: () =>
, })); +jest.mock('@/components/user/rep/header/TopRaterAvatars', () => ({ + __esModule: true, + default: () =>
, +})); + jest.mock('@/components/user/utils/user-cic-status/UserCICStatus', () => ({ __esModule: true, default: ({ cic }: { cic: number }) =>
{cic}
, })); describe('UserPageIdentityHeaderCIC', () => { - const baseProfile: ApiIdentity = { cic: 1000 } as ApiIdentity; + const baseProfile: ApiIdentity = { cic: 1000, handle: 'alice' } as ApiIdentity; it('displays NIC and status info', () => { render(); - expect(screen.getByText('NIC:')).toBeInTheDocument(); + expect(screen.getByText('NIC')).toBeInTheDocument(); expect(screen.getByText('1,000')).toBeInTheDocument(); + expect(screen.getByTestId('raters-avatars')).toBeInTheDocument(); + expect(screen.getByText('2 raters')).toBeInTheDocument(); expect(screen.getByTestId('icon')).toBeInTheDocument(); expect(screen.getByTestId('status')).toHaveTextContent('1000'); }); it('updates when profile prop changes', () => { const { rerender } = render(); - rerender(); + rerender(); expect(screen.getByText('2,000')).toBeInTheDocument(); expect(screen.getByTestId('status')).toHaveTextContent('2000'); }); diff --git a/__tests__/components/user/layout/UserPageTabs.test.tsx b/__tests__/components/user/layout/UserPageTabs.test.tsx index 66aa4cf7c1..215060112c 100644 --- a/__tests__/components/user/layout/UserPageTabs.test.tsx +++ b/__tests__/components/user/layout/UserPageTabs.test.tsx @@ -38,7 +38,7 @@ const renderTabs = ( country: string = "US" ) => { (useRouter as jest.Mock).mockReturnValue({ push: jest.fn() }); - (usePathname as jest.Mock).mockReturnValue("/[user]/rep"); + (usePathname as jest.Mock).mockReturnValue("/[user]/identity"); (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()); (useParams as jest.Mock).mockReturnValue({ user: "testuser" }); capacitorMock.mockReturnValue({ isIos }); diff --git a/__tests__/components/user/rep/UserPageRepActivityLog.test.tsx b/__tests__/components/user/rep/UserPageRepActivityLog.test.tsx deleted file mode 100644 index 068f67795b..0000000000 --- a/__tests__/components/user/rep/UserPageRepActivityLog.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import UserPageRepActivityLog from '@/components/user/rep/UserPageRepActivityLog'; - -jest.mock('@/components/profile-activity/ProfileActivityLogs', () => (props: any) => ( -
{JSON.stringify(props.initialParams)}
-)); -jest.mock('@/components/profile-activity/ProfileName', () => ({ - __esModule: true, - default: () => ProfileName, - ProfileNameType: { POSSESSION: 'POSSESSION' } -})); - -const params = { page: 1 } as any; - -describe('UserPageRepActivityLog', () => { - it('renders activity log component', () => { - render(); - expect(screen.getByText('Rep Activity Log')).toBeInTheDocument(); - expect(screen.getByTestId('logs')).toHaveTextContent('page'); - }); -}); diff --git a/__tests__/components/user/rep/header/UserPageRepHeader.test.tsx b/__tests__/components/user/rep/header/UserPageRepHeader.test.tsx index b210ae3927..9400b97d1c 100644 --- a/__tests__/components/user/rep/header/UserPageRepHeader.test.tsx +++ b/__tests__/components/user/rep/header/UserPageRepHeader.test.tsx @@ -1,19 +1,25 @@ import { render, screen } from '@testing-library/react'; import UserPageRepHeader from '@/components/user/rep/header/UserPageRepHeader'; +const mockProfile = { + handle: 'testuser', + display: 'Test User', + query: 'testuser', +} as any; + describe('UserPageRepHeader', () => { it('shows rep totals when provided', () => { const repRates = { total_rep_rating: 1500, number_of_raters: 25, + rating_stats: [], } as any; - render(); + render(); expect(screen.getByText('1,500')).toBeInTheDocument(); - expect(screen.getByText('25')).toBeInTheDocument(); }); - it('renders empty values without repRates', () => { - const { container } = render(); - expect(container).toHaveTextContent('Rep:'); + it('renders without repRates', () => { + const { container } = render(); + expect(container).toHaveTextContent('Reputation'); }); }); diff --git a/__tests__/components/user/rep/new-rep/UserPageRepNewRep.test.tsx b/__tests__/components/user/rep/new-rep/UserPageRepNewRep.test.tsx index 0551249d07..78e36e441d 100644 --- a/__tests__/components/user/rep/new-rep/UserPageRepNewRep.test.tsx +++ b/__tests__/components/user/rep/new-rep/UserPageRepNewRep.test.tsx @@ -1,13 +1,10 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import UserPageRepNewRep from '@/components/user/rep/new-rep/UserPageRepNewRep'; import type { ApiIdentity } from '@/generated/models/ApiIdentity'; jest.mock('@/components/user/rep/new-rep/UserPageRepNewRepSearch', () => ({ __esModule: true, - default: ({ onRepSearch }: { onRepSearch: (v: string) => void }) => ( - - ), + default: () =>
, })); jest.mock('@/components/user/rep/modify-rep/UserPageRepModifyModal', () => ({ @@ -19,9 +16,8 @@ describe('UserPageRepNewRep', () => { const profile = { id: '1' } as ApiIdentity; const repRates = { rating_stats: [] } as any; - it('opens modal when search selects rep', async () => { + it('renders search component', () => { render(); - await userEvent.click(screen.getByRole('button', { name: 'search' })); - expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('search')).toBeInTheDocument(); }); }); diff --git a/__tests__/components/user/rep/new-rep/UserPageRepNewRepSearch.test.tsx b/__tests__/components/user/rep/new-rep/UserPageRepNewRepSearch.test.tsx index 066e2d2883..40695d9e88 100644 --- a/__tests__/components/user/rep/new-rep/UserPageRepNewRepSearch.test.tsx +++ b/__tests__/components/user/rep/new-rep/UserPageRepNewRepSearch.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import UserPageRepNewRepSearch from "@/components/user/rep/new-rep/UserPageRepNewRepSearch"; import { useQuery } from "@tanstack/react-query"; -jest.mock("@tanstack/react-query", () => ({ useQuery: jest.fn() })); +jest.mock("@tanstack/react-query", () => ({ useQuery: jest.fn(), useMutation: jest.fn().mockReturnValue({ mutateAsync: jest.fn() }) })); jest.mock("@/components/user/rep/new-rep/UserPageRepNewRepSearchHeader", () => () =>
); jest.mock("@/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown", () => (props: any) => ( @@ -15,14 +15,14 @@ jest.mock("@/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown", () => )); jest.mock("@/components/user/rep/new-rep/UserPageRepNewRepError", () => () =>
); jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => () =>
); -jest.mock("services/api/common-api", () => ({ commonApiFetch: jest.fn() })); +jest.mock("services/api/common-api", () => ({ commonApiFetch: jest.fn(), commonApiPost: jest.fn() })); describe("UserPageRepNewRepSearch", () => { it("shows dropdown results and handles selection", async () => { (useQuery as jest.Mock).mockReturnValue({ isFetching: false, data: ["cat1"] }); const user = userEvent.setup(); render( - + ); await user.type(screen.getByPlaceholderText(/Category to grant rep/), "art"); await waitFor(() => screen.getByTestId("dropdown")); diff --git a/__tests__/components/user/user-page-header/stats/UserPageHeaderStats.test.tsx b/__tests__/components/user/user-page-header/stats/UserPageHeaderStats.test.tsx index ec69fbf2a5..fc96542ba9 100644 --- a/__tests__/components/user/user-page-header/stats/UserPageHeaderStats.test.tsx +++ b/__tests__/components/user/user-page-header/stats/UserPageHeaderStats.test.tsx @@ -28,7 +28,7 @@ describe('UserPageHeaderStats', () => { /> ); expect(screen.getByRole('link', { name: 'fmt-10 TDH' })).toHaveAttribute('href', '/bob/collected'); - expect(screen.getByRole('link', { name: 'fmt-20 Rep' })).toHaveAttribute('href', '/bob/rep'); + expect(screen.getByRole('link', { name: 'fmt-20 Rep' })).toHaveAttribute('href', '/bob/identity'); expect(screen.getByRole('link', { name: 'fmt-3 TDH Rate' })).toHaveAttribute( 'href', '/bob/stats?activity=tdh-history' diff --git a/__tests__/components/user/utils/rate/UserPageRateWrapper.test.tsx b/__tests__/components/user/utils/rate/UserPageRateWrapper.test.tsx index b71b61b0cd..3b50cc20d8 100644 --- a/__tests__/components/user/utils/rate/UserPageRateWrapper.test.tsx +++ b/__tests__/components/user/utils/rate/UserPageRateWrapper.test.tsx @@ -19,11 +19,19 @@ describe("UserPageRateWrapper", () => { wallets: [{ wallet: "0xabc" }], }; - function renderWrapper(ctx: any, seize: any, type = RateMatter.NIC) { + function renderWrapper( + ctx: any, + seize: any, + type = RateMatter.NIC, + options?: { hideOwnProfileMessage?: boolean } + ) { useCtx.mockReturnValue(seize); return render( - +
@@ -47,4 +55,33 @@ describe("UserPageRateWrapper", () => { ); expect(screen.getByTestId("child")).toBeInTheDocument(); }); + + it.each([RateMatter.REP, RateMatter.NIC])( + "shows own-profile message by default (%s)", + (type) => { + renderWrapper( + { connectedProfile: { handle: "alice" }, activeProfileProxy: undefined }, + { address: "0xabc" }, + type + ); + expect(screen.getByTestId("infobox")).toHaveTextContent( + "You can't" + ); + expect(screen.queryByTestId("child")).not.toBeInTheDocument(); + } + ); + + it.each([RateMatter.REP, RateMatter.NIC])( + "hides own-profile section when hideOwnProfileMessage is true (%s)", + (type) => { + renderWrapper( + { connectedProfile: { handle: "alice" }, activeProfileProxy: undefined }, + { address: "0xabc" }, + type, + { hideOwnProfileMessage: true } + ); + expect(screen.queryByTestId("infobox")).not.toBeInTheDocument(); + expect(screen.queryByTestId("child")).not.toBeInTheDocument(); + } + ); }); diff --git a/__tests__/components/user/utils/raters-table/ProfileRatersTableBody.test.tsx b/__tests__/components/user/utils/raters-table/ProfileRatersTableBody.test.tsx index e514fc088a..2e468a84ea 100644 --- a/__tests__/components/user/utils/raters-table/ProfileRatersTableBody.test.tsx +++ b/__tests__/components/user/utils/raters-table/ProfileRatersTableBody.test.tsx @@ -1,5 +1,4 @@ import ProfileRatersTableBody from "@/components/user/utils/raters-table/ProfileRatersTableBody"; -import { ProfileRatersTableType } from "@/types/enums"; import { render } from "@testing-library/react"; let itemProps: any[] = []; @@ -27,7 +26,6 @@ describe("ProfileRatersTableBody", () => {
); diff --git a/__tests__/components/user/utils/raters-table/ProfileRatersTableItem.test.tsx b/__tests__/components/user/utils/raters-table/ProfileRatersTableItem.test.tsx index e2adbaa6d8..70e8bf0904 100644 --- a/__tests__/components/user/utils/raters-table/ProfileRatersTableItem.test.tsx +++ b/__tests__/components/user/utils/raters-table/ProfileRatersTableItem.test.tsx @@ -1,5 +1,4 @@ import ProfileRatersTableItem from "@/components/user/utils/raters-table/ProfileRatersTableItem"; -import { ProfileRatersTableType } from "@/types/enums"; import { render, screen } from "@testing-library/react"; let cicProps: any; @@ -23,13 +22,12 @@ describe("ProfileRatersTableItem", () => { ); const link = screen.getByText("bob").closest("a")!; - expect(link).toHaveAttribute("href", "/bob/rep"); + expect(link).toHaveAttribute("href", "/bob/identity"); expect(screen.getByText("+5")).toBeInTheDocument(); expect(cicProps.level).toBe(3); }); @@ -41,7 +39,6 @@ describe("ProfileRatersTableItem", () => { diff --git a/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx b/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx index 6cf34caebb..27f2bd8307 100644 --- a/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx +++ b/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx @@ -167,7 +167,6 @@ const renderSection = (wave = baseWave) => onProfileStatementRemove: jest.fn(), onIdentityFollowChange: jest.fn(), initProfileRepPage: jest.fn(), - initProfileIdentityPage: jest.fn(), initCommunityActivityPage: jest.fn(), waitAndInvalidateDrops: jest.fn(), addOptimisticDrop: jest.fn(), diff --git a/__tests__/pages/userPageIdentity.test.tsx b/__tests__/pages/userPageIdentity.test.tsx deleted file mode 100644 index 7b4d15bae9..0000000000 --- a/__tests__/pages/userPageIdentity.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import React from "react"; - -// Mocks for helpers used by the factory -jest.mock("@/helpers/server.app.helpers", () => ({ - getAppCommonHeaders: jest.fn(async () => ({ "x-test": "1" })), -})); - -jest.mock("@/helpers/server.helpers", () => ({ - getUserProfile: jest.fn(async ({ user }: { user: string }) => ({ - handle: user, - walletAddress: "0xabc", - tdh: 0, - rep: 0, - level: 0, - })), - userPageNeedsRedirect: jest.fn(() => null), -})); - -jest.mock("@/components/providers/metadata", () => ({ - getAppMetadata: jest.fn((v: any) => v), -})); - -// Use real helpers but we'll spy on the metadata builder -import * as Helpers from "@/helpers/Helpers"; - -// Mock layout to avoid React Query provider requirements -jest.mock("@/components/user/layout/UserPageLayout", () => ({ - __esModule: true, - default: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); - -// Mock redirect from next/navigation so we can assert it -const redirectMock = jest.fn(); -jest.mock("next/navigation", () => ({ - redirect: (url: string) => redirectMock(url), -})); - -// Import after mocks -import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; -import { getAppMetadata } from "@/components/providers/metadata"; -import { - getUserProfile, - userPageNeedsRedirect, -} from "@/helpers/server.helpers"; - -// Dummy Identity tab -function DummyIdentityTab({ profile }: { readonly profile: any }) { - return
IDENTITY:{profile.handle}
; -} - -const buildFactory = () => - createUserTabPage({ - subroute: "identity", - metaLabel: "Identity", - Tab: DummyIdentityTab, - }); - -describe("identity page via createUserTabPage", () => { - beforeEach(() => { - redirectMock.mockClear(); - (userPageNeedsRedirect as jest.Mock).mockReturnValue(null); - }); - - it("renders layout + tab when no redirect needed", async () => { - const { Page } = buildFactory(); - - const element = await Page({ - params: Promise.resolve({ user: "Alice" }), - searchParams: Promise.resolve({ foo: "bar" }), - } as any); - - render(element); - - expect(await screen.findByTestId("identity")).toHaveTextContent( - "IDENTITY:alice" - ); - - expect(getUserProfile).toHaveBeenCalledWith({ - user: "alice", - headers: { "x-test": "1" }, - }); - expect(userPageNeedsRedirect).toHaveBeenCalledWith({ - profile: expect.any(Object), - req: { query: { user: "Alice", foo: "bar" } }, - subroute: "identity", - }); - expect(redirectMock).not.toHaveBeenCalled(); - }); - - it("redirects when userPageNeedsRedirect returns destination", async () => { - (userPageNeedsRedirect as jest.Mock).mockReturnValue({ - redirect: { destination: "/bob/identity" }, - }); - - const { Page } = buildFactory(); - - await Page({ - params: Promise.resolve({ user: "Carol" }), - searchParams: Promise.resolve({}), - } as any); - - expect(redirectMock).toHaveBeenCalledWith("/bob/identity"); - }); - - it("generateMetadata uses helpers", async () => { - const { generateMetadata } = buildFactory(); - const spy = jest.spyOn(Helpers, "getMetadataForUserPage"); - - const meta = await generateMetadata({ - params: Promise.resolve({ user: "Dave" }), - } as any); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ handle: "dave", walletAddress: "0xabc" }), - "Identity" - ); - expect(getAppMetadata).toHaveBeenCalled(); - expect(meta).toEqual( - expect.objectContaining({ title: expect.stringContaining("dave") }) - ); - }); -}); diff --git a/__tests__/pages/userPageRep.test.tsx b/__tests__/pages/userPageRep.test.tsx index a166674f16..d9f31a6cd7 100644 --- a/__tests__/pages/userPageRep.test.tsx +++ b/__tests__/pages/userPageRep.test.tsx @@ -52,7 +52,7 @@ function DummyRepTab({ profile }: { readonly profile: any }) { } const buildFactory = () => - createUserTabPage({ subroute: "rep", metaLabel: "Rep", Tab: DummyRepTab }); + createUserTabPage({ subroute: "identity", metaLabel: "Identity", Tab: DummyRepTab }); describe("rep page via createUserTabPage", () => { beforeEach(() => { @@ -79,14 +79,14 @@ describe("rep page via createUserTabPage", () => { expect(userPageNeedsRedirect).toHaveBeenCalledWith({ profile: expect.any(Object), req: { query: { user: "Alice", foo: "bar" } }, - subroute: "rep", + subroute: "identity", }); expect(redirectMock).not.toHaveBeenCalled(); }); it("redirects when userPageNeedsRedirect returns destination", async () => { (userPageNeedsRedirect as jest.Mock).mockReturnValue({ - redirect: { destination: "/bob/rep" }, + redirect: { destination: "/bob/identity" }, }); const { Page } = buildFactory(); @@ -96,7 +96,7 @@ describe("rep page via createUserTabPage", () => { searchParams: Promise.resolve({}), } as any); - expect(redirectMock).toHaveBeenCalledWith("/bob/rep"); + expect(redirectMock).toHaveBeenCalledWith("/bob/identity"); }); it("generateMetadata uses helpers", async () => { @@ -109,7 +109,7 @@ describe("rep page via createUserTabPage", () => { expect(spy).toHaveBeenCalledWith( expect.objectContaining({ handle: "dave", walletAddress: "0xabc" }), - "Rep" + "Identity" ); expect(getAppMetadata).toHaveBeenCalled(); expect(meta).toEqual( diff --git a/app/[user]/identity/page.tsx b/app/[user]/identity/page.tsx index b955d9743b..e7a45f1563 100644 --- a/app/[user]/identity/page.tsx +++ b/app/[user]/identity/page.tsx @@ -1,16 +1,19 @@ import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; -import UserPageIdentityWrapper from "@/components/user/identity/UserPageIdentityWrapper"; import { USER_PAGE_TAB_IDS, USER_PAGE_TAB_MAP, } from "@/components/user/layout/userTabs.config"; +import UserPageRepWrapper from "@/components/user/rep/UserPageRepWrapper"; +import type { ApiProfileRepRatesState } from "@/entities/IProfile"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { getProfileLogTypes } from "@/helpers/profile-logs.helpers"; -import { getInitialRatersParams } from "@/helpers/server.helpers"; -import { ProfileActivityFilterTargetType, RateMatter } from "@/types/enums"; +import { ProfileActivityFilterTargetType } from "@/types/enums"; -const MATTER_TYPE = RateMatter.NIC; +export interface UserPageRepPropsRepRates { + readonly ratings: ApiProfileRepRatesState; + readonly rater: string | null; +} const getInitialActivityLogParams = ( handleOrWallet: string @@ -26,45 +29,31 @@ const getInitialActivityLogParams = ( groupId: null, }); -function IdentityTab({ profile }: { readonly profile: ApiIdentity }) { +function RepTab({ profile }: { readonly profile: ApiIdentity }) { const handleOrWallet = ( profile.handle ?? profile.wallets?.[0]?.wallet ?? "" ).toLowerCase(); - const initialCICGivenParams = getInitialRatersParams({ - handleOrWallet, - matter: MATTER_TYPE, - given: false, - }); - - const initialCICReceivedParams = getInitialRatersParams({ - handleOrWallet, - matter: MATTER_TYPE, - given: true, - }); - const initialActivityLogParams = getInitialActivityLogParams(handleOrWallet); return (
-
); } -const TAB_CONFIG = USER_PAGE_TAB_MAP[USER_PAGE_TAB_IDS.IDENTITY]; +const TAB_CONFIG = USER_PAGE_TAB_MAP[USER_PAGE_TAB_IDS.REP]; const { Page, generateMetadata } = createUserTabPage({ subroute: TAB_CONFIG.route, metaLabel: TAB_CONFIG.metaLabel, - Tab: IdentityTab, + Tab: RepTab, }); export default Page; diff --git a/app/[user]/rep/page.tsx b/app/[user]/rep/page.tsx deleted file mode 100644 index ca06c160f5..0000000000 --- a/app/[user]/rep/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; -import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; -import { - USER_PAGE_TAB_IDS, - USER_PAGE_TAB_MAP, -} from "@/components/user/layout/userTabs.config"; -import UserPageRepWrapper from "@/components/user/rep/UserPageRepWrapper"; -import type { ApiProfileRepRatesState } from "@/entities/IProfile"; -import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { getProfileLogTypes } from "@/helpers/profile-logs.helpers"; -import { getInitialRatersParams } from "@/helpers/server.helpers"; -import { ProfileActivityFilterTargetType, RateMatter } from "@/types/enums"; - -export interface UserPageRepPropsRepRates { - readonly ratings: ApiProfileRepRatesState; - readonly rater: string | null; -} - -const MATTER_TYPE = RateMatter.REP; - -const getInitialActivityLogParams = ( - handleOrWallet: string -): ActivityLogParams => ({ - page: 1, - pageSize: 10, - logTypes: getProfileLogTypes({ - logTypes: [], - }), - matter: RateMatter.REP, - targetType: ProfileActivityFilterTargetType.ALL, - handleOrWallet, - groupId: null, -}); - -function RepTab({ profile }: { readonly profile: ApiIdentity }) { - const handleOrWallet = ( - profile.handle ?? - profile.wallets?.[0]?.wallet ?? - "" - ).toLowerCase(); - - const initialRepGivenParams = getInitialRatersParams({ - handleOrWallet, - matter: MATTER_TYPE, - given: false, - }); - - const initialRepReceivedParams = getInitialRatersParams({ - handleOrWallet, - matter: MATTER_TYPE, - given: true, - }); - const initialActivityLogParams = getInitialActivityLogParams(handleOrWallet); - - return ( -
- -
- ); -} - -const TAB_CONFIG = USER_PAGE_TAB_MAP[USER_PAGE_TAB_IDS.REP]; - -const { Page, generateMetadata } = createUserTabPage({ - subroute: TAB_CONFIG.route, - metaLabel: TAB_CONFIG.metaLabel, - Tab: RepTab, -}); - -export default Page; -export { generateMetadata }; diff --git a/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx b/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx index 28d1f8d51c..750f3dd26d 100644 --- a/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx +++ b/components/brain/notifications/identity-rating/NotificationIdentityRating.tsx @@ -41,7 +41,7 @@ export default function NotificationIdentityRating({ const myHandle = connectedProfile?.handle; const getProfileLink = (): string | null => { if (!myHandle) return null; - return isRep ? `/${myHandle}/rep` : `/${myHandle}/identity`; + return `/${myHandle}/identity`; }; const linkHref = getProfileLink(); diff --git a/components/common/OverlappingAvatars.tsx b/components/common/OverlappingAvatars.tsx index ea296b6fb3..9e4af2689d 100644 --- a/components/common/OverlappingAvatars.tsx +++ b/components/common/OverlappingAvatars.tsx @@ -45,7 +45,7 @@ export default function OverlappingAvatars({ const slice = items.slice(0, maxCount); const sizeClass = SIZE_CLASS[size]; const avatarRing = - "tw-rounded-md tw-bg-iron-700 tw-ring-[1.5px] tw-ring-black tw-object-cover"; + "tw-rounded-full tw-bg-iron-700 tw-ring-[1.5px] tw-ring-black tw-object-cover"; const wrapperHover = "tw-transition-transform tw-duration-200 tw-ease-out hover:tw-scale-110 hover:!tw-z-[100]"; @@ -65,10 +65,10 @@ export default function OverlappingAvatars({ alt={item.ariaLabel ?? "Profile"} loading="lazy" decoding="async" - className={`tw-h-full tw-w-full tw-flex-shrink-0 tw-rounded-md ${avatarRing}`} + className={`tw-h-full tw-w-full tw-flex-shrink-0 tw-rounded-full ${avatarRing}`} /> ) : ( -
+
{item.fallback ?? "?"} diff --git a/components/common/SystemAdjustmentPill.tsx b/components/common/SystemAdjustmentPill.tsx index d0740091f9..e6cd7bfed5 100644 --- a/components/common/SystemAdjustmentPill.tsx +++ b/components/common/SystemAdjustmentPill.tsx @@ -1,5 +1,5 @@ export const SystemAdjustmentPill: React.FC = () => ( - + ( System Adjustment -); \ No newline at end of file +); diff --git a/components/header/header-search/HeaderSearchModal.tsx b/components/header/header-search/HeaderSearchModal.tsx index d79638359e..60f7da772c 100644 --- a/components/header/header-search/HeaderSearchModal.tsx +++ b/components/header/header-search/HeaderSearchModal.tsx @@ -577,7 +577,7 @@ export default function HeaderSearchModal({ const path = getProfileTargetRoute({ handleOrWallet, pathname: pathname ?? "", - defaultPath: USER_PAGE_TAB_IDS.IDENTITY, + defaultPath: USER_PAGE_TAB_IDS.REP, }); router.push(path); onClose(); diff --git a/components/header/header-search/HeaderSearchModalItem.tsx b/components/header/header-search/HeaderSearchModalItem.tsx index 4e1179489f..45afd184e4 100644 --- a/components/header/header-search/HeaderSearchModalItem.tsx +++ b/components/header/header-search/HeaderSearchModalItem.tsx @@ -158,7 +158,7 @@ export default function HeaderSearchModalItem({ return getProfileTargetRoute({ handleOrWallet: profile.handle ?? profile.wallet.toLowerCase(), pathname: pathname ?? "", - defaultPath: USER_PAGE_TAB_IDS.IDENTITY, + defaultPath: USER_PAGE_TAB_IDS.REP, }); } else if (isNft()) { const nft = getNft(); diff --git a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx index 2ec23c87c6..83d41bd099 100644 --- a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx +++ b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx @@ -18,6 +18,7 @@ export default function MobileWrapperDialog({ noPadding, tall, fixedHeight, + tabletModal, }: { readonly title?: string | undefined; readonly isOpen: boolean; @@ -28,6 +29,7 @@ export default function MobileWrapperDialog({ readonly noPadding?: boolean | undefined; readonly tall?: boolean | undefined; readonly fixedHeight?: boolean | undefined; + readonly tabletModal?: boolean | undefined; }) { const { isCapacitor, isIos } = useCapacitor(); @@ -43,8 +45,14 @@ export default function MobileWrapperDialog({ return `calc(${viewportHeight} - 10rem)`; }; - const panelClassNames = `mobile-wrapper-dialog tw-pointer-events-auto tw-relative tw-w-screen md:tw-max-w-screen-md${ - isIos ? "" : " tw-transform-gpu tw-will-change-transform" + const panelClassNames = `mobile-wrapper-dialog tw-pointer-events-auto tw-relative tw-w-screen${ + tabletModal ? "" : " md:tw-max-w-screen-md" + }${isIos ? "" : " tw-transform-gpu tw-will-change-transform"}${ + tabletModal ? " md:tw-w-full md:tw-max-w-md" : "" + }`; + + const containerClassNames = `tw-pointer-events-none tw-fixed tw-inset-x-0 tw-bottom-0 tw-flex tw-max-w-full tw-justify-center tw-pt-10${ + tabletModal ? " md:tw-inset-0 md:tw-items-center md:tw-pt-0 md:tw-p-6" : "" }`; return ( @@ -79,15 +87,23 @@ export default function MobileWrapperDialog({ className="tw-absolute tw-inset-0 tw-overflow-hidden" onClick={(e) => e.stopPropagation()} > -
+
-
+
{!isTouchScreen && ( - Copy + positionStrategy="fixed" + offset={8} + opacity={1} + style={TOOLTIP_STYLES} + > + Copy )} diff --git a/components/profile-activity/list/items/utils/ProfileActivityLogItemWrapper.tsx b/components/profile-activity/list/items/utils/ProfileActivityLogItemWrapper.tsx index b9f7b5b7f8..bb3c9496da 100644 --- a/components/profile-activity/list/items/utils/ProfileActivityLogItemWrapper.tsx +++ b/components/profile-activity/list/items/utils/ProfileActivityLogItemWrapper.tsx @@ -1,7 +1,11 @@ +"use client"; + import { USER_PAGE_TAB_IDS } from "@/components/user/layout/userTabs.config"; import CommonProfileLink from "@/components/user/utils/CommonProfileLink"; import type { ProfileActivityLog } from "@/entities/IProfile"; -import { ProfileActivityLogType, RateMatter } from "@/types/enums"; +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import { useIdentity } from "@/hooks/useIdentity"; +import { ProfileActivityLogType } from "@/types/enums"; import ProfileActivityLogItemTimeAgo from "./ProfileActivityLogItemTimeAgo"; export default function ProfileActivityLogItemWrapper({ @@ -27,29 +31,45 @@ export default function ProfileActivityLogItemWrapper({ const isCurrentUser = user?.toLowerCase() === handleOrWallet.toLowerCase() || !user; - const tabTarget = - log.type === ProfileActivityLogType.RATING_EDIT && - log.contents.rating_matter === RateMatter.REP - ? USER_PAGE_TAB_IDS.REP - : USER_PAGE_TAB_IDS.IDENTITY; + const tabTarget = USER_PAGE_TAB_IDS.REP; + + const { profile } = useIdentity({ + handleOrWallet: isArchived ? null : handleOrWallet, + initialProfile: null, + }); + + const pfp = profile?.pfp ?? null; return ( - - - - {!isArchived && ( - - )} - {children} - - - - - - +
+
+
+ {!isArchived && + (pfp ? ( + {handleOrWallet} + ) : ( +
+ ))} +
+ {!isArchived && ( + + )} + {children} +
+
+
+ +
+
+
); } diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 5784069800..233d7e4d38 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import type { UserPageRepPropsRepRates } from "@/app/[user]/rep/page"; +import type { UserPageRepPropsRepRates } from "@/app/[user]/identity/page"; import type { ApiProfileRepRatesState, ProfileActivityLog, @@ -127,13 +127,6 @@ interface InitProfileRepPageParams { readonly handleOrWallet: string; } -interface InitProfileIdentityPageParams { - readonly profile: ApiIdentity; - readonly activityLogs: InitProfileActivityLogsParams; - readonly cicGivenToUsers: InitProfileRatersParamsAndData; - readonly cicReceivedFromUsers: InitProfileRatersParamsAndData; -} - type ReactQueryWrapperContextType = { readonly setProfile: (profile: ApiIdentity) => void; readonly setWave: (wave: ApiWave) => void; @@ -174,7 +167,6 @@ type ReactQueryWrapperContextType = { onProfileStatementRemove: (params: { profile: ApiIdentity }) => void; onIdentityFollowChange: () => void; initProfileRepPage: (params: InitProfileRepPageParams) => void; - initProfileIdentityPage: (params: InitProfileIdentityPageParams) => void; initCommunityActivityPage: ({ activityLogs, }: { @@ -212,7 +204,6 @@ export const ReactQueryWrapperContext = onProfileStatementRemove: () => {}, onIdentityFollowChange: () => {}, initProfileRepPage: () => {}, - initProfileIdentityPage: () => {}, initCommunityActivityPage: () => {}, waitAndInvalidateDrops: async () => {}, addOptimisticDrop: async () => {}, @@ -767,22 +758,6 @@ const createReactQueryContextValue = ( setProfileRaters(repReceivedFromUsers); }; - const initProfileIdentityPage = ({ - profile, - activityLogs, - cicGivenToUsers, - cicReceivedFromUsers, - }: InitProfileIdentityPageParams) => { - setProfile(profile); - initProfileActivityLogs({ - params: activityLogs.params, - data: activityLogs.data, - disableActiveGroup: true, - }); - setProfileRaters(cicGivenToUsers); - setProfileRaters(cicReceivedFromUsers); - }; - const initCommunityActivityPage = ({ activityLogs, }: { @@ -992,7 +967,6 @@ const createReactQueryContextValue = ( onProfileStatementAdd, onProfileStatementRemove, initProfileRepPage, - initProfileIdentityPage, initCommunityActivityPage, onGroupRemoved, onGroupChanged, diff --git a/components/user/brain/UserPageBrainWrapper.tsx b/components/user/brain/UserPageBrainWrapper.tsx index 4908a04e34..f708f18155 100644 --- a/components/user/brain/UserPageBrainWrapper.tsx +++ b/components/user/brain/UserPageBrainWrapper.tsx @@ -26,7 +26,7 @@ export default function UserPageBrainWrapper({ return; } if (connectedProfile || !address) { - router.push(`/${user}/rep`); + router.push(`/${user}/identity`); } }, [connectedProfile, activeProfileProxy, address, showWaves]); diff --git a/components/user/identity/UserPageIdentity.tsx b/components/user/identity/UserPageIdentity.tsx deleted file mode 100644 index 7d9a61c160..0000000000 --- a/components/user/identity/UserPageIdentity.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import UserPageIdentityStatements from "./statements/UserPageIdentityStatements"; -import UserPageIdentityHeader from "./header/UserPageIdentityHeader"; -import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; -import type { - ProfileRatersParams, -} from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; -import ProfileRatersTableWrapper from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; -import UserPageIdentityActivityLog from "./activity/UserPageIdentityActivityLog"; - -export default function UserPageIdentity({ - profile, - initialCICReceivedParams, - initialCICGivenParams, - initialActivityLogParams, -}: { - readonly profile: ApiIdentity; - readonly initialCICReceivedParams: ProfileRatersParams; - readonly initialCICGivenParams: ProfileRatersParams; - readonly initialActivityLogParams: ActivityLogParams; -}) { - return ( -
- - - -
- - -
- -
- -
-
- ); -} diff --git a/components/user/identity/UserPageIdentityWrapper.tsx b/components/user/identity/UserPageIdentityWrapper.tsx deleted file mode 100644 index 341dc25231..0000000000 --- a/components/user/identity/UserPageIdentityWrapper.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; -import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { useIdentity } from "@/hooks/useIdentity"; -import { useParams } from "next/navigation"; -import type { ProfileRatersParams } from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; -import UserPageSetUpProfileWrapper from "../utils/set-up-profile/UserPageSetUpProfileWrapper"; -import UserPageIdentity from "./UserPageIdentity"; - -export default function UserPageIdentityWrapper({ - profile: initialProfile, - initialCICReceivedParams, - initialCICGivenParams, - initialActivityLogParams, -}: { - readonly profile: ApiIdentity; - readonly initialCICReceivedParams: ProfileRatersParams; - readonly initialCICGivenParams: ProfileRatersParams; - readonly initialActivityLogParams: ActivityLogParams; -}) { - const params = useParams(); - const user = (params?.["user"] as string)?.toLowerCase(); - - const { profile } = useIdentity({ - handleOrWallet: user, - initialProfile: initialProfile, - }); - - return ( - - - - ); -} diff --git a/components/user/identity/activity/UserPageIdentityActivityLog.tsx b/components/user/identity/activity/UserPageIdentityActivityLog.tsx deleted file mode 100644 index a3bb78fdb4..0000000000 --- a/components/user/identity/activity/UserPageIdentityActivityLog.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { - ActivityLogParams, -} from "@/components/profile-activity/ProfileActivityLogs"; -import ProfileActivityLogs from "@/components/profile-activity/ProfileActivityLogs"; -import ProfileName, { - ProfileNameType, -} from "@/components/profile-activity/ProfileName"; -import UserTableHeaderWrapper from "@/components/user/utils/UserTableHeaderWrapper"; - -export default function UserPageIdentityActivityLog({ - initialActivityLogParams, -}: { - readonly initialActivityLogParams: ActivityLogParams; -}) { - return ( -
- - - - {" "} - NIC Activity Log - -
- -
-
- ); -} diff --git a/components/user/identity/header/UserPageIdentityHeader.tsx b/components/user/identity/header/UserPageIdentityHeader.tsx index 4ff6609af0..07c168b337 100644 --- a/components/user/identity/header/UserPageIdentityHeader.tsx +++ b/components/user/identity/header/UserPageIdentityHeader.tsx @@ -1,7 +1,4 @@ -import UserPageRateWrapper from "@/components/user/utils/rate/UserPageRateWrapper"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { RateMatter } from "@/types/enums"; -import UserPageIdentityHeaderCICRate from "./cic-rate/UserPageIdentityHeaderCICRate"; import UserPageIdentityHeaderCIC from "./UserPageIdentityHeaderCIC"; export default function UserPageIdentityHeader({ @@ -10,27 +7,16 @@ export default function UserPageIdentityHeader({ readonly profile: ApiIdentity; }) { return ( -
-
-
-
-

- Network Identity Check (NIC) -

-

- Does the network believe this profile accurately represents its - identity? -

-
- - - - -
+
+
+

+ Network Identity Check (NIC) +

+

+ Does the network believe this profile accurately represents its identity? +

+
); } diff --git a/components/user/identity/header/UserPageIdentityHeaderCIC.tsx b/components/user/identity/header/UserPageIdentityHeaderCIC.tsx index 1a114ba880..d1cb7853ef 100644 --- a/components/user/identity/header/UserPageIdentityHeaderCIC.tsx +++ b/components/user/identity/header/UserPageIdentityHeaderCIC.tsx @@ -1,10 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { RatingWithProfileInfoAndLevel } from "@/entities/IProfile"; +import { SortDirection } from "@/entities/ISort"; +import UserCICStatus from "@/components/user/utils/user-cic-status/UserCICStatus"; +import UserCICTypeIconWrapper from "@/components/user/utils/user-cic-type/UserCICTypeIconWrapper"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { formatNumberWithCommas } from "@/helpers/Helpers"; -import UserCICTypeIconWrapper from "@/components/user/utils/user-cic-type/UserCICTypeIconWrapper"; -import UserCICStatus from "@/components/user/utils/user-cic-status/UserCICStatus"; +import type { Page } from "@/helpers/Types"; +import { commonApiFetch } from "@/services/api/common-api"; +import { ProfileRatersParamsOrderBy, RateMatter } from "@/types/enums"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import TopRaterAvatars from "../../rep/header/TopRaterAvatars"; + +const TOP_RATERS_COUNT = 5; export default function UserPageIdentityHeaderCIC({ profile, @@ -13,26 +23,66 @@ export default function UserPageIdentityHeaderCIC({ }) { const [cicRating, setCicRating] = useState(profile.cic); + const { data: ratings } = useQuery>({ + queryKey: [ + QueryKey.PROFILE_RATERS, + { + handleOrWallet: profile.handle, + matter: RateMatter.NIC, + page: 1, + pageSize: 1, + order: SortDirection.DESC, + orderBy: ProfileRatersParamsOrderBy.RATING, + given: false, + }, + ], + queryFn: async () => + await commonApiFetch>({ + endpoint: `profiles/${profile.handle}/cic/ratings/by-rater`, + params: { + page: `${1}`, + page_size: `${1}`, + order: SortDirection.DESC.toLowerCase(), + order_by: ProfileRatersParamsOrderBy.RATING.toLowerCase(), + given: "false", + }, + }), + enabled: !!profile.handle, + }); + useEffect(() => { setCicRating(profile.cic); }, [profile]); return ( -
-
-
- NIC: - +
+
+ NIC +
+
+
+
{formatNumberWithCommas(cicRating)} +
+
+ + + + +
+
+
+ + + {formatNumberWithCommas(ratings?.count ?? 0)}{" "} + {(ratings?.count ?? 0) === 1 ? "rater" : "raters"}
- - - -
-
- Status: -
); diff --git a/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRate.tsx b/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRate.tsx index fa70e70829..36a9d265aa 100644 --- a/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRate.tsx +++ b/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRate.tsx @@ -1,6 +1,6 @@ "use client"; -import type { FormEvent} from "react"; +import type { FormEvent } from "react"; import { useContext, useEffect, useState } from "react"; import type { ApiProfileRaterCicState } from "@/entities/IProfile"; import { getStringAsNumberOrZero } from "@/helpers/Helpers"; @@ -14,22 +14,35 @@ import { QueryKey, ReactQueryWrapperContext, } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { createBreakpoint } from "react-use"; + import UserRateAdjustmentHelper from "@/components/user/utils/rate/UserRateAdjustmentHelper"; +import UserPageRateInput from "@/components/user/utils/rate/UserPageRateInput"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; import UserPageIdentityHeaderCICRateStats from "./UserPageIdentityHeaderCICRateStats"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -const useBreakpoint = createBreakpoint({ MD: 768, S: 0 }); +const CIC_SPAN_CLASS_NAME = + "tw-flex tw-flex-col tw-items-center tw-justify-center tw-bg-black/40 tw-rounded-l-lg tw-border tw-border-solid tw-border-white/[0.15] tw-px-3"; + +const CIC_FOCUS_RING_CLASS_NAME = + "focus:tw-border-emerald-500 focus:tw-ring-1 focus:tw-ring-emerald-500/30"; + +const CIC_INPUT_TOOLTIP_CLASS_NAME = + "tw-max-w-[12rem] -tw-ml-0.5 tw-appearance-none tw-block tw-rounded-l-none tw-rounded-r-lg tw-border tw-border-solid tw-border-white/[0.15] tw-py-3 tw-px-3 tw-bg-black/40 focus:tw-bg-black/60 tw-text-white tw-font-semibold tw-caret-emerald-400 tw-shadow-inner hover:tw-border-white/30 placeholder:tw-text-iron-500 focus:tw-outline-none tw-text-base sm:tw-text-sm tw-transition tw-duration-300 tw-ease-out"; + +const CIC_INPUT_FULL_CLASS_NAME = + "tw-w-full -tw-ml-0.5 tw-appearance-none tw-block tw-rounded-l-none tw-rounded-r-lg tw-border tw-border-solid tw-border-white/[0.15] tw-py-3.5 tw-px-4 tw-bg-black/40 focus:tw-bg-black/60 tw-text-white tw-font-semibold tw-caret-emerald-400 tw-shadow-inner hover:tw-border-white/30 placeholder:tw-text-iron-500 focus:tw-outline-none tw-text-base sm:tw-text-sm tw-transition tw-duration-300 tw-ease-out"; export default function UserPageIdentityHeaderCICRate({ profile, isTooltip, + onSuccess, }: { readonly profile: ApiIdentity; readonly isTooltip: boolean; + readonly onSuccess?: () => void; }) { const { address } = useSeizeConnectContext(); const { requestAuth, setToast, connectedProfile, activeProfileProxy } = @@ -81,6 +94,7 @@ export default function UserPageIdentityHeaderCICRate({ null, profileProxy: activeProfileProxy ?? null, }); + onSuccess?.(); }, onError: (error) => { setToast({ @@ -169,98 +183,18 @@ export default function UserPageIdentityHeaderCICRate({ `${originalRating}` ); - const getValueStr = (strCIC: string): string => { - if (strCIC.length > 1 && strCIC.startsWith("0")) { - return strCIC.slice(1); - } - return strCIC; - }; - useEffect(() => { setOriginalRating(currentCICState?.cic_rating_by_rater ?? 0); setAdjustedRatingStr(`${currentCICState?.cic_rating_by_rater ?? 0}`); }, [currentCICState]); - const onValueChange = (e: FormEvent) => { - const inputValue = e.currentTarget.value; - const strCIC = ["-0", "0-"].includes(inputValue) ? "-" : inputValue; - if (/^-?\d*$/.test(strCIC)) { - const newCicValue = getValueStr(strCIC); - setAdjustedRatingStr(newCicValue); - } - }; - - const adjustStrValueToMinMax = (): void => { - if (activeProfileProxy) { - return; - } - const { min, max } = getMinMaxValues(); - const valueAsNumber = getStringAsNumberOrZero(adjustedRatingStr); - if (valueAsNumber > max) { - setAdjustedRatingStr(`${max}`); - return; - } - - if (valueAsNumber < min) { - setAdjustedRatingStr(`${min}`); - } - }; - - const getIsValidValue = (): boolean => { - if (activeProfileProxy) { - return true; - } - const { min, max } = minMaxValues; - const valueAsNumber = getStringAsNumberOrZero(adjustedRatingStr); - if (valueAsNumber > max) { - return false; - } - - if (valueAsNumber < min) { - return false; - } - return true; - }; - - const [isValidValue, setIsValidValue] = useState(getIsValidValue()); - - useEffect(() => setIsValidValue(getIsValidValue()), [adjustedRatingStr]); - - const [newRating, setNewRating] = useState( - getStringAsNumberOrZero(adjustedRatingStr) - ); - - useEffect(() => { - setNewRating(getStringAsNumberOrZero(adjustedRatingStr)); - }, [adjustedRatingStr]); - - const [haveChanged, setHaveChanged] = useState( - newRating !== originalRating - ); - - useEffect(() => { - setHaveChanged(newRating !== originalRating); - }, [newRating, originalRating]); - - const getIsSaveDisabled = (): boolean => { - if (!haveChanged) { - return true; - } - - if (!isValidValue) { - return true; - } - - return false; - }; - - const [isSaveDisabled, setIsSaveDisabled] = useState( - getIsSaveDisabled() - ); - - useEffect(() => { - setIsSaveDisabled(getIsSaveDisabled()); - }, [haveChanged, isValidValue]); + const newRating = getStringAsNumberOrZero(adjustedRatingStr); + const haveChanged = newRating !== originalRating; + const isProxy = !!activeProfileProxy; + const isValidValue = + isProxy || + (newRating >= minMaxValues.min && newRating <= minMaxValues.max); + const isSaveDisabled = !haveChanged || !isValidValue; const onSave = async () => { const { success } = await requestAuth(); @@ -283,15 +217,88 @@ export default function UserPageIdentityHeaderCICRate({ await onSave(); }; - const breakpoint = useBreakpoint(); + const rateInput = ( +
+ +
+ ); + + const tooltipButtonContent = mutating ? ( +
+ +
+ ) : ( + <>Rate + ); + + const fullButtonContent = mutating ? ( +
+
+ +
+
+ ) : ( + <>Rate + ); + + const adjustmentHelper = ( + + ); return (
+ {!isTooltip && ( +
+ + + Rate NIC + +
+ )}
-
-
+ className="tw-mt-4"> + {isTooltip ? ( + <> +
+
+ + {rateInput} +
+
+
+ +
+
+
+ {adjustmentHelper} + + ) : ( + <> -
- - - - - - - - - -
-
- -
-
- - {!isTooltip && breakpoint === "MD" && ( - - )} -
-
-
- {!!(isTooltip || breakpoint !== "MD") && ( - + {rateInput} + + {adjustmentHelper} + + + )}
diff --git a/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRateStats.tsx b/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRateStats.tsx index f8d54bfcda..0339ba4959 100644 --- a/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRateStats.tsx +++ b/components/user/identity/header/cic-rate/UserPageIdentityHeaderCICRateStats.tsx @@ -1,11 +1,11 @@ "use client"; -import { useContext, useEffect, useState } from "react"; import { AuthContext } from "@/components/auth/Auth"; -import Link from "next/link"; -import { formatNumberWithCommas } from "@/helpers/Helpers"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; +import { formatNumberWithCommas } from "@/helpers/Helpers"; +import Link from "next/link"; +import { useContext, useEffect, useState } from "react"; export default function UserPageIdentityHeaderCICRateStats({ isTooltip, @@ -54,37 +54,79 @@ export default function UserPageIdentityHeaderCICRateStats({ () => setAvailableCredit(getAvailableCredit()), [heroAvailableCredit, proxyAvailableCredit] ); - return ( -
- {!!activeProfileProxy && ( - - You are acting as proxy for: - - - {activeProfileProxy.created_by.handle} - + + const proxyItem = activeProfileProxy + ? { + label: "Proxy for", + value: ( + + {activeProfileProxy.created_by.handle} + + ), + valueColorClassName: "tw-text-emerald-400", + labelBreakAll: false, + } + : null; + + const creditItem = activeProfileProxy + ? null + : { + label: "Your available NIC:", + value: formatNumberWithCommas(availableCredit), + valueColorClassName: "tw-text-iron-300", + labelBreakAll: false, + }; + + const minMaxItem = activeProfileProxy + ? null + : { + label: `Your max/min NIC Rating to ${profile.handle}:`, + value: `+/- ${formatNumberWithCommas(minMaxValues.max)}`, + valueColorClassName: "tw-text-iron-300", + labelBreakAll: true, + }; + + const items = [proxyItem, creditItem, minMaxItem].filter( + (item): item is NonNullable => item !== null + ); + + if (isTooltip) { + return ( +
+ {items.map((item) => ( + + + {item.label.endsWith(":") ? item.label : `${item.label}:`} + + + {item.value} + - - )} - {!activeProfileProxy && ( - - Your available NIC: - - {formatNumberWithCommas(availableCredit)} + ))} +
+ ); + } + + return ( +
+ {items.map((item) => ( +
+ + {item.label} - - )} - {!activeProfileProxy && ( - - Your max/min NIC Rating to {profile.handle}: - - +/- {formatNumberWithCommas(minMaxValues.max)} + + {item.value} - - )} +
+ ))}
); } diff --git a/components/user/identity/statements/UserPageIdentityStatements.tsx b/components/user/identity/statements/UserPageIdentityStatements.tsx index 52daca8d21..d6899670b7 100644 --- a/components/user/identity/statements/UserPageIdentityStatements.tsx +++ b/components/user/identity/statements/UserPageIdentityStatements.tsx @@ -3,6 +3,7 @@ import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { CicStatement } from "@/entities/IProfile"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { STATEMENT_GROUP } from "@/helpers/Types"; import { commonApiFetch } from "@/services/api/common-api"; import { useQuery } from "@tanstack/react-query"; @@ -84,26 +85,28 @@ export default function UserPageIdentityStatements({ }, [statements]); return ( -
+
- -
-
+ +
+
-
-
+
+
-
+
-
+
-
+
-
+
-
- <> -
- -
- -
    -
  • All statements are optional.
  • -
  • - All statements are fully and permanently public. -
  • -
  • - Seize does not connect to social media accounts or - verify posts. -
  • -
  • - The community will rate the accuracy of statements. -
  • -
-
- -
+ +
    +
  • All statements are optional.
  • +
  • All statements are fully and permanently public.
  • +
  • + Seize does not connect to social media accounts or verify posts. +
  • +
  • The community will rate the accuracy of statements.
  • +
+
); diff --git a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddresses.tsx b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddresses.tsx index ba4227fa51..c9c4520595 100644 --- a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddresses.tsx +++ b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddresses.tsx @@ -1,20 +1,20 @@ "use client"; -import { useContext, useEffect, useState } from "react"; import type { WalletConsolidationState } from "@/entities/IProfile"; -import EthereumIcon from "@/components/user/utils/icons/EthereumIcon"; -import UserPageIdentityStatementsConsolidatedAddressesItem from "./UserPageIdentityStatementsConsolidatedAddressesItem"; -import { amIUser } from "@/helpers/Helpers"; -import { commonApiFetch } from "@/services/api/common-api"; -import { useQueries } from "@tanstack/react-query"; -import type { Page } from "@/helpers/Types"; -import Link from "next/link"; -import { AnimatePresence } from "framer-motion"; +import { useContext, useEffect, useState } from "react"; + import { AuthContext } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import type { ApiWallet } from "@/generated/models/ApiWallet"; +import { amIUser } from "@/helpers/Helpers"; +import type { Page } from "@/helpers/Types"; +import { commonApiFetch } from "@/services/api/common-api"; +import { useQueries } from "@tanstack/react-query"; +import { AnimatePresence } from "framer-motion"; +import Link from "next/link"; +import UserPageIdentityStatementsConsolidatedAddressesItem from "./UserPageIdentityStatementsConsolidatedAddressesItem"; export default function UserPageIdentityStatementsConsolidatedAddresses({ profile, }: { @@ -119,18 +119,34 @@ export default function UserPageIdentityStatementsConsolidatedAddresses({ return (
-
-
-
- -
-
- +
+ Consolidated Addresses +
-
-
    +
      {sortedByPrimary.map((wallet) => ( + className="tw-relative tw-inline-flex tw-items-center tw-rounded-lg tw-border tw-border-solid tw-border-white/10 tw-bg-white/[0.04] tw-px-2.5 tw-py-2 tw-text-xs tw-font-medium tw-text-iron-200 tw-no-underline tw-ring-0 tw-transition tw-duration-300 tw-ease-out hover:tw-bg-white/[0.08] hover:tw-text-iron-200 focus:tw-z-10 focus:tw-outline-none" + > Wallet Checker {showDelegationCenter && ( + className="tw-relative tw-inline-flex tw-items-center tw-rounded-lg tw-border tw-border-solid tw-border-white/10 tw-bg-white/[0.04] tw-px-2.5 tw-py-2 tw-text-xs tw-font-medium tw-text-iron-200 tw-no-underline tw-ring-0 tw-transition tw-duration-300 tw-ease-out hover:tw-bg-white/[0.08] hover:tw-text-iron-200 focus:tw-z-10 focus:tw-outline-none" + > Delegation Center )} diff --git a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItem.tsx b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItem.tsx index dc77555de1..401683cd74 100644 --- a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItem.tsx +++ b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItem.tsx @@ -13,6 +13,7 @@ import { } from "@/constants/constants"; import type { ApiWallet } from "@/generated/models/ApiWallet"; import { getTransactionLink } from "@/helpers/Helpers"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { useEffect, useState } from "react"; import { Tooltip } from "react-tooltip"; import { useCopyToClipboard } from "react-use"; @@ -142,118 +143,97 @@ export default function UserPageIdentityStatementsConsolidatedAddressesItem({ return (
    • -
      - - {!isTouchScreen && ( - - )} - - {!isTouchScreen && ( - - )} -
      -
      - +
      +
      +
      + {title === "Copied!" ? ( {title} ) : ( title )} - {address.display && ( - {address.display} - )} -
      -
      - - - {!isTouchScreen && ( - - )}
      + {address.display && ( +
      + {address.display} +
      + )} +
      +
      + + {!isTouchScreen && ( + + )} + + {!isTouchScreen && ( + + )} + + {!isTouchScreen && ( + + )}
      {statusMessage && ( -
      +
      {statusMessage}
      )} diff --git a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItemPrimary.tsx b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItemPrimary.tsx index 0e6fabec38..e4b994521c 100644 --- a/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItemPrimary.tsx +++ b/components/user/identity/statements/consolidated-addresses/UserPageIdentityStatementsConsolidatedAddressesItemPrimary.tsx @@ -13,7 +13,7 @@ export default function UserPageIdentityStatementsConsolidatedAddressesItemPrima }) { if (isPrimary) { return ( - + Primary ); @@ -24,7 +24,7 @@ export default function UserPageIdentityStatementsConsolidatedAddressesItemPrima diff --git a/components/user/identity/statements/contacts/UserPageIdentityStatementsContacts.tsx b/components/user/identity/statements/contacts/UserPageIdentityStatementsContacts.tsx index 18f2fce7bd..727e27f43e 100644 --- a/components/user/identity/statements/contacts/UserPageIdentityStatementsContacts.tsx +++ b/components/user/identity/statements/contacts/UserPageIdentityStatementsContacts.tsx @@ -1,6 +1,6 @@ import type { CicStatement } from "@/entities/IProfile"; -import UserPageIdentityStatementsStatementsList from "../utils/UserPageIdentityStatementsStatementsList"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import UserPageIdentityStatementsStatementsList from "../utils/UserPageIdentityStatementsStatementsList"; export default function UserPageIdentityStatementsContacts({ statements, @@ -13,31 +13,9 @@ export default function UserPageIdentityStatementsContacts({ }) { return (
      -
      -
      -
      - -
      -
      - - Contact - -
      -
      + + Contact + -
      -

      - {possessionName} ID Statements +
      +

      + {possessionName} ID Statements

      - {canEdit && } + {canEdit && ( + + )}

      ); diff --git a/components/user/identity/statements/nft-accounts/UserPageIdentityStatementsNFTAccounts.tsx b/components/user/identity/statements/nft-accounts/UserPageIdentityStatementsNFTAccounts.tsx index bee2ba3998..d00be0bbab 100644 --- a/components/user/identity/statements/nft-accounts/UserPageIdentityStatementsNFTAccounts.tsx +++ b/components/user/identity/statements/nft-accounts/UserPageIdentityStatementsNFTAccounts.tsx @@ -1,6 +1,6 @@ import type { CicStatement } from "@/entities/IProfile"; -import UserPageIdentityStatementsStatementsList from "../utils/UserPageIdentityStatementsStatementsList"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import UserPageIdentityStatementsStatementsList from "../utils/UserPageIdentityStatementsStatementsList"; export default function UserPageIdentityStatementsNFTAccounts({ statements, @@ -13,31 +13,9 @@ export default function UserPageIdentityStatementsNFTAccounts({ }) { return (
      -
      -
      -
      - -
      -
      - - NFT Accounts - -
      -
      + + NFT Accounts + -
      -
      -
      - -
      -
      - - Social Media Accounts - -
      -
      + + Social Media Accounts + -
      -
      - -
      - - Social Media Verification Posts - -
      -
      + + Social Media Verification Posts + Delete diff --git a/components/user/identity/statements/utils/UserPageIdentityStatementsStatement.tsx b/components/user/identity/statements/utils/UserPageIdentityStatementsStatement.tsx index 45af0dcfc8..43709fbb76 100644 --- a/components/user/identity/statements/utils/UserPageIdentityStatementsStatement.tsx +++ b/components/user/identity/statements/utils/UserPageIdentityStatementsStatement.tsx @@ -5,6 +5,7 @@ import CopyIcon from "@/components/utils/icons/CopyIcon"; import OutsideLinkIcon from "@/components/utils/icons/OutsideLinkIcon"; import type { CicStatement } from "@/entities/IProfile"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { STATEMENT_META } from "@/helpers/Types"; import { useEffect, useState } from "react"; import { Tooltip } from "react-tooltip"; @@ -38,89 +39,77 @@ export default function UserPageIdentityStatementsStatement({ }, []); return ( -
    • -
      -
      -
      -
      - -
      - +
    • +
      +
      +
      + +
      +
      +
      {STATEMENT_META[statement.statement_type].title} - +
      +
      + {title === "Copied!" ? ( + {title} + ) : ( + statement.statement_value + )} +
      - -
      - {canOpen && ( - <> - +
      +
      + {canOpen && ( + <> + +
      - - {!isTouchScreen && ( - - Open - - )} - - )} -
      + + {!isTouchScreen && ( + + Open + + )} + + )} + - {!isTouchScreen && ( - - Copy - - )} - {canEdit && ( - - )} -
      -
      - -
      - - {title === "Copied!" ? ( - {title} - ) : ( - statement.statement_value - )} - +
      + + {!isTouchScreen && ( + + Copy + + )} + {canEdit && ( + + )}
diff --git a/components/user/identity/statements/utils/UserPageIdentityStatementsStatementsList.tsx b/components/user/identity/statements/utils/UserPageIdentityStatementsStatementsList.tsx index 1d8b571d14..2bbbb34f3a 100644 --- a/components/user/identity/statements/utils/UserPageIdentityStatementsStatementsList.tsx +++ b/components/user/identity/statements/utils/UserPageIdentityStatementsStatementsList.tsx @@ -41,7 +41,7 @@ export default function UserPageIdentityStatementsStatementsList({ } return ( -
    +
      {statements.map((statement) => ( + {noItemsMessage} )} diff --git a/components/user/layout/userTabs.config.ts b/components/user/layout/userTabs.config.ts index ca3ed690e7..397369a9c8 100644 --- a/components/user/layout/userTabs.config.ts +++ b/components/user/layout/userTabs.config.ts @@ -24,15 +24,10 @@ const TAB_DEFINITIONS = [ }, { id: "rep", - title: "Rep", - route: "rep", - }, - { - id: "identity", title: "Identity", route: "identity", }, - { +{ id: "collected", title: "Collected", route: "collected", diff --git a/components/user/rep/UserPageCombinedActivityLog.tsx b/components/user/rep/UserPageCombinedActivityLog.tsx new file mode 100644 index 0000000000..bc2f7243f7 --- /dev/null +++ b/components/user/rep/UserPageCombinedActivityLog.tsx @@ -0,0 +1,47 @@ +import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; +import ProfileActivityLogs from "@/components/profile-activity/ProfileActivityLogs"; +import type { RateMatter } from "@/types/enums"; + +export default function UserPageCombinedActivityLog({ + initialActivityLogParams, + matter, + withMatterFilter = true, +}: { + readonly initialActivityLogParams: ActivityLogParams; + readonly matter?: RateMatter | null; + readonly withMatterFilter?: boolean; +}) { + const params: ActivityLogParams = + matter === undefined + ? initialActivityLogParams + : { ...initialActivityLogParams, matter }; + + return ( +
      + +

      + + Activity Log +

      +
      +
      + ); +} diff --git a/components/user/rep/UserPageRep.helpers.ts b/components/user/rep/UserPageRep.helpers.ts new file mode 100644 index 0000000000..0d4aa7ec64 --- /dev/null +++ b/components/user/rep/UserPageRep.helpers.ts @@ -0,0 +1,42 @@ +import type { RatingStats } from "@/entities/IProfile"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ApiProfileProxy } from "@/generated/models/ApiProfileProxy"; +import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; + +export function sortRepsByRatingAndContributors(items: RatingStats[]) { + return [...items].sort((a, d) => { + if (a.rating === d.rating) { + return d.contributor_count - a.contributor_count; + } + return d.rating - a.rating; + }); +} + +export function getCanEditRep({ + myProfile, + targetProfile, + activeProfileProxy, +}: { + readonly myProfile: ApiIdentity | null; + readonly targetProfile: ApiIdentity; + readonly activeProfileProxy: ApiProfileProxy | null; +}) { + if (!myProfile?.handle) { + return false; + } + + if (activeProfileProxy) { + if (targetProfile.handle === activeProfileProxy.created_by.handle) { + return false; + } + return activeProfileProxy.actions.some( + (action) => action.action_type === ApiProfileProxyActionType.AllocateRep + ); + } + + if (myProfile.handle === targetProfile.handle) { + return false; + } + + return true; +} diff --git a/components/user/rep/UserPageRep.tsx b/components/user/rep/UserPageRep.tsx index b49c951a50..617ccb9eed 100644 --- a/components/user/rep/UserPageRep.tsx +++ b/components/user/rep/UserPageRep.tsx @@ -10,22 +10,20 @@ import { RateMatter } from "@/types/enums"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useContext, useEffect, useState } from "react"; +import UserPageIdentityHeader from "../identity/header/UserPageIdentityHeader"; +import UserPageIdentityHeaderCICRate from "../identity/header/cic-rate/UserPageIdentityHeaderCICRate"; +import UserPageIdentityStatements from "../identity/statements/UserPageIdentityStatements"; import UserPageRateWrapper from "../utils/rate/UserPageRateWrapper"; -import type { ProfileRatersParams } from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; -import ProfileRatersTableWrapper from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; + +import UserPageCombinedActivityLog from "./UserPageCombinedActivityLog"; import UserPageRepHeader from "./header/UserPageRepHeader"; -import UserPageRepNewRep from "./new-rep/UserPageRepNewRep"; +import UserPageRepMobile from "./UserPageRepMobile"; import UserPageRepReps from "./reps/UserPageRepReps"; -import UserPageRepActivityLog from "./UserPageRepActivityLog"; export default function UserPageRep({ profile, - initialRepReceivedParams, - initialRepGivenParams, initialActivityLogParams, }: { readonly profile: ApiIdentity; - readonly initialRepReceivedParams: ProfileRatersParams; - readonly initialRepGivenParams: ProfileRatersParams; readonly initialActivityLogParams: ActivityLogParams; }) { const { connectedProfile } = useContext(AuthContext); @@ -54,25 +52,82 @@ export default function UserPageRep({ return (
      - - - - - + {/* Mobile layout (<1024px) */} + -
      -
      - -
      -
      - + {/* Desktop layout (>=1024px) */} +
      +
      + {/* Left Column - Rep Content */} +
      + + +
      + +
      + + {/* Rep raters tables - commented out for now +
      +
      + +
      +
      + +
      +
      + */} +
      + + {/* Right Sidebar - Identity Card */} +
      +
      +
      +
      +
      +
      +
      +
      + + +
      + + + +
      +
      +
      +
      -
      -
      - + {/* CIC raters tables - commented out for now +
      +
      + +
      +
      + +
      +
      + */}
      ); diff --git a/components/user/rep/UserPageRepActivityLog.tsx b/components/user/rep/UserPageRepActivityLog.tsx deleted file mode 100644 index 47d885cd8a..0000000000 --- a/components/user/rep/UserPageRepActivityLog.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { - ActivityLogParams, -} from "@/components/profile-activity/ProfileActivityLogs"; -import ProfileActivityLogs from "@/components/profile-activity/ProfileActivityLogs"; -import ProfileName, { - ProfileNameType, -} from "@/components/profile-activity/ProfileName"; -import UserTableHeaderWrapper from "../utils/UserTableHeaderWrapper"; - -export default function UserPageRepActivityLog({ - initialActivityLogParams, -}: { - readonly initialActivityLogParams: ActivityLogParams; -}) { - return ( -
      - - - - {" "} - Rep Activity Log - -
      -
      - -
      -
      -
      - ); -} diff --git a/components/user/rep/UserPageRepMobile.tsx b/components/user/rep/UserPageRepMobile.tsx new file mode 100644 index 0000000000..907b25c5ee --- /dev/null +++ b/components/user/rep/UserPageRepMobile.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { AuthContext } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; +import type { + ApiProfileRepRatesState, + RatingWithProfileInfoAndLevel, +} from "@/entities/IProfile"; +import { SortDirection } from "@/entities/ISort"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; +import { amIUser, formatNumberWithCommas } from "@/helpers/Helpers"; +import type { Page } from "@/helpers/Types"; +import { commonApiFetch } from "@/services/api/common-api"; +import { ProfileRatersParamsOrderBy, RateMatter } from "@/types/enums"; +import { useQuery } from "@tanstack/react-query"; +import { AnimatePresence, motion } from "framer-motion"; +import { useContext, useEffect, useMemo, useState } from "react"; +import UserPageIdentityHeaderCICRate from "../identity/header/cic-rate/UserPageIdentityHeaderCICRate"; +import UserPageIdentityStatementsAddButton from "../identity/statements/add/UserPageIdentityStatementsAddButton"; +import UserPageIdentityStatements from "../identity/statements/UserPageIdentityStatements"; +import UserPageRateWrapper from "../utils/rate/UserPageRateWrapper"; +import UserCICStatus from "../utils/user-cic-status/UserCICStatus"; +import UserCICTypeIcon from "../utils/user-cic-type/UserCICTypeIcon"; +import TopRaterAvatars from "./header/TopRaterAvatars"; +import UserPageRepNewRep from "./new-rep/UserPageRepNewRep"; +import UserPageRepRepsTable from "./reps/table/UserPageRepRepsTable"; +import UserPageCombinedActivityLog from "./UserPageCombinedActivityLog"; +import { + getCanEditRep, + sortRepsByRatingAndContributors, +} from "./UserPageRep.helpers"; + +type MobileTab = "rep" | "identity"; + +export default function UserPageRepMobile({ + profile, + repRates, + initialActivityLogParams, +}: { + readonly profile: ApiIdentity; + readonly repRates: ApiProfileRepRatesState | null; + readonly initialActivityLogParams: ActivityLogParams; +}) { + const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + const { address } = useSeizeConnectContext(); + const profileHandle = profile.handle ?? ""; + + const [activeTab, setActiveTab] = useState("rep"); + const [isGrantRepOpen, setIsGrantRepOpen] = useState(false); + const [isNicRateOpen, setIsNicRateOpen] = useState(false); + + const { data: nicRatings } = useQuery>({ + queryKey: [ + QueryKey.PROFILE_RATERS, + { + handleOrWallet: profileHandle, + matter: RateMatter.NIC, + page: 1, + pageSize: 1, + order: SortDirection.DESC, + orderBy: ProfileRatersParamsOrderBy.RATING, + given: false, + }, + ], + queryFn: async () => + await commonApiFetch>({ + endpoint: `profiles/${profileHandle}/cic/ratings/by-rater`, + params: { + page: `${1}`, + page_size: `${1}`, + order: SortDirection.DESC.toLowerCase(), + order_by: ProfileRatersParamsOrderBy.RATING.toLowerCase(), + given: "false", + }, + }), + enabled: !!profileHandle, + }); + + // Close modals when viewport reaches lg breakpoint + useEffect(() => { + const mq = globalThis.matchMedia("(min-width: 1024px)"); + const handler = (e: MediaQueryListEvent) => { + if (e.matches) { + setIsGrantRepOpen(false); + setIsNicRateOpen(false); + } + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // --- derived: sorted reps, can-edit flags --- + const reps = useMemo( + () => sortRepsByRatingAndContributors(repRates?.rating_stats ?? []), + [repRates?.rating_stats] + ); + + const canEditRep = useMemo( + () => + getCanEditRep({ + myProfile: connectedProfile, + targetProfile: profile, + activeProfileProxy, + }), + [connectedProfile, profile, activeProfileProxy] + ); + + const canEditNic = useMemo((): boolean => { + if (!connectedProfile?.handle) return false; + if (activeProfileProxy) { + if (profile.handle === activeProfileProxy.created_by.handle) return false; + return activeProfileProxy.actions.some( + (action) => action.action_type === ApiProfileProxyActionType.AllocateCic + ); + } + if (amIUser({ profile, address })) return false; + return true; + }, [connectedProfile, profile, activeProfileProxy, address]); + + const canEditStatements = + !activeProfileProxy && + !!profile?.handle && + (profile.wallets ?? []).some( + (w) => w.wallet.toLowerCase() === address?.toLowerCase() + ); + + // --- render --- + + return ( +
      + {/* Score Cards (tappable navigation) */} +
      + {/* Rep Score */} + + + {/* NIC Score */} + +
      + + {/* Tab Content */} + + {activeTab === "rep" ? ( + + {canEditRep && ( +
      + +
      + + Add skill to this identity + + +
      +
      +
      + )} + + {/* Rep Table */} + {!!reps.length && ( +
      + +
      + )} + +
      + +
      +
      + ) : ( + + {/* Rate NIC CTA */} + {canEditNic && ( +
      + +
      + + Verify this identity + + +
      +
      +
      + )} + + {/* Identity Statements */} +
      +

      + ID Statements +

      + {canEditStatements && ( + + )} +
      +
      + +
      + +
      + +
      +
      + )} +
      + + {/* Grant Rep Bottom Sheet */} + setIsGrantRepOpen(false)} + tabletModal + > +
      + + setIsGrantRepOpen(false)} + /> + +
      + +
      +
      +
      + + {/* Rate NIC Bottom Sheet */} + setIsNicRateOpen(false)} + tabletModal + > +
      + + setIsNicRateOpen(false)} + /> + +
      + +
      +
      +
      +
      + ); +} diff --git a/components/user/rep/UserPageRepWrapper.tsx b/components/user/rep/UserPageRepWrapper.tsx index a65863b6bc..792ec1923c 100644 --- a/components/user/rep/UserPageRepWrapper.tsx +++ b/components/user/rep/UserPageRepWrapper.tsx @@ -4,18 +4,13 @@ import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { useIdentity } from "@/hooks/useIdentity"; import { useParams } from "next/navigation"; import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; -import type { ProfileRatersParams } from "../utils/raters-table/wrapper/ProfileRatersTableWrapper"; import UserPageSetUpProfileWrapper from "../utils/set-up-profile/UserPageSetUpProfileWrapper"; import UserPageRep from "./UserPageRep"; export default function UserPageRepWrapper({ profile: initialProfile, - initialRepReceivedParams, - initialRepGivenParams, initialActivityLogParams, }: { readonly profile: ApiIdentity; - readonly initialRepReceivedParams: ProfileRatersParams; - readonly initialRepGivenParams: ProfileRatersParams; readonly initialActivityLogParams: ActivityLogParams; }) { const params = useParams(); @@ -30,8 +25,6 @@ export default function UserPageRepWrapper({ diff --git a/components/user/rep/header/TopRaterAvatars.tsx b/components/user/rep/header/TopRaterAvatars.tsx new file mode 100644 index 0000000000..c28e746371 --- /dev/null +++ b/components/user/rep/header/TopRaterAvatars.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useQuery, useQueries } from "@tanstack/react-query"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { commonApiFetch } from "@/services/api/common-api"; +import type { RatingWithProfileInfoAndLevel } from "@/entities/IProfile"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { Page } from "@/helpers/Types"; +import OverlappingAvatars from "@/components/common/OverlappingAvatars"; +import { RateMatter } from "@/types/enums"; +import type { MouseEvent } from "react"; + +const STALE_TIME = 5 * 60 * 1000; + +export default function TopRaterAvatars({ + handleOrWallet, + category, + matter = RateMatter.REP, + withLinks = true, + onAvatarClick, + count = 5, + size = "sm", +}: { + readonly handleOrWallet: string; + readonly category?: string; + readonly matter?: RateMatter.REP | RateMatter.NIC; + readonly withLinks?: boolean; + readonly onAvatarClick?: (e: MouseEvent) => void; + readonly count?: number; + readonly size?: "sm" | "md"; +}) { + const params: Record = { + page: "1", + page_size: `${count}`, + order: "desc", + order_by: "rating", + given: "false", + }; + if (matter === RateMatter.REP && category) { + params["category"] = category; + } + + const ratingsEndpoint = + matter === RateMatter.NIC + ? `profiles/${handleOrWallet}/cic/ratings/by-rater` + : `profiles/${handleOrWallet}/rep/ratings/by-rater`; + + const { data: ratersPage } = useQuery>({ + queryKey: [ + QueryKey.PROFILE_RATERS, + { + handleOrWallet: handleOrWallet.toLowerCase(), + matter, + category, + count, + }, + ], + queryFn: async () => + await commonApiFetch>({ + endpoint: ratingsEndpoint, + params, + }), + enabled: !!handleOrWallet, + staleTime: STALE_TIME, + }); + + const raterHandles = ratersPage?.data.map((r) => r.handle) ?? []; + + const identityQueries = useQueries({ + queries: raterHandles.map((handle) => ({ + queryKey: [QueryKey.PROFILE, handle.toLowerCase()], + queryFn: async () => + await commonApiFetch({ + endpoint: `identities/${handle.toLowerCase()}`, + }), + enabled: !!handle, + staleTime: STALE_TIME, + })), + }); + + const items = identityQueries + .filter((q) => q.data) + .map((q) => { + const identity = q.data!; + const target = identity.handle ?? identity.primary_wallet; + return { + key: target, + pfpUrl: identity.pfp ?? null, + ...(withLinks && target ? { href: `/${target}` } : {}), + ariaLabel: target, + fallback: identity.handle + ? identity.handle.charAt(0).toUpperCase() + : "?", + title: target, + }; + }); + + if (items.length === 0) { + return null; + } + + return ( + onAvatarClick?.(e)} + /> + ); +} diff --git a/components/user/rep/header/UserPageRepHeader.tsx b/components/user/rep/header/UserPageRepHeader.tsx index 9ba43e4f16..29996b1dc4 100644 --- a/components/user/rep/header/UserPageRepHeader.tsx +++ b/components/user/rep/header/UserPageRepHeader.tsx @@ -1,37 +1,171 @@ -import type { ApiProfileRepRatesState } from "@/entities/IProfile"; +"use client"; + +import { AuthContext } from "@/components/auth/Auth"; +import type { ApiProfileRepRatesState, RatingStats } from "@/entities/IProfile"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { formatNumberWithCommas } from "@/helpers/Helpers"; +import { useContext, useEffect, useState } from "react"; +import UserPageRepModifyModal from "../modify-rep/UserPageRepModifyModal"; +import TopRaterAvatars from "./TopRaterAvatars"; +import { + getCanEditRep, + sortRepsByRatingAndContributors, +} from "../UserPageRep.helpers"; + +const TOP_REPS_COUNT = 4; export default function UserPageRepHeader({ repRates, + profile, }: { readonly repRates: ApiProfileRepRatesState | null; + readonly profile: ApiIdentity; }) { + const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + + const [topReps, setTopReps] = useState( + sortRepsByRatingAndContributors(repRates?.rating_stats ?? []).slice( + 0, + TOP_REPS_COUNT + ) + ); + + useEffect(() => { + setTopReps( + sortRepsByRatingAndContributors(repRates?.rating_stats ?? []).slice( + 0, + TOP_REPS_COUNT + ) + ); + }, [repRates?.rating_stats]); + + const [canEditRep, setCanEditRep] = useState( + getCanEditRep({ + myProfile: connectedProfile, + targetProfile: profile, + activeProfileProxy, + }) + ); + + useEffect(() => { + setCanEditRep( + getCanEditRep({ + myProfile: connectedProfile, + targetProfile: profile, + activeProfileProxy, + }) + ); + }, [connectedProfile, profile, activeProfileProxy]); + + const [editCategory, setEditCategory] = useState(null); + + const openEditCategory = (category: string) => { + if (!canEditRep) { + return; + } + setEditCategory(category); + }; + return ( -
      -
      -
      -
      -
      -
      - Rep: - - {repRates - ? formatNumberWithCommas(repRates.total_rep_rating) - : ""} - -
      + <> +
      +
      +
      +
      +
      + +
      +
      +
      +

      + Rep +

      +

      + What others recognize this identity for. +

      -
      - Raters: - + +
      +
      + Total Rep +
      +
      {repRates - ? formatNumberWithCommas(repRates.number_of_raters) + ? formatNumberWithCommas(repRates.total_rep_rating) : ""} - +
      + {repRates && ( + + {formatNumberWithCommas(repRates.number_of_raters)}{" "} + {repRates.number_of_raters === 1 ? "rater" : "raters"} + + )}
      + + {topReps.length > 0 && ( +
      +
      + Top Rep +
      +
      + {topReps.map((rep) => ( +
      + {canEditRep ? ( + + ) : ( +
      + + {rep.category} + + + {formatNumberWithCommas(rep.rating)} + +
      + )} + · + + + {formatNumberWithCommas(rep.contributor_count)}{" "} + {rep.contributor_count === 1 ? "rater" : "raters"} + +
      + ))} +
      +
      + )}
      -
      + + {canEditRep && editCategory && ( + setEditCategory(null)} + /> + )} + ); } diff --git a/components/user/rep/modify-rep/UserPageRepModifyModal.tsx b/components/user/rep/modify-rep/UserPageRepModifyModal.tsx index 3775b76810..99a33d35c4 100644 --- a/components/user/rep/modify-rep/UserPageRepModifyModal.tsx +++ b/components/user/rep/modify-rep/UserPageRepModifyModal.tsx @@ -2,29 +2,20 @@ import { AuthContext } from "@/components/auth/Auth"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; -import { - QueryKey, - ReactQueryWrapperContext, -} from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import UserRateAdjustmentHelper from "@/components/user/utils/rate/UserRateAdjustmentHelper"; -import type { - ApiProfileRepRatesState, - RatingStats, -} from "@/entities/IProfile"; +import UserPageRateInput from "@/components/user/utils/rate/UserPageRateInput"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; import { getStringAsNumberOrZero } from "@/helpers/Helpers"; -import { - commonApiFetch, - commonApiPost, -} from "@/services/api/common-api"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { commonApiPost } from "@/services/api/common-api"; +import { useMutation } from "@tanstack/react-query"; import { useContext, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import { useClickAway, useKeyPressEvent } from "react-use"; import UserPageRepModifyModalHeader from "./UserPageRepModifyModalHeader"; import UserPageRepModifyModalRaterStats from "./UserPageRepModifyModalRaterStats"; +import { useRepAllocation } from "@/hooks/useRepAllocation"; interface ApiAddRepRatingToProfileRequest { readonly amount: number; readonly category: string; @@ -43,173 +34,18 @@ export default function UserPageRepModifyModal({ const { requestAuth, setToast, connectedProfile, activeProfileProxy } = useContext(AuthContext); - const { data: proxyGrantorRepRates } = useQuery({ - queryKey: [ - QueryKey.PROFILE_REP_RATINGS, - { - rater: activeProfileProxy?.created_by.handle, - handleOrWallet: profile?.handle, - }, - ], - queryFn: async () => - await commonApiFetch({ - endpoint: `profiles/${profile?.query}/rep/ratings/received`, - params: activeProfileProxy?.created_by.handle - ? { rater: activeProfileProxy.created_by.handle } - : {}, - }), - enabled: !!activeProfileProxy?.created_by.handle, + const { repState, heroAvailableRep, minMaxValues } = useRepAllocation({ + profile, + category, }); - const { data: connectedProfileRepRates } = useQuery({ - queryKey: [ - QueryKey.PROFILE_REP_RATINGS, - { - rater: connectedProfile?.handle, - handleOrWallet: profile?.handle, - }, - ], - queryFn: async () => - await commonApiFetch({ - endpoint: `profiles/${profile?.query}/rep/ratings/received`, - params: connectedProfile?.handle - ? { rater: connectedProfile?.handle } - : {}, - }), - enabled: !!connectedProfile?.handle, - }); - - const getProxyAvailableCredit = (): number | null => { - const repProxy = activeProfileProxy?.actions.find( - (a) => a.action_type === ApiProfileProxyActionType.AllocateRep - ); - if (!repProxy) { - return null; - } - return Math.max( - (repProxy.credit_amount ?? 0) - (repProxy.credit_spent ?? 0), - 0 - ); - }; - - const [proxyAvailableCredit, setProxyAvailableCredit] = useState< - number | null - >(getProxyAvailableCredit()); - - useEffect( - () => setProxyAvailableCredit(getProxyAvailableCredit()), - [activeProfileProxy] - ); - - const getRepState = (): RatingStats | null => { - if (activeProfileProxy && proxyGrantorRepRates) { - const target = proxyGrantorRepRates.rating_stats.find( - (s) => s.category === category - ); - return ( - target ?? { - category, - rating: 0, - contributor_count: 0, - rater_contribution: 0, - } - ); - } - - if (activeProfileProxy) { - return null; - } - - if (connectedProfileRepRates) { - const target = connectedProfileRepRates.rating_stats.find( - (s) => s.category === category - ); - return ( - target ?? { - category, - rating: 0, - contributor_count: 0, - rater_contribution: 0, - } - ); - } - - return null; - }; - - const [repState, setRepState] = useState(getRepState()); const [adjustedRatingStr, setAdjustedRatingStr] = useState( - `${getRepState()?.rater_contribution ?? 0}` - ); - - const getHeroAvailableRep = (): number => { - if (activeProfileProxy) { - return proxyGrantorRepRates?.rep_rates_left_for_rater ?? 0; - } - return connectedProfileRepRates?.rep_rates_left_for_rater ?? 0; - }; - - const [heroAvailableRep, setHeroAvailableRep] = useState( - getHeroAvailableRep() + `${repState?.rater_contribution ?? 0}` ); - useEffect( - () => setHeroAvailableRep(getHeroAvailableRep()), - [activeProfileProxy, proxyGrantorRepRates, connectedProfileRepRates] - ); - - const getMinValue = (): number => { - const currentRep = repState?.rater_contribution ?? 0; - const minHeroRep = 0 - (Math.abs(currentRep) + heroAvailableRep); - if (typeof proxyAvailableCredit !== "number") { - return minHeroRep; - } - const minProxyRep = currentRep - proxyAvailableCredit; - - return Math.abs(minHeroRep) < Math.abs(minProxyRep) - ? minHeroRep - : minProxyRep; - }; - - const getMaxValue = (): number => { - const currentRep = repState?.rater_contribution ?? 0; - const maxHeroRep = Math.abs(currentRep) + heroAvailableRep; - if (typeof proxyAvailableCredit !== "number") { - return maxHeroRep; - } - const maxProxyRep = currentRep + proxyAvailableCredit; - - return Math.min(maxHeroRep, maxProxyRep); - }; - - const getMinMaxValues = (): { - readonly min: number; - readonly max: number; - } => ({ - min: getMinValue(), - max: getMaxValue(), - }); - - const [minMaxValues, setMinMaxValues] = useState<{ - readonly min: number; - readonly max: number; - }>(getMinMaxValues()); - useEffect(() => { - const newState = getRepState(); - setRepState(newState); - setAdjustedRatingStr(`${newState?.rater_contribution ?? 0}`); - }, [ - proxyGrantorRepRates, - activeProfileProxy, - connectedProfileRepRates, - heroAvailableRep, - ]); - - useEffect( - () => setMinMaxValues(getMinMaxValues()), - [repState, proxyGrantorRepRates, proxyAvailableCredit] - ); + setAdjustedRatingStr(`${repState?.rater_contribution ?? 0}`); + }, [repState]); const inputRef = useRef(null); useEffect(() => { @@ -249,58 +85,10 @@ export default function UserPageRepModifyModal({ }; }, []); - const getValueStr = (value: string): string => { - if (value.length > 1 && value.startsWith("0")) { - return value.slice(1); - } - - return value; - }; - - const adjustStrValueToMinMax = (): void => { - if (activeProfileProxy) { - return; - } - const { min, max } = minMaxValues; - const valueAsNumber = getStringAsNumberOrZero(adjustedRatingStr); - if (valueAsNumber > max) { - setAdjustedRatingStr(`${max}`); - return; - } - - if (valueAsNumber < min) { - setAdjustedRatingStr(`${min}`); - } - }; - - const onValueChange = (event: React.ChangeEvent) => { - const inputValue = event.currentTarget.value; - const strCIC = ["-0", "0-"].includes(inputValue) ? "-" : inputValue; - if (/^-?\d*$/.test(strCIC)) { - const newCicValue = getValueStr(strCIC); - setAdjustedRatingStr(newCicValue); - } - }; - - const getIsValidValue = (): boolean => { - if (activeProfileProxy) { - return true; - } - const { min, max } = minMaxValues; - const valueAsNumber = getStringAsNumberOrZero(adjustedRatingStr); - if (valueAsNumber > max) { - return false; - } - - if (valueAsNumber < min) { - return false; - } - return true; - }; - - const [isValidValue, setIsValidValue] = useState(getIsValidValue()); - - useEffect(() => setIsValidValue(getIsValidValue()), [adjustedRatingStr]); + const adjustedValueNum = getStringAsNumberOrZero(adjustedRatingStr); + const isValidValue = + !!activeProfileProxy || + (adjustedValueNum >= minMaxValues.min && adjustedValueNum <= minMaxValues.max); const [newRating, setNewRating] = useState( getStringAsNumberOrZero(adjustedRatingStr) @@ -441,49 +229,13 @@ export default function UserPageRepModifyModal({ Your total Rep for {category}:
      - - - - -
      -

      +

      Add Rep to {handleOrWallet}

      diff --git a/components/user/rep/new-rep/UserPageRepNewRep.tsx b/components/user/rep/new-rep/UserPageRepNewRep.tsx index a6ccb74676..b156723a55 100644 --- a/components/user/rep/new-rep/UserPageRepNewRep.tsx +++ b/components/user/rep/new-rep/UserPageRepNewRep.tsx @@ -1,68 +1,27 @@ "use client"; -import { useState } from "react"; -import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; -import UserPageRepModifyModal from "../modify-rep/UserPageRepModifyModal"; import type { ApiProfileRepRatesState, - RatingStats, } from "@/entities/IProfile"; import UserPageRepNewRepSearch from "./UserPageRepNewRepSearch"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { AnimatePresence } from "framer-motion"; export default function UserPageRepNewRep({ profile, repRates, + onSuccess, }: { readonly profile: ApiIdentity; readonly repRates: ApiProfileRepRatesState | null; + readonly onSuccess?: () => void; }) { - const [isAddNewRepModalOpen, setIsAddNewRepModalOpen] = - useState(false); - - const [repToAdd, setRepToAdd] = useState(null); - - const onRepSearch = (repSearch: string) => { - const rep: RatingStats = repRates?.rating_stats.find( - (r) => r.category === repSearch - ) ?? { - category: repSearch, - rating: 0, - contributor_count: 0, - rater_contribution: 0, - }; - setRepToAdd(rep); - setIsAddNewRepModalOpen(true); - }; - - const onCloseModal = () => { - setIsAddNewRepModalOpen(false); - setRepToAdd(null); - }; + const searchProps = onSuccess ? { onSuccess } : {}; return ( - <> - - - {isAddNewRepModalOpen && repToAdd && ( - e.stopPropagation()}> - - - )} - - + ); } diff --git a/components/user/rep/new-rep/UserPageRepNewRepSearch.tsx b/components/user/rep/new-rep/UserPageRepNewRepSearch.tsx index cafb835eca..d488940fa2 100644 --- a/components/user/rep/new-rep/UserPageRepNewRepSearch.tsx +++ b/components/user/rep/new-rep/UserPageRepNewRepSearch.tsx @@ -1,22 +1,46 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { commonApiFetch } from "@/services/api/common-api"; -import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; +import { useContext, useEffect, useRef, useState } from "react"; import { useClickAway, useDebounce, useKeyPressEvent } from "react-use"; import { AnimatePresence, motion } from "framer-motion"; import type { ApiProfileRepRatesState } from "@/entities/IProfile"; -import UserPageRepNewRepSearchHeader from "./UserPageRepNewRepSearchHeader"; import UserPageRepNewRepSearchDropdown from "./UserPageRepNewRepSearchDropdown"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; import UserPageRepNewRepError from "./UserPageRepNewRepError"; -import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { + QueryKey, + ReactQueryWrapperContext, +} from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { AuthContext } from "@/components/auth/Auth"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { formatNumberWithCommas, getStringAsNumberOrZero } from "@/helpers/Helpers"; +import UserRateAdjustmentHelper from "@/components/user/utils/rate/UserRateAdjustmentHelper"; +import UserPageRateInput from "@/components/user/utils/rate/UserPageRateInput"; +import { useRepAllocation } from "@/hooks/useRepAllocation"; + const SEARCH_LENGTH = { MIN: 3, MAX: 100, }; +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (typeof error === "string" && error.trim()) { + return error; + } + if (typeof error === "object" && error !== null && "message" in error) { + const message = (error as { readonly message?: unknown }).message; + if (typeof message === "string" && message.trim()) { + return message; + } + } + return "Something went wrong."; +}; + export enum RepSearchState { MIN_LENGTH_ERROR = "MIN_LENGTH_ERROR", MAX_LENGTH_ERROR = "MAX_LENGTH_ERROR", @@ -26,21 +50,21 @@ export enum RepSearchState { export default function UserPageRepNewRepSearch({ repRates, - onRepSearch, profile, + onSuccess, }: { readonly repRates: ApiProfileRepRatesState | null; - readonly onRepSearch: (repSearch: string) => void; readonly profile: ApiIdentity; + readonly onSuccess?: () => void; }) { - const [repSearch, setRepSearch] = useState(""); + const { onProfileRepModify } = useContext(ReactQueryWrapperContext); + const { requestAuth, setToast, connectedProfile, activeProfileProxy } = + useContext(AuthContext); - const handleRepSearchChange = ( - event: React.ChangeEvent - ) => { - const newValue = event.target.value; - setRepSearch(newValue); - }; + const [repSearch, setRepSearch] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(null); + const [amountStr, setAmountStr] = useState("0"); + const [mutating, setMutating] = useState(false); const [errorMsg, setErrorMsg] = useState(null); @@ -78,6 +102,28 @@ export default function UserPageRepNewRepSearch({ enabled: matchingSearchLength, }); + const { repState, heroAvailableRep, minMaxValues } = useRepAllocation({ + profile, + category: selectedCategory, + }); + + // Pre-fill amountStr when category is selected + useEffect(() => { + if (repState) { + setAmountStr(`${repState.rater_contribution}`); + } + }, [repState]); + + const amountNum = getStringAsNumberOrZero(amountStr); + const isValidValue = + !!activeProfileProxy || + (amountNum >= minMaxValues.min && amountNum <= minMaxValues.max); + + const newRating = getStringAsNumberOrZero(amountStr); + const haveChanged = newRating !== (repState?.rater_contribution ?? 0); + + // --- End derived rep state --- + const [isOpen, setIsOpen] = useState(false); const listRef = useRef(null); @@ -97,8 +143,8 @@ export default function UserPageRepNewRepSearch({ param: rep, }, }); - onRepSearch(rep); - setRepSearch(""); + setSelectedCategory(rep); + setRepSearch(rep); setErrorMsg(null); setIsOpen(false); } catch (error: any) { @@ -108,7 +154,55 @@ export default function UserPageRepNewRepSearch({ } }; - const onSubmit = async (event: React.FormEvent) => { + const addRepMutation = useMutation({ + mutationFn: async ({ + amount, + category, + }: { + amount: number; + category: string; + }) => + await commonApiPost<{ amount: number; category: string }, void>({ + endpoint: `profiles/${profile?.query}/rep/rating`, + body: { amount, category }, + }), + onSuccess: () => { + setToast({ message: "Rep updated.", type: "success" }); + onProfileRepModify({ + targetProfile: profile, + connectedProfile, + profileProxy: activeProfileProxy ?? null, + }); + setSelectedCategory(null); + setRepSearch(""); + setAmountStr("0"); + setIsOpen(false); + onSuccess?.(); + }, + onError: (error) => { + setToast({ message: getErrorMessage(error), type: "error" }); + }, + }); + + const onGrantRep = async () => { + if (mutating || !selectedCategory || !amountStr) return; + const amount = Number.parseInt(amountStr, 10); + if (Number.isNaN(amount)) return; + if (!haveChanged) return; + setMutating(true); + try { + const { success } = await requestAuth(); + if (!success) { + setToast({ message: "You must be logged in.", type: "error" }); + return; + } + await addRepMutation.mutateAsync({ amount, category: selectedCategory }); + } finally { + setMutating(false); + } + }; + + const onSearchSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!debouncedValue) return; onRepSelect(debouncedValue); @@ -128,10 +222,10 @@ export default function UserPageRepNewRepSearch({ } setCategoriesToDisplay(items); - if (debouncedValue.length) { + if (debouncedValue.length && repSearch.length && !selectedCategory) { setIsOpen(true); } - }, [debouncedValue, categories, matchingSearchLength]); + }, [debouncedValue, repSearch, categories, matchingSearchLength, selectedCategory]); const [repSearchState, setRepSearchState] = useState( RepSearchState.MIN_LENGTH_ERROR @@ -149,73 +243,145 @@ export default function UserPageRepNewRepSearch({ } }, [debouncedValue, isFetching, categories]); + const handleRepSearchChange = ( + event: React.ChangeEvent + ) => { + const newValue = event.target.value; + setRepSearch(newValue); + if (selectedCategory && newValue !== selectedCategory) { + setSelectedCategory(null); + setAmountStr(""); + } + }; + + const isGrantDisabled = + !selectedCategory || !amountStr || !haveChanged || !isValidValue || mutating; + return ( -
      -
      -
      - -
      -
      -
      +
      +
      +
      +
      +
      +
      -
      - + Your available Rep: + + {formatNumberWithCommas(heroAvailableRep)} + + + + + + Your Rep assigned to{" "} + {profile.query ?? profile.handle ?? profile.display}: + + + {formatNumberWithCommas( + repRates?.total_rep_rating_by_rater ?? 0 + )} + + +
      +
      +
      + +
      + + setIsOpen(true)} + className="tw-form-input tw-appearance-none tw-block tw-w-full tw-rounded-lg tw-border tw-border-solid tw-border-white/10 tw-py-3 tw-pl-9 tw-pr-3 tw-bg-[#0A0A0A]/80 tw-text-white tw-text-sm tw-font-medium lg:tw-font-semibold tw-caret-primary-400 placeholder:tw-text-iron-500 lg:placeholder:tw-text-iron-400 placeholder:tw-font-normal focus:tw-outline-none focus:tw-border-blue-500/50 tw-transition tw-duration-300 tw-ease-out" + placeholder="Category to grant rep for" /> - - setIsOpen(true)} - className="tw-form-input tw-appearance-none tw-block tw-w-full tw-rounded-lg tw-border tw-border-solid tw-border-iron-700 tw-py-3 tw-pl-11 tw-pr-3 tw-bg-iron-900 focus:tw-bg-iron-950 tw-text-iron-100 tw-font-normal tw-caret-primary-400 tw-shadow-sm hover:tw-ring-iron-700 placeholder:tw-text-iron-400 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-iron-700 tw-text-base sm:tw-leading-6 tw-transition tw-duration-300 tw-ease-out" - placeholder="Category to grant rep for" + {checkingAvailability && ( +
      + +
      + )} +
      + + {isOpen && ( + e.stopPropagation()}> + + + )} + + +
      + - {checkingAvailability && ( -
      +
      +
      - -
      - - {isOpen && ( - e.stopPropagation()}> - - + +
      + {selectedCategory && ( + )} - +
      diff --git a/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown.tsx b/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown.tsx index a1d8ce45b9..1cd5558d18 100644 --- a/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown.tsx +++ b/components/user/rep/new-rep/UserPageRepNewRepSearchDropdown.tsx @@ -28,7 +28,7 @@ export default function UserPageRepNewRepSearchDropdown({ )} {state === RepSearchState.LOADING && ( -
    • +
    • Loading...
    • )} diff --git a/components/user/rep/new-rep/UserPageRepNewRepSearchHeader.tsx b/components/user/rep/new-rep/UserPageRepNewRepSearchHeader.tsx index c3a3107e79..b7528570b9 100644 --- a/components/user/rep/new-rep/UserPageRepNewRepSearchHeader.tsx +++ b/components/user/rep/new-rep/UserPageRepNewRepSearchHeader.tsx @@ -102,32 +102,37 @@ export default function UserPageRepNewRepSearchHeader({ return (
      {!!activeProfileProxy && ( - + You are acting as proxy for: - + {activeProfileProxy.created_by.handle} )} - {!activeProfileProxy && ( - - Your available Rep: - - {formatNumberWithCommas(availableCredit)} - - - )} - +
      + {!activeProfileProxy && ( + <> + + Your available Rep: + + {formatNumberWithCommas(availableCredit)} + + + + + )} - Your Rep assigned to{" "} - {profile.query ?? profile.handle ?? profile.display}: - - - {repRates ? formatNumberWithCommas(activeRepRates.rated) : ""} + + Your Rep assigned to{" "} + {profile.query ?? profile.handle ?? profile.display}: + + + {repRates ? formatNumberWithCommas(activeRepRates.rated) : ""} + - +
      ); } diff --git a/components/user/rep/reps/UserPageRepReps.tsx b/components/user/rep/reps/UserPageRepReps.tsx index afc84cddd7..390d7246d0 100644 --- a/components/user/rep/reps/UserPageRepReps.tsx +++ b/components/user/rep/reps/UserPageRepReps.tsx @@ -1,16 +1,14 @@ "use client"; import { useContext, useEffect, useState } from "react"; -import type { - ApiProfileRepRatesState, - RatingStats, -} from "@/entities/IProfile"; -import UserPageRepRepsTop from "./UserPageRepRepsTop"; +import type { ApiProfileRepRatesState, RatingStats } from "@/entities/IProfile"; import UserPageRepRepsTable from "./table/UserPageRepRepsTable"; import { AuthContext } from "@/components/auth/Auth"; import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -const TOP_REPS_COUNT = 5; +import UserPageRateWrapper from "../../utils/rate/UserPageRateWrapper"; +import UserPageRepNewRep from "../new-rep/UserPageRepNewRep"; +import { RateMatter } from "@/types/enums"; export default function UserPageRepReps({ repRates, @@ -33,18 +31,11 @@ export default function UserPageRepReps({ sortReps(repRates?.rating_stats ?? []) ); - const getTopReps = (items: RatingStats[]) => { - return items.slice(0, TOP_REPS_COUNT); - }; - - const [topReps, setTopReps] = useState(getTopReps(reps)); useEffect( () => setReps(sortReps(repRates?.rating_stats ?? [])), [repRates?.rating_stats] ); - useEffect(() => setTopReps(getTopReps(reps)), [reps]); - const getCanEditRep = ({ myProfile, targetProfile, @@ -86,31 +77,31 @@ export default function UserPageRepReps({ }, [connectedProfile, profile]); return ( -
      - {!!reps.length && ( - <> -
      -

      - Top Rep -

      -
      - -
      -

      - Total Rep -

      +
      +
      +
      +

      + Total Rep +

      +
      + + + + + + {!!reps.length && ( +
      +
      - - - )} + )} +
      ); } diff --git a/components/user/rep/reps/UserPageRepRepsTop.tsx b/components/user/rep/reps/UserPageRepRepsTop.tsx deleted file mode 100644 index 4f4d621b3c..0000000000 --- a/components/user/rep/reps/UserPageRepRepsTop.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { RatingStats } from "@/entities/IProfile"; -import UserPageRepsItem from "./UserPageRepsItem"; -import type { ApiIdentity } from "@/generated/models/ApiIdentity"; - -export default function UserPageRepRepsTop({ - reps, - profile, - canEditRep, -}: { - readonly reps: RatingStats[]; - readonly profile: ApiIdentity; - readonly canEditRep: boolean; -}) { - return ( -
      - {reps.map((rep) => ( - - ))} -
      - ); -} diff --git a/components/user/rep/reps/UserPageRepsItem.tsx b/components/user/rep/reps/UserPageRepsItem.tsx deleted file mode 100644 index 6c85d31fb4..0000000000 --- a/components/user/rep/reps/UserPageRepsItem.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; -import type { RatingStats } from "@/entities/IProfile"; -import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { formatNumberWithCommas } from "@/helpers/Helpers"; -import { useEffect, useState } from "react"; -import { Tooltip } from "react-tooltip"; -import UserPageRepModifyModal from "../modify-rep/UserPageRepModifyModal"; -import { AnimatePresence } from "framer-motion"; -export default function UserPageRepsItem({ - rep, - profile, - canEditRep, -}: { - readonly rep: RatingStats; - readonly profile: ApiIdentity; - readonly canEditRep: boolean; -}) { - const isPositiveRating = rep.rating > 0; - const [isEditRepModalOpen, setIsEditRepModalOpen] = useState(false); - - const [isTouchScreen, setIsTouchScreen] = useState(false); - useEffect(() => { - setIsTouchScreen(window.matchMedia("(pointer: coarse)").matches); - }, []); - - return ( - <> - - - {isEditRepModalOpen && ( - e.stopPropagation()}> - setIsEditRepModalOpen(false)} - /> - - )} - - - ); -} diff --git a/components/user/rep/reps/table/UserPageRepRepsTable.tsx b/components/user/rep/reps/table/UserPageRepRepsTable.tsx index 4c2254f5eb..83dbcbd761 100644 --- a/components/user/rep/reps/table/UserPageRepRepsTable.tsx +++ b/components/user/rep/reps/table/UserPageRepRepsTable.tsx @@ -6,7 +6,6 @@ import UserPageRepRepsTableBody from "./UserPageRepRepsTableBody"; import UserPageRepRepsTableHeader from "./UserPageRepRepsTableHeader"; import { SortDirection } from "@/entities/ISort"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; -import CommonTableWrapper from "@/components/utils/table/CommonTableWrapper"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; export enum RepsTableSort { REP = "REP", @@ -14,15 +13,20 @@ export enum RepsTableSort { MY_RATES = "MY_RATES", } +const DEFAULT_INITIAL_COUNT = 10; + export default function UserPageRepRepsTable({ reps, profile, canEditRep, + initialCount = DEFAULT_INITIAL_COUNT, }: { readonly reps: RatingStats[]; readonly profile: ApiIdentity; readonly canEditRep: boolean; + readonly initialCount?: number; }) { + const [showAll, setShowAll] = useState(false); const [sortType, setSortType] = useState(RepsTableSort.REP); const [sortDirection, setSortDirection] = useState( SortDirection.DESC @@ -103,20 +107,43 @@ export default function UserPageRepRepsTable({ } }, [canEditRep, sortType]); + const displayedReps = showAll + ? sortedReps + : sortedReps.slice(0, initialCount); + const hasMore = sortedReps.length > initialCount; + const maxRep = Math.max(...reps.map((r) => Math.abs(r.rating)), 0); + return ( - - +
      +
      + + - - + +
      +
      + {hasMore && ( + + )} +
      ); } diff --git a/components/user/rep/reps/table/UserPageRepRepsTableBody.tsx b/components/user/rep/reps/table/UserPageRepRepsTableBody.tsx index caf898efd7..6c1a0d9a80 100644 --- a/components/user/rep/reps/table/UserPageRepRepsTableBody.tsx +++ b/components/user/rep/reps/table/UserPageRepRepsTableBody.tsx @@ -6,19 +6,22 @@ export default function UserPageRepRepsTableBody({ reps, profile, canEditRep, + maxRep, }: { readonly reps: RatingStats[]; readonly profile: ApiIdentity; readonly canEditRep: boolean; + readonly maxRep: number; }) { return ( - + {reps.map((rep) => ( ))} diff --git a/components/user/rep/reps/table/UserPageRepRepsTableHeader.tsx b/components/user/rep/reps/table/UserPageRepRepsTableHeader.tsx index aa6fcb38bd..8061e091a0 100644 --- a/components/user/rep/reps/table/UserPageRepRepsTableHeader.tsx +++ b/components/user/rep/reps/table/UserPageRepRepsTableHeader.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; import type { SortDirection } from "@/entities/ISort"; +import { useEffect, useState } from "react"; import { RepsTableSort } from "./UserPageRepRepsTable"; import UserPageRepRepsTableHeaderSortableCell from "./UserPageRepRepsTableHeaderSortableCell"; @@ -27,11 +27,11 @@ export default function UserPageRepRepsTableHeader({ useEffect(() => setTypes(getTypes(showMyRates)), [showMyRates]); return ( - - + + + className="tw-whitespace-nowrap tw-px-4 tw-py-1.5 tw-text-left tw-text-xs tw-font-semibold tw-uppercase tw-tracking-wider tw-text-iron-500 tw-border-0"> Category {types.map((sortType) => ( diff --git a/components/user/rep/reps/table/UserPageRepRepsTableHeaderSortableCell.tsx b/components/user/rep/reps/table/UserPageRepRepsTableHeaderSortableCell.tsx index f5204cd46f..0fc47ce288 100644 --- a/components/user/rep/reps/table/UserPageRepRepsTableHeaderSortableCell.tsx +++ b/components/user/rep/reps/table/UserPageRepRepsTableHeaderSortableCell.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import CommonTableSortIcon from "@/components/user/utils/icons/CommonTableSortIcon"; import { SortDirection } from "@/entities/ISort"; +import { useEffect, useState } from "react"; import { RepsTableSort } from "./UserPageRepRepsTable"; -import CommonTableSortIcon from "@/components/user/utils/icons/CommonTableSortIcon"; export default function UserPageRepRepsTableHeaderSortableCell({ type, @@ -37,15 +37,16 @@ export default function UserPageRepRepsTableHeaderSortableCell({ return ( onSortTypeClick(type)}> {SORT_TYPE_TO_TEXT[type]} - + {/* Sort icons intentionally hidden for this iteration. */} + diff --git a/components/user/rep/reps/table/UserPageRepRepsTableItem.tsx b/components/user/rep/reps/table/UserPageRepRepsTableItem.tsx index fb52531970..0325157b6f 100644 --- a/components/user/rep/reps/table/UserPageRepRepsTableItem.tsx +++ b/components/user/rep/reps/table/UserPageRepRepsTableItem.tsx @@ -2,22 +2,21 @@ import { useState } from "react"; import type { RatingStats } from "@/entities/IProfile"; -import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; import UserPageRepModifyModal from "@/components/user/rep/modify-rep/UserPageRepModifyModal"; import { formatNumberWithCommas } from "@/helpers/Helpers"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { AnimatePresence } from "framer-motion"; + export default function UserPageRepRepsTableItem({ rep, profile, canEditRep, + maxRep, }: { readonly rep: RatingStats; readonly profile: ApiIdentity; readonly canEditRep: boolean; + readonly maxRep: number; }) { - const isPositiveRating = rep.rating > 0; - const isPositiveRaterContribution = rep.rater_contribution > 0; const [isEditRepModalOpen, setIsEditRepModalOpen] = useState(false); const onTableClick = () => { @@ -26,54 +25,85 @@ export default function UserPageRepRepsTableItem({ } }; + const progressPercent = + maxRep > 0 ? (Math.abs(rep.rating) / maxRep) * 100 : 0; + + const cellBase = + "tw-py-2.5 sm:tw-py-3 tw-px-4 tw-bg-gradient-to-r tw-from-[#0f1014]/40 tw-to-[#0A0A0C]/40 tw-border-y tw-border-solid tw-border-white/[0.08] sm:tw-border-white/[0.04] tw-border-x-0 tw-transition-all tw-duration-200 tw-ease-out"; + const hoverClass = canEditRep + ? "group-hover:tw-from-[#12141a]/60 group-hover:tw-to-[#0d0f13]/60 group-hover:tw-border-white/[0.16]" + : ""; + return ( - - - {rep.category} - - - {formatNumberWithCommas(rep.rating)} - - - {formatNumberWithCommas(rep.contributor_count)} - - {canEditRep && ( - <> + <> + + {/* Category + progress bar */} + +
      + {rep.category} +
      +
      +
      +
      + + + {/* Community (Rep) */} + + + {formatNumberWithCommas(rep.rating)} + + + + {/* People (Raters) */} + + + {formatNumberWithCommas(rep.contributor_count)} + + + + {/* From You (My Rates) */} + {canEditRep && ( - {rep.rater_contribution - ? formatNumberWithCommas(rep.rater_contribution) - : " "} - - - {isEditRepModalOpen && ( - e.stopPropagation()}> - setIsEditRepModalOpen(false)} - /> - + className={`${cellBase} ${hoverClass} tw-rounded-r-lg tw-border-r tw-border-r-white/[0.08] sm:tw-border-r-white/[0.04] tw-text-right`} + > + {rep.rater_contribution ? ( + 0 + ? "tw-text-primary-400/90 group-hover:tw-text-primary-400" + : "tw-text-rose-400" + }`} + > + {formatNumberWithCommas(rep.rater_contribution)} + + ) : ( + - )} - - + + )} + + + {canEditRep && isEditRepModalOpen && ( + setIsEditRepModalOpen(false)} + /> )} - + ); } diff --git a/components/user/user-page-header/about/UserPageHeaderAboutStatement.tsx b/components/user/user-page-header/about/UserPageHeaderAboutStatement.tsx index dd3aeb16f1..ec05ab0c8c 100644 --- a/components/user/user-page-header/about/UserPageHeaderAboutStatement.tsx +++ b/components/user/user-page-header/about/UserPageHeaderAboutStatement.tsx @@ -25,7 +25,7 @@ export default function UserPageHeaderAboutStatement({ return (
      {statement.statement_value}
      diff --git a/components/user/user-page-header/name/UserPageHeaderName.tsx b/components/user/user-page-header/name/UserPageHeaderName.tsx index 20180de98c..d8fff0fdc2 100644 --- a/components/user/user-page-header/name/UserPageHeaderName.tsx +++ b/components/user/user-page-header/name/UserPageHeaderName.tsx @@ -65,7 +65,7 @@ export default function UserPageHeaderName({

      {profile?.handle && ( -
      +
      )} diff --git a/components/user/utils/CommonProfileLink.tsx b/components/user/utils/CommonProfileLink.tsx index ccc43be975..03b2475929 100644 --- a/components/user/utils/CommonProfileLink.tsx +++ b/components/user/utils/CommonProfileLink.tsx @@ -9,10 +9,12 @@ export default function CommonProfileLink({ handleOrWallet, isCurrentUser, tabTarget, + textClassName, }: { readonly handleOrWallet: string; readonly isCurrentUser: boolean; readonly tabTarget: UserPageTabKey; + readonly textClassName?: string | undefined; }) { const pathname = usePathname(); const url = getProfileTargetRoute({ @@ -20,17 +22,19 @@ export default function CommonProfileLink({ pathname: pathname ?? "", defaultPath: tabTarget, }); + const resolvedTextClassName = + textClassName ?? "tw-text-base tw-font-medium tw-text-iron-200"; return ( + } tw-leading-4 tw-p-0 tw-no-underline hover:tw-no-underline`}> + } tw-whitespace-nowrap hover:tw-text-iron-400 tw-transition tw-duration-300 tw-ease-out ${resolvedTextClassName}`}> {handleOrWallet} diff --git a/components/user/utils/icons/EmailIcon.tsx b/components/user/utils/icons/EmailIcon.tsx index 7eccfc9a34..4a466f7a7d 100644 --- a/components/user/utils/icons/EmailIcon.tsx +++ b/components/user/utils/icons/EmailIcon.tsx @@ -1,7 +1,7 @@ export default function EmailIcon() { return ( + + + + + + ); +} diff --git a/components/user/utils/rate/UserPageRateWrapper.tsx b/components/user/utils/rate/UserPageRateWrapper.tsx index f1d0471f8c..42a08ab780 100644 --- a/components/user/utils/rate/UserPageRateWrapper.tsx +++ b/components/user/utils/rate/UserPageRateWrapper.tsx @@ -29,10 +29,12 @@ export default function UserPageRateWrapper({ profile, type, children, + hideOwnProfileMessage = false, }: { readonly profile: ApiIdentity; readonly type: RateMatter; readonly children: React.ReactNode; + readonly hideOwnProfileMessage?: boolean; }) { const { address } = useSeizeConnectContext(); const { activeProfileProxy, connectedProfile } = useContext(AuthContext); @@ -110,6 +112,10 @@ export default function UserPageRateWrapper({ setRaterContextMessage(getRaterContextMessage(context)); }, [connectedProfile, activeProfileProxy, address, profile, type]); + if (raterContext === RaterContext.MY_PROFILE && hideOwnProfileMessage) { + return null; + } + if (raterContextMessage) { return ; } diff --git a/components/user/utils/rate/UserRateAdjustmentHelper.tsx b/components/user/utils/rate/UserRateAdjustmentHelper.tsx index 2c45432f22..931b019641 100644 --- a/components/user/utils/rate/UserRateAdjustmentHelper.tsx +++ b/components/user/utils/rate/UserRateAdjustmentHelper.tsx @@ -16,7 +16,7 @@ export default function UserRateAdjustmentHelper({ className={`${ inLineValues ? "tw-mt-2 tw-flex tw-flex-wrap tw-gap-y-1 tw-gap-x-4" - : "tw-mt-3 md:tw-mt-0 tw-space-x-4 md:tw-space-x-0 md:-tw-space-y-1 tw-flex md:tw-flex-col" + : "tw-grid tw-grid-cols-2 tw-gap-2 tw-mb-4" } `} > - +
      + {title} - + {valueString}
      diff --git a/components/user/utils/raters-table/ProfileRatersTable.tsx b/components/user/utils/raters-table/ProfileRatersTable.tsx index 96d5f54945..17adedc30a 100644 --- a/components/user/utils/raters-table/ProfileRatersTable.tsx +++ b/components/user/utils/raters-table/ProfileRatersTable.tsx @@ -44,7 +44,7 @@ export default function ProfileRatersTable({ isLoading={loading} onSortTypeClick={onSortTypeClick} /> - +
      diff --git a/components/user/utils/raters-table/ProfileRatersTableBody.tsx b/components/user/utils/raters-table/ProfileRatersTableBody.tsx index 1ff3ec11a3..55169d2fde 100644 --- a/components/user/utils/raters-table/ProfileRatersTableBody.tsx +++ b/components/user/utils/raters-table/ProfileRatersTableBody.tsx @@ -1,14 +1,11 @@ import type { RatingWithProfileInfoAndLevel } from "@/entities/IProfile"; import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; -import type { ProfileRatersTableType } from "@/types/enums"; import ProfileRatersTableItem from "./ProfileRatersTableItem"; export default function ProfileRatersTableBody({ ratings, - type, }: { readonly ratings: RatingWithProfileInfoAndLevel[]; - readonly type: ProfileRatersTableType; }) { return ( @@ -16,7 +13,6 @@ export default function ProfileRatersTableBody({ ))} diff --git a/components/user/utils/raters-table/ProfileRatersTableHeader.tsx b/components/user/utils/raters-table/ProfileRatersTableHeader.tsx index 37412942db..e92b17e2c5 100644 --- a/components/user/utils/raters-table/ProfileRatersTableHeader.tsx +++ b/components/user/utils/raters-table/ProfileRatersTableHeader.tsx @@ -36,7 +36,7 @@ export default function ProfileRatersTableHeader({ return ( - + Name onSortTypeClick(sortType)} - className="tw-group tw-cursor-pointer tw-whitespace-nowrap tw-px-4 tw-py-3.5 tw-text-right tw-text-sm tw-font-medium tw-text-iron-400 sm:tw-px-6 sm:tw-text-md lg:tw-pl-4" + className="tw-group tw-cursor-pointer tw-whitespace-nowrap tw-px-4 tw-py-3.5 tw-text-right tw-text-[11px] tw-font-bold tw-text-iron-500 tw-uppercase tw-tracking-widest sm:tw-px-6 lg:tw-pl-4" > { return rating > 0 @@ -24,19 +20,7 @@ export default function ProfileRatersTableItem({ const ratingColor = isPositiveRating ? "tw-text-green" : "tw-text-red"; const timeAgo = getTimeAgo(new Date(rating.last_modified).getTime()); - const getProfileRoute = (): string => { - switch (type) { - case ProfileRatersTableType.CIC_RECEIVED: - case ProfileRatersTableType.CIC_GIVEN: - return `/${rating.handle}/identity`; - case ProfileRatersTableType.REP_RECEIVED: - case ProfileRatersTableType.REP_GIVEN: - return `/${rating.handle}/rep`; - default: - assertUnreachable(type); - return ""; - } - }; + const getProfileRoute = (): string => `/${rating.handle}/identity`; const profileRoute = getProfileRoute(); diff --git a/components/user/utils/stats/UserStatsRow.tsx b/components/user/utils/stats/UserStatsRow.tsx index 0a06ade895..3688b8f5e4 100644 --- a/components/user/utils/stats/UserStatsRow.tsx +++ b/components/user/utils/stats/UserStatsRow.tsx @@ -59,10 +59,10 @@ export default function UserStatsRow({ href={`/${routeHandle}/collected`} className="tw-no-underline desktop-hover:hover:tw-underline tw-transition tw-duration-300 tw-ease-out" > - + {formatStatFloor(tdh)} {" "} - + TDH {tdh_rate > 0 && ( @@ -81,10 +81,10 @@ export default function UserStatsRow({ href={`/${routeHandle}/xtdh`} className="tw-no-underline desktop-hover:hover:tw-underline tw-transition tw-duration-300 tw-ease-out" > - + {formatStatFloor(xtdh)} {" "} - + xTDH {xtdh_rate > 0 && ( @@ -103,22 +103,22 @@ export default function UserStatsRow({ href={`/${routeHandle}/identity`} className="tw-no-underline desktop-hover:hover:tw-underline tw-transition tw-duration-300 tw-ease-out" > - + {formatStatFloor(cic)} {" "} - + NIC - + {formatStatFloor(rep)} {" "} - + Rep @@ -127,10 +127,10 @@ export default function UserStatsRow({ href={`/${routeHandle}/followers`} className="tw-no-underline desktop-hover:hover:tw-underline tw-transition tw-duration-300 tw-ease-out" > - + {formatNumberWithCommas(count)} {" "} - + {followerLabel} diff --git a/components/user/utils/user-cic-status/UserCICStatus.tsx b/components/user/utils/user-cic-status/UserCICStatus.tsx index fae3a84bb3..88677a41ad 100644 --- a/components/user/utils/user-cic-status/UserCICStatus.tsx +++ b/components/user/utils/user-cic-status/UserCICStatus.tsx @@ -27,7 +27,7 @@ export const CIC_META: Record = { }, [CICType.HIGHLY_ACCURATE]: { title: CIC_TO_TEXT[CICType.HIGHLY_ACCURATE], - class: "tw-text-[#3CCB7F]", + class: "tw-text-emerald-400", }, }; diff --git a/components/user/utils/user-cic-type/icons/UserCICAccurateIcon.tsx b/components/user/utils/user-cic-type/icons/UserCICAccurateIcon.tsx index f162bccd8f..87819db751 100644 --- a/components/user/utils/user-cic-type/icons/UserCICAccurateIcon.tsx +++ b/components/user/utils/user-cic-type/icons/UserCICAccurateIcon.tsx @@ -1,7 +1,7 @@ export default function UserCICAccurateIcon() { return (
      -
      -

      +
      +

      {message}

      diff --git a/components/utils/select/tabs/CommonTabs.tsx b/components/utils/select/tabs/CommonTabs.tsx index 7e090b49a8..af102a930f 100644 --- a/components/utils/select/tabs/CommonTabs.tsx +++ b/components/utils/select/tabs/CommonTabs.tsx @@ -16,6 +16,7 @@ export default function CommonTabs( const sortDirection = "sortDirection" in props ? props.sortDirection : undefined; const disabled = "disabled" in props ? props.disabled ?? false : false; + const size = "size" in props ? props.size : undefined; const scrollContainerRef = useRef(null); @@ -160,7 +161,7 @@ export default function CommonTabs( }} disabled={disabled} fill={props.fill ?? true} - + size={size} /> ))}

      diff --git a/components/utils/select/tabs/CommonTabsTab.tsx b/components/utils/select/tabs/CommonTabsTab.tsx index 0131a56e7f..e8d58ede7f 100644 --- a/components/utils/select/tabs/CommonTabsTab.tsx +++ b/components/utils/select/tabs/CommonTabsTab.tsx @@ -19,7 +19,7 @@ export default function CommonTabsTab( | undefined; readonly disabled?: boolean | undefined; readonly fill?: boolean | undefined; - readonly size?: "md" | "tabs" | undefined; + readonly size?: "sm" | "md" | "tabs" | undefined; } > ) { @@ -32,6 +32,7 @@ export default function CommonTabsTab( buttonRef, disabled = false, fill = true, + size = "md", } = props; const getIsActive = (): boolean => item.value === activeItem; @@ -101,7 +102,7 @@ export default function CommonTabsTab( } ${ fill ? "tw-flex-1" : "" } ${ - "tw-px-3 tw-py-1.5 tw-text-sm" + size === "sm" ? "tw-px-2 tw-py-1 tw-text-xs" : "tw-px-3 tw-py-1.5 tw-text-sm" } tw-whitespace-nowrap tw-leading-5 tw-font-medium tw-border-0 tw-rounded-lg tw-transition-all tw-duration-300 tw-ease-out tw-flex tw-items-center tw-justify-center tw-gap-2`} > {item.label} diff --git a/components/utils/table/paginator/CommonTablePagination.tsx b/components/utils/table/paginator/CommonTablePagination.tsx index 9568d4f9ef..e8e2416742 100644 --- a/components/utils/table/paginator/CommonTablePagination.tsx +++ b/components/utils/table/paginator/CommonTablePagination.tsx @@ -5,6 +5,8 @@ export default function CommonTablePagination({ totalPages, haveNextPage, loading = false, + showTopBorder = true, + className, }: { readonly small: boolean; readonly currentPage: number; @@ -12,14 +14,16 @@ export default function CommonTablePagination({ readonly totalPages: number | null; readonly haveNextPage: boolean; readonly loading?: boolean | undefined; + readonly showTopBorder?: boolean | undefined; + readonly className?: string | undefined; }) { return (
      {typeof totalPages === "number" ? ( diff --git a/helpers/profile-logs.helpers.ts b/helpers/profile-logs.helpers.ts index f36f37d057..90de6f0dfe 100644 --- a/helpers/profile-logs.helpers.ts +++ b/helpers/profile-logs.helpers.ts @@ -5,6 +5,7 @@ import type { import { ProfileActivityFilterTargetType, ProfileActivityLogType, + RateMatter, } from "@/types/enums"; const DISABLED_LOG_TYPES = [ @@ -56,7 +57,7 @@ export const convertActivityLogParams = ({ : "", }; - if (params.matter) { + if (params.matter === RateMatter.REP) { converted.rating_matter = params.matter; } if (params.groupId && !params.handleOrWallet && !disableActiveGroup) { diff --git a/hooks/useRepAllocation.ts b/hooks/useRepAllocation.ts new file mode 100644 index 0000000000..213c0c8d8f --- /dev/null +++ b/hooks/useRepAllocation.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useContext, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AuthContext } from "@/components/auth/Auth"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { commonApiFetch } from "@/services/api/common-api"; +import type { + ApiProfileRepRatesState, + RatingStats, +} from "@/entities/IProfile"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { ApiProfileProxyActionType } from "@/generated/models/ApiProfileProxyActionType"; + +export function useRepAllocation({ + profile, + category, +}: { + readonly profile: ApiIdentity; + readonly category: string | null; +}): { + repState: RatingStats | null; + heroAvailableRep: number; + proxyAvailableCredit: number | null; + minMaxValues: { readonly min: number; readonly max: number }; +} { + const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + + const { data: proxyGrantorRepRates } = useQuery({ + queryKey: [ + QueryKey.PROFILE_REP_RATINGS, + { + rater: activeProfileProxy?.created_by.handle, + handleOrWallet: profile?.handle, + }, + ], + queryFn: async () => + await commonApiFetch({ + endpoint: `profiles/${profile?.query}/rep/ratings/received`, + params: activeProfileProxy?.created_by.handle + ? { rater: activeProfileProxy.created_by.handle } + : {}, + }), + enabled: !!activeProfileProxy?.created_by.handle, + }); + + const { data: connectedProfileRepRates } = + useQuery({ + queryKey: [ + QueryKey.PROFILE_REP_RATINGS, + { + rater: connectedProfile?.handle, + handleOrWallet: profile?.handle, + }, + ], + queryFn: async () => + await commonApiFetch({ + endpoint: `profiles/${profile?.query}/rep/ratings/received`, + params: connectedProfile?.handle + ? { rater: connectedProfile.handle } + : {}, + }), + enabled: !!connectedProfile?.handle, + }); + + const getRepState = (): RatingStats | null => { + if (!category) return null; + + if (activeProfileProxy && proxyGrantorRepRates) { + const target = proxyGrantorRepRates.rating_stats.find( + (s) => s.category === category + ); + return ( + target ?? { + category, + rating: 0, + contributor_count: 0, + rater_contribution: 0, + } + ); + } + + if (activeProfileProxy) return null; + + if (connectedProfileRepRates) { + const target = connectedProfileRepRates.rating_stats.find( + (s) => s.category === category + ); + return ( + target ?? { + category, + rating: 0, + contributor_count: 0, + rater_contribution: 0, + } + ); + } + + return null; + }; + + const getProxyAvailableCredit = (): number | null => { + const repProxy = activeProfileProxy?.actions.find( + (a) => a.action_type === ApiProfileProxyActionType.AllocateRep + ); + if (!repProxy) return null; + return Math.max( + (repProxy.credit_amount ?? 0) - (repProxy.credit_spent ?? 0), + 0 + ); + }; + + const getHeroAvailableRep = (): number => { + if (activeProfileProxy) { + return proxyGrantorRepRates?.rep_rates_left_for_rater ?? 0; + } + return connectedProfileRepRates?.rep_rates_left_for_rater ?? 0; + }; + + const [repState, setRepState] = useState(getRepState()); + const [proxyAvailableCredit, setProxyAvailableCredit] = useState< + number | null + >(getProxyAvailableCredit()); + const [heroAvailableRep, setHeroAvailableRep] = useState( + getHeroAvailableRep() + ); + + useEffect(() => { + setRepState(getRepState()); + }, [ + category, + proxyGrantorRepRates, + activeProfileProxy, + connectedProfileRepRates, + ]); + + useEffect( + () => setProxyAvailableCredit(getProxyAvailableCredit()), + [activeProfileProxy] + ); + + useEffect( + () => setHeroAvailableRep(getHeroAvailableRep()), + [activeProfileProxy, proxyGrantorRepRates, connectedProfileRepRates] + ); + + const getMinValue = (): number => { + const currentRep = repState?.rater_contribution ?? 0; + const minHeroRep = 0 - (Math.abs(currentRep) + heroAvailableRep); + if (typeof proxyAvailableCredit !== "number") return minHeroRep; + const minProxyRep = currentRep - proxyAvailableCredit; + return Math.abs(minHeroRep) < Math.abs(minProxyRep) + ? minHeroRep + : minProxyRep; + }; + + const getMaxValue = (): number => { + const currentRep = repState?.rater_contribution ?? 0; + const maxHeroRep = Math.abs(currentRep) + heroAvailableRep; + if (typeof proxyAvailableCredit !== "number") return maxHeroRep; + const maxProxyRep = currentRep + proxyAvailableCredit; + return Math.min(maxHeroRep, maxProxyRep); + }; + + const [minMaxValues, setMinMaxValues] = useState<{ + readonly min: number; + readonly max: number; + }>({ min: getMinValue(), max: getMaxValue() }); + + useEffect( + () => setMinMaxValues({ min: getMinValue(), max: getMaxValue() }), + [repState, proxyGrantorRepRates, proxyAvailableCredit, heroAvailableRep] + ); + + return { repState, heroAvailableRep, proxyAvailableCredit, minMaxValues }; +}