diff --git a/__tests__/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.test.tsx b/__tests__/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.test.tsx new file mode 100644 index 0000000000..15660e6596 --- /dev/null +++ b/__tests__/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.test.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus"; +import GroupCreateXtdhGrantModal from "@/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal"; +import type { ApiXTdhGrant } from "@/generated/models/ApiXTdhGrant"; + +jest.mock("react-dom", () => ({ + ...jest.requireActual("react-dom"), + createPortal: (node: any) => node, +})); + +const mockUseXtdhGrantsSearchQuery = jest.fn(); +jest.mock("@/hooks/useXtdhGrantsSearchQuery", () => ({ + useXtdhGrantsSearchQuery: (...args: unknown[]) => + mockUseXtdhGrantsSearchQuery(...args), +})); + +const identitySearchMock = jest.fn( + (props: { setIdentity: (value: string) => void }) => ( + + ) +); + +jest.mock("@/components/utils/input/identity/IdentitySearch", () => ({ + __esModule: true, + IdentitySearchSize: { + SM: "SM", + MD: "MD", + }, + default: (props: { setIdentity: (value: string) => void }) => + identitySearchMock(props), +})); + +const queryState = () => ({ + grants: [], + totalCount: 0, + isLoading: false, + isError: false, + errorMessage: undefined, + refetch: jest.fn().mockResolvedValue(undefined), + hasNextPage: false, + fetchNextPage: jest.fn(), + isFetchingNextPage: false, +}); + +const renderModal = ({ + isOpen = true, + onClose = jest.fn(), + onGrantSelect = () => undefined, +}: { + isOpen?: boolean; + onClose?: () => void; + onGrantSelect?: (grant: ApiXTdhGrant) => void; +} = {}) => + render( + + ); + +describe("GroupCreateXtdhGrantModal", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseXtdhGrantsSearchQuery.mockReturnValue(queryState()); + }); + + it("does not send grantor filter until an identity is selected", () => { + renderModal(); + + expect(mockUseXtdhGrantsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + grantor: null, + targetCollectionName: null, + statuses: [ApiXTdhGrantStatus.Granted], + enabled: true, + pageSize: 20, + }) + ); + }); + + it("uses the selected identity wallet as grantor filter", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByTestId("grantor-select")); + + expect(mockUseXtdhGrantsSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ + grantor: "0xabc123", + }) + ); + }); + + it("clears selected grantor when filters are reset", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByTestId("grantor-select")); + expect(mockUseXtdhGrantsSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ + grantor: "0xabc123", + }) + ); + + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(mockUseXtdhGrantsSearchQuery).toHaveBeenLastCalledWith( + expect.objectContaining({ + grantor: null, + }) + ); + }); + + it("keeps collection input accessible by name without visible label", () => { + renderModal(); + + expect( + screen.getByRole("textbox", { name: "Collection name" }) + ).toBeInTheDocument(); + }); + + it("closes when Escape is pressed while open", () => { + const onClose = jest.fn(); + renderModal({ onClose }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close when Escape is pressed while closed", () => { + const onClose = jest.fn(); + renderModal({ isOpen: false, onClose }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/components/groups/page/create/GroupCreate.tsx b/components/groups/page/create/GroupCreate.tsx index f41ce068e2..d7c37d5a29 100644 --- a/components/groups/page/create/GroupCreate.tsx +++ b/components/groups/page/create/GroupCreate.tsx @@ -75,14 +75,14 @@ export default function GroupCreate({ const [isFetching, setIsFetching] = useState( loadingOriginalGroup || - loadingOriginalGroupWallets || - loadingOriginalGroupExcludedWallets + loadingOriginalGroupWallets || + loadingOriginalGroupExcludedWallets ); useEffect(() => { setIsFetching( loadingOriginalGroup || - loadingOriginalGroupWallets || - loadingOriginalGroupExcludedWallets + loadingOriginalGroupWallets || + loadingOriginalGroupExcludedWallets ); }, [ loadingOriginalGroup, @@ -115,6 +115,7 @@ export default function GroupCreate({ owns_nfts: [], identity_addresses: null, excluded_identity_addresses: null, + is_beneficiary_of_grant_id: null, }, is_private: false, }); @@ -155,6 +156,8 @@ export default function GroupCreate({ owns_nfts: originalGroup.group.owns_nfts, identity_addresses: originalGroupWallets ?? [], excluded_identity_addresses: originalGroupExcludedWallets ?? [], + is_beneficiary_of_grant_id: + originalGroup.group.is_beneficiary_of_grant_id ?? null, }, is_private: originalGroup.is_private ?? false, }); @@ -211,7 +214,7 @@ export default function GroupCreate({
-
+
@@ -244,6 +247,7 @@ export default function GroupCreate({ wallets={groupConfig.group.identity_addresses} excludeWallets={groupConfig.group.excluded_identity_addresses} nfts={groupConfig.group.owns_nfts} + beneficiaryGrantId={groupConfig.group.is_beneficiary_of_grant_id} iAmIncluded={iAmIncluded} setLevel={(level) => setGroupConfig((prev) => ({ @@ -293,6 +297,15 @@ export default function GroupCreate({ group: { ...prev.group, owns_nfts: nfts }, })) } + setBeneficiaryGrantId={(grantId) => + setGroupConfig((prev) => ({ + ...prev, + group: { + ...prev.group, + is_beneficiary_of_grant_id: grantId ?? null, + }, + })) + } /> void; readonly setTDH: (tdh: ApiCreateGroupDescription["tdh"]) => void; @@ -46,18 +50,25 @@ export default function GroupCreateConfig({ wallets: ApiCreateGroupDescription["excluded_identity_addresses"] ) => void; readonly setNfts: (nfts: ApiCreateGroupDescription["owns_nfts"]) => void; + readonly setBeneficiaryGrantId: ( + grantId: ApiCreateGroupDescription["is_beneficiary_of_grant_id"] + ) => void; }) { return (
-
+
-
+
+
diff --git a/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrant.tsx b/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrant.tsx new file mode 100644 index 0000000000..e8d206290a --- /dev/null +++ b/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrant.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import { useDebounce } from "react-use"; +import type { ApiCreateGroupDescription } from "@/generated/models/ApiCreateGroupDescription"; +import { useXtdhGrantQuery } from "@/hooks/useXtdhGrantQuery"; +import GroupCreateXtdhGrantModal from "./GroupCreateXtdhGrantModal"; +import GroupCreateXtdhGrantRow from "./subcomponents/GroupCreateXtdhGrantRow"; +import { isSelectableNonGrantedStatus, toShortGrantId } from "./utils"; + +interface GroupCreateXtdhGrantProps { + readonly beneficiaryGrantId: ApiCreateGroupDescription["is_beneficiary_of_grant_id"]; + readonly setBeneficiaryGrantId: ( + grantId: ApiCreateGroupDescription["is_beneficiary_of_grant_id"] + ) => void; +} + +export default function GroupCreateXtdhGrant({ + beneficiaryGrantId, + setBeneficiaryGrantId, +}: GroupCreateXtdhGrantProps) { + const normalizedGrantId = beneficiaryGrantId?.trim() ?? ""; + const hasSelectedGrant = normalizedGrantId.length > 0; + + const [lookupGrantId, setLookupGrantId] = useState( + hasSelectedGrant ? normalizedGrantId : null + ); + const [isModalOpen, setIsModalOpen] = useState(false); + + useDebounce( + () => { + setLookupGrantId(hasSelectedGrant ? normalizedGrantId : null); + }, + 250, + [hasSelectedGrant, normalizedGrantId] + ); + + const { grant, isFetching, isError, errorMessage } = useXtdhGrantQuery({ + grantId: lookupGrantId, + enabled: !!lookupGrantId, + }); + + const isLookupFresh = lookupGrantId === normalizedGrantId; + const showNonGrantedWarning = + isLookupFresh && + grant?.status !== undefined && + isSelectableNonGrantedStatus(grant.status); + const showLookupError = isLookupFresh && Boolean(lookupGrantId && isError); + + const onInputChange = (nextValue: string) => { + const normalized = nextValue.trim(); + setBeneficiaryGrantId(normalized.length ? normalized : null); + }; + + const onClearSelection = () => { + setLookupGrantId(null); + setBeneficiaryGrantId(null); + }; + + return ( +
+
+

+ xTDH Grant Beneficiary +

+

+ Optionally require identities to be beneficiaries of a selected xTDH + grant. +

+
+ +
+ + + +
+ + {isFetching && !!lookupGrantId && ( +

+ Validating grant... +

+ )} + + {showLookupError && ( +
+

+ {errorMessage ?? "Unable to resolve grant ID."} +

+

+ The ID will still be submitted as entered:{" "} + + {toShortGrantId(lookupGrantId)} + +

+
+ )} + + {isLookupFresh && !!grant && ( + + )} + + {showNonGrantedWarning && ( +
+

+ Selected grant status is not GRANTED. This filter is still allowed + and will be submitted. +

+
+ )} + + setIsModalOpen(false)} + onGrantSelect={(selectedGrant) => { + setBeneficiaryGrantId(selectedGrant.id); + setLookupGrantId(selectedGrant.id); + setIsModalOpen(false); + }} + /> +
+ ); +} diff --git a/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.tsx b/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.tsx new file mode 100644 index 0000000000..a185e04009 --- /dev/null +++ b/components/groups/page/create/config/xtdh-grant/GroupCreateXtdhGrantModal.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useClickAway, useDebounce } from "react-use"; +import type { CommonSelectItem } from "@/components/utils/select/CommonSelect"; +import IdentitySearch, { + IdentitySearchSize, +} from "@/components/utils/input/identity/IdentitySearch"; +import type { ApiXTdhGrant } from "@/generated/models/ApiXTdhGrant"; +import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus"; +import { useXtdhGrantsSearchQuery } from "@/hooks/useXtdhGrantsSearchQuery"; +import GroupCreateXtdhGrantRow from "./subcomponents/GroupCreateXtdhGrantRow"; + +interface GroupCreateXtdhGrantModalProps { + readonly isOpen: boolean; + readonly selectedGrantId: string | null; + readonly onClose: () => void; + readonly onGrantSelect: (grant: ApiXTdhGrant) => void; +} + +const STATUS_OPTIONS: readonly CommonSelectItem[] = [ + { + key: ApiXTdhGrantStatus.Granted, + value: ApiXTdhGrantStatus.Granted, + label: "Granted", + }, + { + key: ApiXTdhGrantStatus.Pending, + value: ApiXTdhGrantStatus.Pending, + label: "Pending", + }, + { + key: ApiXTdhGrantStatus.Disabled, + value: ApiXTdhGrantStatus.Disabled, + label: "Revoked", + }, + { + key: ApiXTdhGrantStatus.Failed, + value: ApiXTdhGrantStatus.Failed, + label: "Failed", + }, +]; + +export default function GroupCreateXtdhGrantModal({ + isOpen, + selectedGrantId, + onClose, + onGrantSelect, +}: GroupCreateXtdhGrantModalProps) { + const modalRef = useRef(null); + const skipInitialOutsideClick = useRef(true); + + const [selectedGrantor, setSelectedGrantor] = useState(null); + const [targetCollectionInput, setTargetCollectionInput] = useState(""); + const [targetCollectionFilter, setTargetCollectionFilter] = useState(""); + const [selectedStatus, setSelectedStatus] = useState( + ApiXTdhGrantStatus.Granted + ); + + useEffect(() => { + if (isOpen) { + skipInitialOutsideClick.current = true; + } + + const timeout = globalThis.setTimeout(() => { + skipInitialOutsideClick.current = false; + }, 0); + + return () => { + globalThis.clearTimeout(timeout); + }; + }, [isOpen]); + + useDebounce( + () => setTargetCollectionFilter(targetCollectionInput.trim()), + 250, + [targetCollectionInput] + ); + + useClickAway(modalRef, () => { + if (skipInitialOutsideClick.current) { + skipInitialOutsideClick.current = false; + return; + } + onClose(); + }); + useEffect(() => { + if (!isOpen) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + globalThis.addEventListener("keydown", handleEscape); + + return () => { + globalThis.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + const { + grants, + totalCount, + isLoading, + isError, + errorMessage, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useXtdhGrantsSearchQuery({ + grantor: selectedGrantor, + targetCollectionName: targetCollectionFilter || null, + statuses: [selectedStatus], + enabled: isOpen, + pageSize: 20, + }); + + const onResetFilters = () => { + setSelectedGrantor(null); + setTargetCollectionInput(""); + setTargetCollectionFilter(""); + setSelectedStatus(ApiXTdhGrantStatus.Granted); + }; + + if (!isOpen) { + return null; + } + + return createPortal( +
+
+
+
+
+
+
+

+ Find xTDH Grant +

+

+ Search global grants and select one for beneficiary filtering. +

+
+ +
+ +
+
+
+ + setSelectedGrantor( + identity ? identity.toLowerCase() : null + ) + } + /> +
+ + setTargetCollectionInput(event.target.value) + } + placeholder="Collection name" + aria-label="Collection name" + className="tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-py-2.5 tw-text-sm tw-text-iron-50 tw-ring-1 tw-ring-inset tw-ring-iron-700 placeholder:tw-text-iron-500 focus:tw-ring-primary-400" + /> +
+ +
+
+ + Status + +
+ {STATUS_OPTIONS.map((statusOption) => { + const isActive = selectedStatus === statusOption.value; + return ( + + ); + })} +
+
+ +
+ +
+
+

+ Results +

+

+ {totalCount} total +

+
+ +
+ {isLoading && !grants.length && ( +

+ Loading grants... +

+ )} + + {isError && !grants.length && ( +
+

+ {errorMessage ?? "Unable to load grants."} +

+ +
+ )} + + {!isLoading && !isError && !grants.length && ( +

+ No grants matched the selected filters. +

+ )} + + {!!grants.length && ( +
    + {grants.map((grant) => { + return ( + + ); + })} +
+ )} +
+ + {hasNextPage && ( +
+ +
+ )} +
+
+
+
+
+
, + document.body + ); +} diff --git a/components/groups/page/create/config/xtdh-grant/subcomponents/GroupCreateXtdhGrantRow.tsx b/components/groups/page/create/config/xtdh-grant/subcomponents/GroupCreateXtdhGrantRow.tsx new file mode 100644 index 0000000000..96a3c75fad --- /dev/null +++ b/components/groups/page/create/config/xtdh-grant/subcomponents/GroupCreateXtdhGrantRow.tsx @@ -0,0 +1,227 @@ +"use client"; + +import type { KeyboardEvent } from "react"; +import ProfileBadge from "@/components/common/profile/ProfileBadge"; +import { ProfileBadgeSize } from "@/components/common/profile/ProfileAvatar"; +import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileTooltipWrapper"; +import type { ApiXTdhGrant } from "@/generated/models/ApiXTdhGrant"; +import { cicToType } from "@/helpers/Helpers"; +import { + isAutoGeneratedHandle, + isEthereumAddress, +} from "@/helpers/AllowlistToolHelpers"; +import { shortenAddress } from "@/helpers/address.helpers"; +import { + formatAmount, + formatDateTime, +} from "@/components/user/xtdh/utils/xtdhGrantFormatters"; +import { getGrantStatusLabel } from "../utils"; + +interface GroupCreateXtdhGrantRowProps { + readonly grant: ApiXTdhGrant; + readonly isSelected?: boolean | undefined; + readonly interactive?: boolean | undefined; + readonly asListItem?: boolean | undefined; + readonly className?: string | undefined; + readonly onSelect?: ((grant: ApiXTdhGrant) => void) | undefined; +} + +const getStatusPillClasses = (statusLabel: string): string => { + if (statusLabel === "ACTIVE") { + return "tw-bg-green/20 tw-text-green"; + } + if (statusLabel === "SCHEDULED") { + return "tw-bg-blue-400/20 tw-text-blue-200"; + } + if (statusLabel === "ENDED") { + return "tw-bg-iron-700/30 tw-text-iron-400"; + } + if (statusLabel === "PENDING") { + return "tw-bg-primary-400/20 tw-text-primary-300"; + } + return "tw-bg-red/20 tw-text-red"; +}; + +const getStateClasses = (interactive: boolean, isSelected: boolean): string => { + if (interactive && isSelected) { + return "tw-cursor-pointer tw-border-primary-400 tw-bg-primary-500/10"; + } + if (interactive) { + return "tw-cursor-pointer tw-border-iron-800 tw-bg-iron-900/70 desktop-hover:hover:tw-border-iron-700 desktop-hover:hover:tw-bg-iron-900"; + } + if (isSelected) { + return "tw-border-primary-400 tw-bg-primary-500/10"; + } + return "tw-border-iron-800 tw-bg-iron-900/60"; +}; + +const getRowClasses = ({ + asListItem, + interactive, + stateClasses, + className, +}: { + readonly asListItem: boolean; + readonly interactive: boolean; + readonly stateClasses: string; + readonly className: string | undefined; +}): string => + `${asListItem ? "tw-list-none " : ""}tw-rounded-lg tw-border tw-border-solid tw-p-3 tw-outline-none tw-transition tw-duration-200 ${interactive ? "focus-visible:tw-ring-2 focus-visible:tw-ring-primary-400" : ""} ${stateClasses} ${className ?? ""}`.trim(); + +const getInteractiveProps = ( + interactive: boolean, + handleSelect: () => void, + handleKeyDown: (event: KeyboardEvent) => void +) => { + if (!interactive) { + return { + role: undefined, + tabIndex: undefined, + onClick: undefined, + onKeyDown: undefined, + }; + } + + return { + role: "button" as const, + tabIndex: 0, + onClick: handleSelect, + onKeyDown: handleKeyDown, + }; +}; + +const isActivationKey = (key: string): boolean => + key === "Enter" || key === " "; + +export default function GroupCreateXtdhGrantRow({ + grant, + isSelected = false, + interactive = false, + asListItem = false, + className, + onSelect, +}: GroupCreateXtdhGrantRowProps) { + const statusLabel = getGrantStatusLabel({ + status: grant.status, + validFrom: grant.valid_from, + validTo: grant.valid_to, + }); + + const trimmedCollectionName = grant.target_collection_name?.trim() ?? ""; + const targetLabel = + trimmedCollectionName.length > 0 + ? trimmedCollectionName + : grant.target_contract || "Unknown target"; + + const grantor = grant.grantor; + const grantorHandle = grantor.handle; + const grantorPrimaryAddress = grantor.primary_address; + const displayGrantor = grantorHandle ?? shortenAddress(grantorPrimaryAddress); + const shouldTruncateGrantor = + isEthereumAddress(grantorHandle ?? grantorPrimaryAddress) || + isAutoGeneratedHandle(grantorHandle ?? grantorPrimaryAddress); + const tooltipIdentity = grantorHandle ?? grantor.id; + const profileHref = grantorHandle ? `/${grantorHandle}` : undefined; + + const avatarFallback = ( +
+ ? +
+ ); + + const grantorBadge = ( + div>div]:tw-min-w-0 [&>div]:tw-min-w-0 [&_p]:tw-truncate" + : "" + }`} + avatarAlt={grantorHandle ?? "Grantor profile"} + avatarFallback={avatarFallback} + asLink={Boolean(profileHref)} + /> + ); + + const grantorSummary = tooltipIdentity ? ( + + {grantorBadge} + + ) : ( + grantorBadge + ); + + const handleSelect = () => onSelect?.(grant); + + const handleKeyDown = (event: KeyboardEvent) => { + if (!interactive || !isActivationKey(event.key)) { + return; + } + event.preventDefault(); + handleSelect(); + }; + + const stateClasses = getStateClasses(interactive, isSelected); + const rowClasses = getRowClasses({ + asListItem, + interactive, + stateClasses, + className, + }); + const interactiveProps = getInteractiveProps( + interactive, + handleSelect, + handleKeyDown + ); + const ContainerTag = asListItem ? "li" : "div"; + + const rowContent = ( +
+
+
+ + {statusLabel} + +

+ {targetLabel} +

+
+ +
+
{grantorSummary}
+

+ Rate: {formatAmount(grant.rate)} +

+
+ +

+ Valid:{" "} + {formatDateTime(grant.valid_from ?? null, { + fallbackLabel: "Immediately", + includeTime: false, + })}{" "} + {"->"}{" "} + {formatDateTime(grant.valid_to ?? null, { + fallbackLabel: "No expiry", + includeTime: false, + })} +

+
+
+ ); + + return ( + + {rowContent} + + ); +} diff --git a/components/groups/page/create/config/xtdh-grant/utils.ts b/components/groups/page/create/config/xtdh-grant/utils.ts new file mode 100644 index 0000000000..5cdf39cb8e --- /dev/null +++ b/components/groups/page/create/config/xtdh-grant/utils.ts @@ -0,0 +1,47 @@ +import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus"; + +export const getGrantStatusLabel = ({ + status, + validFrom, + validTo, +}: { + readonly status: ApiXTdhGrantStatus; + readonly validFrom?: number | null | undefined; + readonly validTo?: number | null | undefined; +}): string => { + if (status === ApiXTdhGrantStatus.Granted) { + const now = Date.now(); + const normalizedValidFrom = validFrom ?? null; + const normalizedValidTo = validTo ?? null; + + if ( + typeof normalizedValidTo === "number" && + normalizedValidTo > 0 && + normalizedValidTo < now + ) { + return "ENDED"; + } + if (typeof normalizedValidFrom === "number" && normalizedValidFrom > now) { + return "SCHEDULED"; + } + return "ACTIVE"; + } + + if (status === ApiXTdhGrantStatus.Disabled) { + return "REVOKED"; + } + + return status; +}; + +export const isSelectableNonGrantedStatus = ( + status: ApiXTdhGrantStatus +): boolean => status !== ApiXTdhGrantStatus.Granted; + +export const toShortGrantId = (grantId: string): string => { + const normalizedGrantId = grantId.trim(); + if (normalizedGrantId.length <= 16) { + return normalizedGrantId; + } + return `${normalizedGrantId.slice(0, 8)}...${normalizedGrantId.slice(-4)}`; +}; diff --git a/components/groups/page/list/card/GroupCardConfig.tsx b/components/groups/page/list/card/GroupCardConfig.tsx index bb962d8f18..ca66e5656b 100644 --- a/components/groups/page/list/card/GroupCardConfig.tsx +++ b/components/groups/page/list/card/GroupCardConfig.tsx @@ -13,6 +13,7 @@ export default function GroupCardConfig({ [GroupDescriptionType.LEVEL]: "Level", [GroupDescriptionType.OWNS_NFTS]: "Owns NFTs", [GroupDescriptionType.WALLETS]: "Manual list", + [GroupDescriptionType.XTDH_GRANT]: "Grant", }; return ( diff --git a/components/groups/page/list/card/GroupCardConfigs.tsx b/components/groups/page/list/card/GroupCardConfigs.tsx index 70edbc7bbc..5573aefe1a 100644 --- a/components/groups/page/list/card/GroupCardConfigs.tsx +++ b/components/groups/page/list/card/GroupCardConfigs.tsx @@ -6,6 +6,8 @@ import type { ApiGroupDescription } from "@/generated/models/ApiGroupDescription import { ApiGroupFilterDirection } from "@/generated/models/ApiGroupFilterDirection"; import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; import { ApiGroupTdhInclusionStrategy } from "@/generated/models/ApiGroupTdhInclusionStrategy"; +import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus"; +import { toShortGrantId } from "@/components/groups/page/create/config/xtdh-grant/utils"; import GroupCardConfig from "./GroupCardConfig"; export interface GroupCardConfigProps { @@ -19,11 +21,23 @@ export interface GroupCardConfigProps { const MANUAL_LIST_TOOLTIP = "Wallets explicitly listed in this group. Filter-matching wallets are not counted here."; +const GRANT_TOOLTIP = + "Identity must be a beneficiary of the selected xTDH grant."; + +const GRANT_STATUS_LABELS: Record = { + [ApiXTdhGrantStatus.Pending]: "PENDING", + [ApiXTdhGrantStatus.Failed]: "FAILED", + [ApiXTdhGrantStatus.Disabled]: "REVOKED", + [ApiXTdhGrantStatus.Granted]: "GRANTED", +}; + export default function GroupCardConfigs({ group, }: { readonly group?: ApiGroupFull | undefined; }) { + const [nowMs] = useState(() => Date.now()); + const directionLabels: Record = { [ApiGroupFilterDirection.Received]: "from", [ApiGroupFilterDirection.Sent]: "to", @@ -151,6 +165,53 @@ export default function GroupCardConfigs({ }; }; + const getGrantStatusLabel = ( + grant: ApiGroupDescription["is_beneficiary_of_grant"] + ): string | null => { + if (grant?.status === undefined) { + return null; + } + + if (grant.status === ApiXTdhGrantStatus.Granted) { + const from = grant.valid_from ?? null; + const to = grant.valid_to ?? null; + + if (typeof to === "number" && to > 0 && to < nowMs) { + return "ENDED"; + } + if (typeof from === "number" && from > nowMs) { + return "SCHEDULED"; + } + return "ACTIVE"; + } + + return GRANT_STATUS_LABELS[grant.status]; + }; + + const getGrantConfig = ( + groupDescription: ApiGroupDescription + ): GroupCardConfigProps | null => { + const grantId = groupDescription.is_beneficiary_of_grant_id; + if (!grantId) { + return null; + } + + const statusLabel = getGrantStatusLabel( + groupDescription.is_beneficiary_of_grant + ); + const shortGrantId = toShortGrantId(grantId); + const value = statusLabel + ? `${statusLabel} (${shortGrantId})` + : shortGrantId; + + return { + key: GroupDescriptionType.XTDH_GRANT, + value, + label: "Grant", + tooltip: GRANT_TOOLTIP, + }; + }; + const getConfigs = (): GroupCardConfigProps[] => { if (!group) { return [ @@ -170,11 +231,13 @@ export default function GroupCardConfigs({ const repConfig = getRepConfig(rep); const cicConfig = getCicConfig(cic); const levelConfig = getLevelConfig(level); + const grantConfig = getGrantConfig(group.group); const walletsConfig = getWalletsConfig(identity_group_identities_count); if (tdhConfig) configs.push(tdhConfig); if (repConfig) configs.push(repConfig); if (cicConfig) configs.push(cicConfig); if (levelConfig) configs.push(levelConfig); + if (grantConfig) configs.push(grantConfig); configs.push(walletsConfig); return configs; diff --git a/entities/IGroup.ts b/entities/IGroup.ts index ffa17d1fd3..16625a784b 100644 --- a/entities/IGroup.ts +++ b/entities/IGroup.ts @@ -11,4 +11,5 @@ export enum GroupDescriptionType { LEVEL = "LEVEL", OWNS_NFTS = "OWNS_NFTS", WALLETS = "WALLETS", + XTDH_GRANT = "XTDH_GRANT", } diff --git a/generated/models/ApiSeizeSettings.ts b/generated/models/ApiSeizeSettings.ts index 3bbfd6b253..12f3bd5e22 100644 --- a/generated/models/ApiSeizeSettings.ts +++ b/generated/models/ApiSeizeSettings.ts @@ -17,6 +17,7 @@ export class ApiSeizeSettings { 'rememes_submission_tdh_threshold': number; 'all_drops_notifications_subscribers_limit': number; 'memes_wave_id': string | null; + 'curation_wave_id': string | null; static readonly discriminator: string | undefined = undefined; @@ -40,6 +41,12 @@ export class ApiSeizeSettings { "baseName": "memes_wave_id", "type": "string", "format": "" + }, + { + "name": "curation_wave_id", + "baseName": "curation_wave_id", + "type": "string", + "format": "" } ]; static getAttributeTypeMap() { diff --git a/hooks/useXtdhGrantsSearchQuery.ts b/hooks/useXtdhGrantsSearchQuery.ts new file mode 100644 index 0000000000..dc7a8a335e --- /dev/null +++ b/hooks/useXtdhGrantsSearchQuery.ts @@ -0,0 +1,191 @@ +"use client"; + +import { useMemo } from "react"; +import { + keepPreviousData, + type InfiniteData, + type UseInfiniteQueryResult, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { SortDirection } from "@/entities/ISort"; +import type { ApiXTdhGrant } from "@/generated/models/ApiXTdhGrant"; +import type { ApiXTdhGrantsPage } from "@/generated/models/ApiXTdhGrantsPage"; +import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus"; +import { commonApiFetch } from "@/services/api/common-api"; + +type XtdhGrantSortField = "created_at" | "valid_from" | "valid_to" | "rate"; + +interface UseXtdhGrantsSearchQueryParams { + readonly grantor?: string | null | undefined; + readonly targetCollectionName?: string | null | undefined; + readonly targetContract?: string | null | undefined; + readonly statuses?: readonly ApiXTdhGrantStatus[] | undefined; + readonly sortField?: XtdhGrantSortField | undefined; + readonly sortDirection?: SortDirection | undefined; + readonly pageSize?: number | undefined; + readonly enabled?: boolean | undefined; +} + +type XtdhGrantsInfiniteData = InfiniteData; + +type UseXtdhGrantsSearchQueryResult = UseInfiniteQueryResult< + XtdhGrantsInfiniteData, + Error +> & { + readonly grants: ApiXTdhGrant[]; + readonly totalCount: number; + readonly errorMessage?: string | undefined; + readonly isEnabled: boolean; + readonly firstPage?: ApiXTdhGrantsPage | undefined; +}; + +const DEFAULT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 2000; +const DEFAULT_SORT_FIELD: XtdhGrantSortField = "created_at"; +const DEFAULT_SORT_DIRECTION: SortDirection = SortDirection.DESC; +const DEFAULT_STALE_TIME = 30_000; // 30 seconds +const XTDH_GRANT_STATUS_SORT_RANK: Readonly> = { + [ApiXTdhGrantStatus.Pending]: 0, + [ApiXTdhGrantStatus.Failed]: 1, + [ApiXTdhGrantStatus.Disabled]: 2, + [ApiXTdhGrantStatus.Granted]: 3, +}; + +const compareXtdhGrantStatuses = ( + left: ApiXTdhGrantStatus, + right: ApiXTdhGrantStatus +): number => { + const leftRank = XTDH_GRANT_STATUS_SORT_RANK[left]; + const rightRank = XTDH_GRANT_STATUS_SORT_RANK[right]; + + if (leftRank !== undefined && rightRank !== undefined) { + return leftRank - rightRank; + } + + if (leftRank !== undefined) { + return -1; + } + + if (rightRank !== undefined) { + return 1; + } + + return left.localeCompare(right); +}; + +const normalizeTextFilter = (value?: string | null): string => { + const normalized = value?.trim() ?? ""; + return normalized.length ? normalized : ""; +}; + +const normalizeStatuses = ( + statuses?: readonly ApiXTdhGrantStatus[] +): ApiXTdhGrantStatus[] => { + if (statuses === undefined || statuses.length === 0) { + return []; + } + return Array.from(new Set(statuses)).sort(compareXtdhGrantStatuses); +}; + +export function useXtdhGrantsSearchQuery({ + grantor, + targetCollectionName, + targetContract, + statuses, + sortField = DEFAULT_SORT_FIELD, + sortDirection = DEFAULT_SORT_DIRECTION, + pageSize = DEFAULT_PAGE_SIZE, + enabled = true, +}: Readonly): UseXtdhGrantsSearchQueryResult { + const normalizedGrantor = normalizeTextFilter(grantor); + const normalizedTargetCollectionName = + normalizeTextFilter(targetCollectionName); + const normalizedTargetContract = normalizeTextFilter(targetContract); + const normalizedStatuses = normalizeStatuses(statuses); + const serializedStatuses = normalizedStatuses.join(","); + const normalizedPageSize = Math.min( + MAX_PAGE_SIZE, + Math.max(1, Math.floor(pageSize)) + ); + const isEnabled = enabled; + + const queryKey = useMemo( + () => [ + QueryKey.TDH_GRANTS, + "group-create-grants-search", + normalizedGrantor, + normalizedTargetCollectionName, + normalizedTargetContract, + serializedStatuses || "ALL", + sortField, + sortDirection, + normalizedPageSize, + ], + [ + normalizedGrantor, + normalizedTargetCollectionName, + normalizedTargetContract, + serializedStatuses, + sortField, + sortDirection, + normalizedPageSize, + ] + ); + + const query = useInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam }: { pageParam?: number | undefined }) => { + const currentPage = pageParam ?? DEFAULT_PAGE; + const params: Record = { + page: currentPage.toString(), + page_size: normalizedPageSize.toString(), + sort: sortField, + sort_direction: sortDirection, + }; + + if (normalizedGrantor) { + params["grantor"] = normalizedGrantor; + } + if (normalizedTargetCollectionName) { + params["target_collection_name"] = normalizedTargetCollectionName; + } + if (normalizedTargetContract) { + params["target_contract"] = normalizedTargetContract; + } + if (serializedStatuses) { + params["status"] = serializedStatuses; + } + + return await commonApiFetch({ + endpoint: "xtdh/grants", + params, + }); + }, + initialPageParam: DEFAULT_PAGE, + getNextPageParam: (lastPage) => + lastPage.next ? lastPage.page + 1 : undefined, + enabled: isEnabled, + staleTime: DEFAULT_STALE_TIME, + placeholderData: keepPreviousData, + }); + + const firstPage = query.data?.pages[0]; + const grants = useMemo( + () => query.data?.pages.flatMap((pageData) => pageData.data) ?? [], + [query.data] + ); + const totalCount = firstPage?.count ?? 0; + const errorMessage = + query.error instanceof Error ? query.error.message : undefined; + + return { + ...query, + grants, + totalCount, + errorMessage, + isEnabled, + firstPage, + }; +} diff --git a/openapi.yaml b/openapi.yaml index edcf6bfa3e..f5b1e10939 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9119,6 +9119,7 @@ components: - rememes_submission_tdh_threshold - all_drops_notifications_subscribers_limit - memes_wave_id + - curation_wave_id properties: rememes_submission_tdh_threshold: type: integer @@ -9129,6 +9130,9 @@ components: memes_wave_id: type: string nullable: true + curation_wave_id: + type: string + nullable: true ApiStartMultipartMediaUploadResponse: required: - upload_id diff --git a/services/groups/groupMutations.ts b/services/groups/groupMutations.ts index 2cfbe784b8..2d11c085aa 100644 --- a/services/groups/groupMutations.ts +++ b/services/groups/groupMutations.ts @@ -32,22 +32,27 @@ export const toErrorMessage = (error: unknown): string => { const sanitiseGroupPayload = ( payload: ApiCreateGroup, name: string -): ApiCreateGroup => ({ - ...payload, - name, - group: { - ...payload.group, - owns_nfts: [...payload.group.owns_nfts], - identity_addresses: - payload.group.identity_addresses?.length - ? [...payload.group.identity_addresses] - : null, - excluded_identity_addresses: - payload.group.excluded_identity_addresses?.length - ? [...payload.group.excluded_identity_addresses] - : null, - }, -}); +): ApiCreateGroup => { + const identityAddresses = payload.group.identity_addresses; + const excludedIdentityAddresses = payload.group.excluded_identity_addresses; + + return { + ...payload, + name, + group: { + ...payload.group, + owns_nfts: [...payload.group.owns_nfts], + identity_addresses: + identityAddresses && identityAddresses.length > 0 + ? [...identityAddresses] + : null, + excluded_identity_addresses: + excludedIdentityAddresses && excludedIdentityAddresses.length > 0 + ? [...excludedIdentityAddresses] + : null, + }, + }; +}; export const validateGroupPayload = ( payload: ApiCreateGroup @@ -80,6 +85,9 @@ export const validateGroupPayload = ( payload.group.cic.max !== null || payload.group.cic.user_identity !== null; const hasNfts = payload.group.owns_nfts.length > 0; + const hasGrantBeneficiary = + typeof payload.group.is_beneficiary_of_grant_id === "string" && + payload.group.is_beneficiary_of_grant_id.trim().length > 0; if ( !( @@ -89,7 +97,8 @@ export const validateGroupPayload = ( hasTdh || hasRep || hasCic || - hasNfts + hasNfts || + hasGrantBeneficiary ) ) { issues.push("NO_FILTERS");