diff --git a/app/profile/[address]/hypercerts-tab-content.tsx b/app/profile/[address]/hypercerts-tab-content.tsx index 19376db9..1b3d933d 100644 --- a/app/profile/[address]/hypercerts-tab-content.tsx +++ b/app/profile/[address]/hypercerts-tab-content.tsx @@ -2,12 +2,15 @@ import { getHypercertsByCreator } from "@/hypercerts/getHypercertsByCreator"; import { getAllowListRecordsForAddressByClaimed } from "@/allowlists/getAllowListRecordsForAddressByClaimed"; import HypercertWindow from "@/components/hypercert/hypercert-window"; import { EmptySection } from "@/components/global/sections"; -import UnclaimedHypercertsList from "@/components/profile/unclaimed-hypercerts-list"; +import UnclaimedHypercertsList, { + UnclaimedFraction, +} from "@/components/profile/unclaimed-hypercerts-list"; import { Suspense } from "react"; import ExploreListSkeleton from "@/components/explore/explore-list-skeleton"; import { ProfileSubTabKey, subTabs } from "@/app/profile/[address]/tabs"; import { SubTabsWithCount } from "@/components/profile/sub-tabs-with-count"; import { getHypercertsByOwner } from "@/hypercerts/getHypercertsByOwner"; +import { getHypercertMetadata } from "@/hypercerts/getHypercertMetadata"; const HypercertsTabContentInner = async ({ address, @@ -27,7 +30,35 @@ const HypercertsTabContentInner = async ({ const claimableHypercerts = await getAllowListRecordsForAddressByClaimed( address, false, - ); + ).then(async (res) => { + if (!res?.data) { + return { + data: [], + count: 0, + }; + } + const hypercertsWithMetadata = await Promise.all( + res.data.map(async (record): Promise => { + const metadata = await getHypercertMetadata( + record.hypercert_id as string, + ); + if (!metadata) { + return { + ...record, + metadata: null, + }; + } + return { + ...record, + metadata: metadata?.data, + }; + }), + ); + return { + data: hypercertsWithMetadata, + count: res?.count, + }; + }); const showCreatedHypercerts = createdHypercerts?.data && createdHypercerts.data.length > 0; diff --git a/components/global/extra-content.tsx b/components/global/extra-content.tsx new file mode 100644 index 00000000..d892b62c --- /dev/null +++ b/components/global/extra-content.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/components/ui/button"; +import type { Chain, TransactionReceipt } from "viem"; +import { generateBlockExplorerLink } from "@/lib/utils"; + +export const createExtraContent = ({ + receipt, + hypercertId, + chain, +}: { + receipt: TransactionReceipt; + hypercertId?: string; + chain: Chain; +}) => { + const receiptButton = receipt && ( + <> + + + + + ); + + const hypercertButton = hypercertId && ( + <> + + + + + ); + + return ( +
+

+ Your hypercert has been minted successfully! +

+
+ {receiptButton} + {hypercertButton} +
+
+ ); +}; diff --git a/components/profile/unclaimed-hypercert-butchClaim-button.tsx b/components/profile/unclaimed-hypercert-butchClaim-button.tsx new file mode 100644 index 00000000..822bb96c --- /dev/null +++ b/components/profile/unclaimed-hypercert-butchClaim-button.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByClaimed"; +import { Button } from "../ui/button"; +import { useHypercertClient } from "@/hooks/use-hypercert-client"; +import { waitForTransactionReceipt } from "viem/actions"; +import { useAccount, useSwitchChain, useWalletClient } from "wagmi"; +import { useRouter } from "next/navigation"; +import { useStepProcessDialogContext } from "../global/step-process-dialog"; +import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; +import { useState } from "react"; +import { Hex, ByteArray, getAddress } from "viem"; +import { errorToast } from "@/lib/errorToast"; +import { ChainFactory } from "@/lib/chainFactory"; +import { createExtraContent } from "../global/extra-content"; + +interface TransformedClaimData { + hypercertTokenIds: bigint[]; + units: bigint[]; + proofs: (Hex | ByteArray)[][]; + roots?: (Hex | ByteArray)[]; +} + +function transformAllowListRecords( + records: AllowListRecord[], +): TransformedClaimData { + return { + hypercertTokenIds: records.map((record) => BigInt(record.token_id!)), + units: records.map((record) => BigInt(record.units!)), + proofs: records.map((record) => record.proof as (Hex | ByteArray)[]), + roots: records.map((record) => record.root as Hex | ByteArray), + }; +} + +export default function UnclaimedHypercertBatchClaimButton({ + allowListRecords, + selectedChainId, +}: { + allowListRecords: AllowListRecord[]; + selectedChainId: number | null; +}) { + const { client } = useHypercertClient(); + const { data: walletClient } = useWalletClient(); + const account = useAccount(); + const { refresh } = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + useStepProcessDialogContext(); + const { switchChain } = useSwitchChain(); + + const selectedChain = selectedChainId + ? ChainFactory.getChain(selectedChainId) + : null; + + const claimHypercert = async () => { + setIsLoading(true); + setOpen(true); + setSteps([ + { id: "preparing", description: "Preparing to claim fractions..." }, + { id: "claiming", description: "Claiming fractions on-chain..." }, + { id: "confirming", description: "Waiting for on-chain confirmation" }, + { id: "done", description: "Claiming complete!" }, + ]); + setTitle("Claim fractions from Allowlist"); + if (!client) { + throw new Error("No client found"); + } + if (!walletClient) { + throw new Error("No wallet client found"); + } + if (!account) { + throw new Error("No address found"); + } + + const claimData = transformAllowListRecords(allowListRecords); + await setDialogStep("preparing, active"); + try { + await setDialogStep("claiming", "active"); + + const tx = await client.batchClaimFractionsFromAllowlists(claimData); + if (!tx) { + await setDialogStep("claiming", "error"); + throw new Error("Failed to claim fractions"); + } + await setDialogStep("confirming", "active"); + const receipt = await waitForTransactionReceipt(walletClient, { + hash: tx, + }); + if (receipt.status == "success") { + await setDialogStep("done", "completed"); + const extraContent = createExtraContent({ + receipt, + chain: account?.chain!, + }); + setExtraContent(extraContent); + await revalidatePathServerAction([ + `/profile/${account.address}?tab=hypercerts-claimable`, + `/profile/${account.address}?tab=hypercerts-owned`, + ]); + } else if (receipt.status == "reverted") { + await setDialogStep("confirming", "error", "Transaction reverted"); + } + setTimeout(() => { + refresh(); + }, 5000); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const isBatchClaimDisabled = + isLoading || + !allowListRecords.length || + !account || + !client || + account.address !== getAddress(allowListRecords[0].user_address as string); + + return ( + <> + {account.chainId === selectedChainId ? ( + + ) : ( + + )} + + ); +} diff --git a/components/profile/unclaimed-hypercert-claim-button.tsx b/components/profile/unclaimed-hypercert-claim-button.tsx index 75f00735..8e0e87ec 100644 --- a/components/profile/unclaimed-hypercert-claim-button.tsx +++ b/components/profile/unclaimed-hypercert-claim-button.tsx @@ -4,20 +4,44 @@ import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByCla import { Button } from "../ui/button"; import { useHypercertClient } from "@/hooks/use-hypercert-client"; import { waitForTransactionReceipt } from "viem/actions"; -import { useAccount, useWalletClient } from "wagmi"; +import { useAccount, useSwitchChain, useWalletClient } from "wagmi"; import { useRouter } from "next/navigation"; +import { Row } from "@tanstack/react-table"; +import { useStepProcessDialogContext } from "../global/step-process-dialog"; +import { createExtraContent } from "../global/extra-content"; +import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; +import { useState } from "react"; + +interface UnclaimedHypercertClaimButtonProps { + allowListRecord: Row; +} export default function UnclaimedHypercertClaimButton({ allowListRecord, -}: { - allowListRecord: AllowListRecord; -}) { +}: UnclaimedHypercertClaimButtonProps) { const { client } = useHypercertClient(); const { data: walletClient } = useWalletClient(); - const { address } = useAccount(); + const account = useAccount(); const { refresh } = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const { setDialogStep, setSteps, setOpen, setTitle, setExtraContent } = + useStepProcessDialogContext(); + const { switchChain } = useSwitchChain(); + const selectedHypercert = allowListRecord.original; + const hypercertChainId = selectedHypercert?.hypercert_id?.split("-")[0]; const claimHypercert = async () => { + setIsLoading(true); + setOpen(true); + setSteps([ + { id: "preparing", description: "Preparing to claim fraction..." }, + { id: "claiming", description: "Claiming fraction on-chain..." }, + { id: "confirming", description: "Waiting for on-chain confirmation" }, + { id: "route", description: "Creating your new fraction's link..." }, + { id: "done", description: "Claiming complete!" }, + ]); + + setTitle("Claim fraction from Allowlist"); if (!client) { throw new Error("No client found"); } @@ -26,42 +50,87 @@ export default function UnclaimedHypercertClaimButton({ throw new Error("No wallet client found"); } - if (!address) { + if (!account) { throw new Error("No address found"); } if ( - !allowListRecord.units || - !allowListRecord.proof || - !allowListRecord.token_id + !selectedHypercert?.units || + !selectedHypercert?.proof || + !selectedHypercert?.token_id ) { throw new Error("Invalid allow list record"); } + await setDialogStep("preparing, active"); - // DUMMY VALUES - const root: `0x${string}` = - "0x0000000000000000000000000000000000000000000000000000000000000000"; + try { + await setDialogStep("claiming", "active"); + const tx = await client.mintClaimFractionFromAllowlist( + BigInt(selectedHypercert?.token_id), + BigInt(selectedHypercert?.units), + selectedHypercert?.proof as `0x${string}`[], + undefined, + ); - console.log(allowListRecord); + if (!tx) { + await setDialogStep("claiming", "error"); + throw new Error("Failed to claim fraction"); + } - const tx = await client.mintClaimFractionFromAllowlist( - BigInt(allowListRecord.token_id), - BigInt(allowListRecord.units), - allowListRecord.proof as `0x${string}`[], - undefined, - ); - console.log(tx); - if (!tx) { - throw new Error("Failed to claim hypercert"); - } + await setDialogStep("confirming", "active"); + const receipt = await waitForTransactionReceipt(walletClient, { + hash: tx, + }); - await waitForTransactionReceipt(walletClient, { - hash: tx, - }); - - setTimeout(() => { - refresh(); - }, 5000); + if (receipt.status == "success") { + await setDialogStep("route", "active"); + const extraContent = createExtraContent({ + receipt: receipt, + hypercertId: selectedHypercert?.hypercert_id!, + chain: account.chain!, + }); + setExtraContent(extraContent); + await setDialogStep("done", "completed"); + await revalidatePathServerAction([ + `/hypercerts/${selectedHypercert?.hypercert_id}`, + `/profile/${account.address}?tab=hypercerts-claimable`, + `/profile/${account.address}?tab=hypercerts-owned`, + ]); + } else if (receipt.status == "reverted") { + await setDialogStep("confirming", "error", "Transaction reverted"); + } + setTimeout(() => { + refresh(); + }, 5000); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } }; - return ; + + return ( + + ); } diff --git a/components/profile/unclaimed-hypercert-list-item.tsx b/components/profile/unclaimed-hypercert-list-item.tsx deleted file mode 100644 index a86e12fd..00000000 --- a/components/profile/unclaimed-hypercert-list-item.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByClaimed"; -import Image from "next/image"; -import UnclaimedHypercertClaimButton from "./unclaimed-hypercert-claim-button"; -import TimeFrame from "../hypercert/time-frame"; -import { getHypercert } from "@/hypercerts/getHypercert"; - -export default async function UnclaimedHypercertListItem({ - allowListRecordFragment: allowListRecord, -}: { - allowListRecordFragment: AllowListRecord; -}) { - const hypercertId = allowListRecord.hypercert_id; - if (!hypercertId) { - return null; - } - - const hypercert = await getHypercert(hypercertId); - if (!hypercert) { - return null; - } - - return ( -
-
- {hypercert?.metadata?.name - -
-
- {hypercert?.metadata?.name || "Untitled"} -
- -
- -
- - -
-
- ); -} diff --git a/components/profile/unclaimed-hypercerts-list.tsx b/components/profile/unclaimed-hypercerts-list.tsx index e2e18db1..78d2dd08 100644 --- a/components/profile/unclaimed-hypercerts-list.tsx +++ b/components/profile/unclaimed-hypercerts-list.tsx @@ -1,11 +1,17 @@ import { EmptySection } from "@/components/global/sections"; -import UnclaimedHypercertListItem from "./unclaimed-hypercert-list-item"; import { AllowListRecord } from "@/allowlists/getAllowListRecordsForAddressByClaimed"; +import { UnclaimedFractionTable } from "./unclaimed-table/unclaimed-fraction-table"; +import { UnclaimedFractionColumns } from "./unclaimed-table/unclaimed-fraction-columns"; +import { HypercertMetadata } from "@/hypercerts/fragments/hypercert-metadata.fragment"; + +export type UnclaimedFraction = AllowListRecord & { + metadata: HypercertMetadata | null; +}; export default async function UnclaimedHypercertsList({ unclaimedHypercerts, }: { - unclaimedHypercerts: readonly AllowListRecord[]; + unclaimedHypercerts: UnclaimedFraction[]; }) { if (unclaimedHypercerts.length === 0) { return ( @@ -17,12 +23,10 @@ export default async function UnclaimedHypercertsList({ return (
- {unclaimedHypercerts.map((unclaimedHypercert, i) => ( - - ))} +
); } diff --git a/components/profile/unclaimed-table/table-filter.tsx b/components/profile/unclaimed-table/table-filter.tsx new file mode 100644 index 00000000..e40c39ad --- /dev/null +++ b/components/profile/unclaimed-table/table-filter.tsx @@ -0,0 +1,176 @@ +import React, { useEffect } from "react"; +import { Column } from "@tanstack/react-table"; +import { Check, PlusCircle } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { useAccount } from "wagmi"; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; +} + +export function TableFilter({ + column, + title, + options, +}: DataTableFacetedFilterProps) { + const { chain } = useAccount(); + const facets = column?.getFacetedUniqueValues(); + const selectedValues = new Set(column?.getFilterValue() as string[]); + + // Set default chain filter to user connected chain when component mounts + useEffect(() => { + if (chain?.id && column && !column.getFilterValue()) { + const chainIdStr = chain.id.toString(); + + // Check if the connected chain is in the available options + const isValidChain = options.some( + (option) => option.value === chainIdStr, + ); + + if (isValidChain) { + column.setFilterValue([chainIdStr]); + } + } + }, [chain?.id, column, options]); + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + if (isSelected) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + column?.setFilterValue( + filterValues.length ? filterValues : undefined, + ); + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + { + // Reset to connected chain if available + if ( + chain?.id && + options.some( + (option) => option.value === chain.id.toString(), + ) + ) { + column?.setFilterValue([chain.id.toString()]); + } else { + column?.setFilterValue(undefined); + } + }} + className="justify-center text-center" + > + Reset to connected chain + + + + )} +
+
+
+
+ ); +} diff --git a/components/profile/unclaimed-table/table-toolbar.tsx b/components/profile/unclaimed-table/table-toolbar.tsx new file mode 100644 index 00000000..a17668c3 --- /dev/null +++ b/components/profile/unclaimed-table/table-toolbar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { TableFilter } from "./table-filter"; +import { ChainFactory } from "@/lib/chainFactory"; + +interface DataTableToolbarProps { + table: Table; +} + +export function TableToolbar({ table }: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + const chainOptions = useMemo(() => { + const supportedChains = ChainFactory.getSupportedChains(); + return supportedChains.map((chainId) => { + const chain = ChainFactory.getChain(chainId); + return { + label: chain.name, + value: chainId.toString(), + }; + }); + }, []); + + return ( +
+
+ {table.getColumn("hypercert_id") && ( + + )} + {isFiltered && ( + + )} +
+
+ ); +} diff --git a/components/profile/unclaimed-table/unclaimed-fraction-columns.tsx b/components/profile/unclaimed-table/unclaimed-fraction-columns.tsx new file mode 100644 index 00000000..0feafb67 --- /dev/null +++ b/components/profile/unclaimed-table/unclaimed-fraction-columns.tsx @@ -0,0 +1,159 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +import { ColumnDef } from "@tanstack/react-table"; +import { createColumnHelper } from "@tanstack/react-table"; +import TimeFrame from "@/components/hypercert/time-frame"; +import { ChainFactory } from "@/lib/chainFactory"; +import { Checkbox } from "@/components/ui/checkbox"; +import { UnclaimedTableDropdown } from "./unclaimed-table-dropdown"; +import UnclaimedHypercertClaimButton from "../unclaimed-hypercert-claim-button"; +import { Badge } from "@/components/ui/badge"; +import { FormattedUnits } from "@/components/formatted-units"; +import Link from "next/link"; +import { TooltipInfo } from "@/components/tooltip-info"; +import { UnclaimedFraction } from "../unclaimed-hypercerts-list"; +import { calculateBigIntPercentage } from "@/lib/calculateBigIntPercentage"; + +const columnHelper = createColumnHelper(); + +export const UnclaimedFractionColumns = [ + columnHelper.accessor("id", { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }), + columnHelper.accessor("hypercert_id", { + header: "Hypercert", + id: "hypercert_id", + cell: ({ row }) => { + const hypercertId = row.original.hypercert_id as string; + const metadata = row.original.metadata; + const [chainId] = hypercertId.split("-"); + const chain = ChainFactory.getChain(Number(chainId)); + + return ( + +
+
+ {hypercertId +
+
+
+ {chain && ( + + {chain.name} + + )} + + {metadata?.name} + +
+
+ +
+
+
+ + ); + }, + filterFn: ( + row: { getValue: (columnId: string) => string }, + id: string, + filterValue: string[], + ) => { + if (!filterValue?.length) return true; + const [chainId] = row.getValue(id).split("-"); + return filterValue.includes(chainId); + }, + }), + columnHelper.accessor("units", { + header: () => { + return ( +
+ Claimable fraction + +
+ ); + }, + cell: ({ row }) => { + const calculatedPercentage = calculateBigIntPercentage( + row.original.units as string, + row.original.total_units as string, + ); + + const displayPercentage = + calculatedPercentage! < 1 + ? "<1" + : Math.round(calculatedPercentage!).toString(); + + return ( +
+ {displayPercentage}% + + {row.original.units as string} + {" / "} + + {row.original.total_units as string} + + +
+ ); + }, + }), + columnHelper.display({ + id: "claim", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }), +] as ColumnDef[]; diff --git a/components/profile/unclaimed-table/unclaimed-fraction-table.tsx b/components/profile/unclaimed-table/unclaimed-fraction-table.tsx new file mode 100644 index 00000000..dcfb1db3 --- /dev/null +++ b/components/profile/unclaimed-table/unclaimed-fraction-table.tsx @@ -0,0 +1,228 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + ColumnDef, + ColumnFiltersState, + OnChangeFn, + Row, + RowSelectionState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import UnclaimedHypercertBatchClaimButton from "../unclaimed-hypercert-butchClaim-button"; +import { TableToolbar } from "./table-toolbar"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { UnclaimedFraction } from "../unclaimed-hypercerts-list"; + +export interface DataTableProps { + columns: ColumnDef[]; + data: UnclaimedFraction[]; +} + +export function UnclaimedFractionTable({ columns, data }: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + const [selectedChain, setSelectedChain] = useState(null); + const [selectedRecords, setSelectedRecords] = useState( + [], + ); + + const isMobile = useMediaQuery("(max-width: 640px)"); + + // Hide units and actions columns on mobile + useEffect(() => { + if (isMobile) { + setColumnVisibility((prev) => ({ + ...prev, + units: false, + actions: false, + })); + } + }, [isMobile]); + + // Utility function to extract chain ID from hypercert_id + const getChainId = useCallback((hypercertId: string) => { + const [chainId] = hypercertId.split("-"); + return Number(chainId); + }, []); + + // Determines if a row can be selected based on chain compatibility + // This prevents selection of hypercerts from different chains simultaneously + const isRowSelectable = useCallback( + (row: Row) => { + const rowChainId = getChainId(row.original.hypercert_id!); + // Allow selection if no chain is selected yet or if chains match + return !selectedChain || selectedChain === rowChainId; + }, + [selectedChain, getChainId], + ); + + // Handles row selection changes and maintains chain-based selection logic + const handleRowSelectionChange: OnChangeFn = useCallback( + (updaterOrValue) => { + // Handle both function updater and direct value updates + const updatedSelection: RowSelectionState = + typeof updaterOrValue === "function" + ? updaterOrValue(rowSelection) + : updaterOrValue; + + // Get all currently selected rows + const selectedRows = Object.entries(updatedSelection) + .filter(([_, isSelected]) => isSelected) + .map(([rowId]) => table.getRow(rowId)); + + // Update selected chain based on selection state + if (selectedRows.length > 0 && !selectedChain) { + // Set selected chain when first row is selected + const firstRow = selectedRows[0]; + setSelectedChain(getChainId(firstRow.original.hypercert_id!)); + } else if (selectedRows.length === 0) { + // Clear selected chain when no rows are selected + setSelectedChain(null); + } + + setRowSelection(updatedSelection); + }, + [selectedChain, getChainId, rowSelection], + ); + + const table = useReactTable({ + data: data as UnclaimedFraction[], + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: handleRowSelectionChange, + enableRowSelection: isRowSelectable, + initialState: { + pagination: { + pageSize: 25, + }, + }, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + // Helper to get currently selected records + const getSelectedRecords = useCallback(() => { + return table.getSelectedRowModel().rows.map((row) => row.original); + }, [table]); + + // Keep selectedRecords state in sync with row selection + useEffect(() => { + setSelectedRecords(getSelectedRecords()); + }, [rowSelection, getSelectedRecords]); + + return ( +
+
+ + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} hypercert fraction(s) + selected. +
+
+ + +
+
+
+ ); +} diff --git a/components/profile/unclaimed-table/unclaimed-table-dropdown.tsx b/components/profile/unclaimed-table/unclaimed-table-dropdown.tsx new file mode 100644 index 00000000..80cc9ec9 --- /dev/null +++ b/components/profile/unclaimed-table/unclaimed-table-dropdown.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Row } from "@tanstack/react-table"; +import Link from "next/link"; +import { MoreHorizontal } from "lucide-react"; +import { UnclaimedFraction } from "../unclaimed-hypercerts-list"; + +interface DataTableRowActionsProps { + row: Row; +} + +export function UnclaimedTableDropdown({ + row, +}: DataTableRowActionsProps) { + const hypercertId = row.original?.hypercert_id; + return ( + + + + + + + + View Hypercert + + + { + event.stopPropagation(); + if (!hypercertId) { + return; + } + void navigator.clipboard.writeText(hypercertId); + }} + > + Copy HypercertId + + + + ); +} diff --git a/hypercerts/fragments/hypercert-metadata.fragment.ts b/hypercerts/fragments/hypercert-metadata.fragment.ts new file mode 100644 index 00000000..39192cc4 --- /dev/null +++ b/hypercerts/fragments/hypercert-metadata.fragment.ts @@ -0,0 +1,16 @@ +import { ResultOf, graphql } from "@/lib/graphql"; + +export const HypercertMetadataFragment = graphql(` + fragment HypercertMetadataFragment on Metadata { + contributors + description + id + image + name + work_scope + work_timeframe_from + work_timeframe_to + } +`); + +export type HypercertMetadata = ResultOf; diff --git a/hypercerts/getHypercertMetadata.ts b/hypercerts/getHypercertMetadata.ts new file mode 100644 index 00000000..1872c253 --- /dev/null +++ b/hypercerts/getHypercertMetadata.ts @@ -0,0 +1,35 @@ +"server-only"; +import { graphql, readFragment } from "@/lib/graphql"; + +import { HYPERCERTS_API_URL_GRAPH } from "@/configs/hypercerts"; +import request from "graphql-request"; +import { HypercertMetadataFragment } from "./fragments/hypercert-metadata.fragment"; + +const query = graphql( + ` + query Metadata($hypercert_id: String!) { + metadata(where: { hypercerts: { hypercert_id: { eq: $hypercert_id } } }) { + data { + ...HypercertMetadataFragment + } + } + } + `, + [HypercertMetadataFragment], +); + +export async function getHypercertMetadata(hypercertId: string) { + const res = await request(HYPERCERTS_API_URL_GRAPH, query, { + hypercert_id: hypercertId, + }); + + if (!res.metadata?.data) { + return undefined; + } + + const processedFragments = res.metadata.data[0]; + + return { + data: readFragment(HypercertMetadataFragment, processedFragments), + }; +} diff --git a/hypercerts/hooks/useMintHypercert.ts b/hypercerts/hooks/useMintHypercert.ts index 81b4a6a1..e56857f8 100644 --- a/hypercerts/hooks/useMintHypercert.ts +++ b/hypercerts/hooks/useMintHypercert.ts @@ -1,5 +1,4 @@ import { useStepProcessDialogContext } from "@/components/global/step-process-dialog"; -import { Button, buttonVariants } from "@/components/ui/button"; import { toast } from "@/components/ui/use-toast"; import { useHypercertClient } from "@/hooks/use-hypercert-client"; import { generateHypercertIdFromReceipt } from "@/lib/generateHypercertIdFromReceipt"; @@ -9,74 +8,12 @@ import { TransferRestrictions, } from "@hypercerts-org/sdk"; import { useMutation } from "@tanstack/react-query"; -import { createElement } from "react"; -import type { Chain, TransactionReceipt } from "viem"; import { waitForTransactionReceipt } from "viem/actions"; import { useAccount, useWalletClient } from "wagmi"; -import { generateBlockExplorerLink } from "@/lib/utils"; import { useQueueMintBlueprint } from "@/blueprints/hooks/queueMintBlueprint"; import { revalidatePathServerAction } from "@/app/actions/revalidatePathServerAction"; import { track } from "@vercel/analytics"; - -const createExtraContent = ( - receipt: TransactionReceipt, - hypercertId?: string, - chain?: Chain, -) => { - const receiptButton = - receipt && - createElement( - "a", - { - href: generateBlockExplorerLink(chain, receipt.transactionHash), - target: "_blank", - rel: "noopener noreferrer", - }, - createElement( - Button, - { - size: "default", - className: buttonVariants({ variant: "secondary" }), - }, - "View transaction", - ), - ); - - const hypercertButton = - hypercertId && - createElement( - "a", - { - href: `/hypercerts/${hypercertId}`, - target: "_blank", - rel: "noopener noreferrer", - }, - createElement( - Button, - { - size: "default", - className: buttonVariants({ variant: "default" }), - }, - "View hypercert", - ), - ); - - return createElement( - "div", - { className: "flex flex-col space-y-2" }, - createElement( - "p", - { className: "text-sm font-medium" }, - "Your hypercert has been minted successfully!", - ), - createElement( - "div", - { className: "flex space-x-4" }, - receiptButton, - hypercertButton, - ), - ); -}; +import { createExtraContent } from "@/components/global/extra-content"; export const useMintHypercert = () => { const { client } = useHypercertClient(); @@ -150,7 +87,11 @@ export const useMintHypercert = () => { ); } - const extraContent = createExtraContent(receipt, hypercertId, chain); + const extraContent = createExtraContent({ + receipt, + hypercertId, + chain: chain!, + }); setExtraContent(extraContent); await setDialogStep("done", "completed");