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");