diff --git a/apps/dashboard/src/@/hooks/useEmbeddedWallets.ts b/apps/dashboard/src/@/hooks/useEmbeddedWallets.ts index b066bb2ec2e..2a5bf9ad1a4 100644 --- a/apps/dashboard/src/@/hooks/useEmbeddedWallets.ts +++ b/apps/dashboard/src/@/hooks/useEmbeddedWallets.ts @@ -7,22 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys"; const fetchAccountList = ({ jwt, clientId, + ecosystemSlug, + teamId, pageNumber, }: { jwt: string; - clientId: string; + clientId?: string; + ecosystemSlug?: string; + teamId: string; pageNumber: number; }) => { return async () => { const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`); - url.searchParams.append("clientId", clientId); + + // Add clientId or ecosystemSlug parameter + if (ecosystemSlug) { + url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`); + } else if (clientId) { + url.searchParams.append("clientId", clientId); + } + url.searchParams.append("page", pageNumber.toString()); const res = await fetch(url.href, { headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", - "x-client-id": clientId, + "x-thirdweb-team-id": teamId, + ...(clientId && { "x-client-id": clientId }), }, method: "GET", }); @@ -38,23 +50,27 @@ const fetchAccountList = ({ }; export function useEmbeddedWallets(params: { - clientId: string; + clientId?: string; + ecosystemSlug?: string; + teamId: string; page: number; authToken: string; }) { - const { clientId, page, authToken } = params; + const { clientId, ecosystemSlug, teamId, page, authToken } = params; const address = useActiveAccount()?.address; return useQuery({ - enabled: !!address && !!clientId, + enabled: !!address && !!(clientId || ecosystemSlug), queryFn: fetchAccountList({ clientId, + ecosystemSlug, + teamId, jwt: authToken, pageNumber: page, }), queryKey: embeddedWalletsKeys.embeddedWallets( address || "", - clientId, + clientId || ecosystemSlug || "", page, ), }); @@ -67,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) { const address = useActiveAccount()?.address; return useMutation({ - mutationFn: async ({ clientId }: { clientId: string }) => { + mutationFn: async ({ + clientId, + ecosystemSlug, + teamId, + }: { + clientId?: string; + ecosystemSlug?: string; + teamId: string; + }) => { const responses: WalletUser[] = []; let page = 1; @@ -77,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) { }>({ queryFn: fetchAccountList({ clientId, + ecosystemSlug, + teamId, jwt: authToken, pageNumber: page, }), queryKey: embeddedWalletsKeys.embeddedWallets( address || "", - clientId, + clientId || ecosystemSlug || "", page, ), }); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx index d8ce5386c2e..2fec668eaa8 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/components/EcosystemSlugLayout.tsx @@ -62,6 +62,10 @@ export async function EcosystemLayoutSlug({ name: "Analytics", path: `${ecosystemLayoutPath}/${ecosystem.slug}/analytics`, }, + { + name: "Users", + path: `${ecosystemLayoutPath}/${ecosystem.slug}/users`, + }, { name: "Design (coming soon)", path: `${ecosystemLayoutPath}/${ecosystem.slug}/#`, diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/users/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/users/page.tsx new file mode 100644 index 00000000000..190344df67e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/users/page.tsx @@ -0,0 +1,40 @@ +import { loginRedirect } from "@app/login/loginRedirect"; +import { InAppWalletUsersPageContent } from "@app/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components"; +import { redirect } from "next/navigation"; +import { getAuthToken } from "@/api/auth-token"; +import { fetchEcosystem } from "@/api/ecosystems"; +import { getTeamBySlug } from "@/api/team"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; + +export default async function EcosystemUsersPage(props: { + params: Promise<{ team_slug: string; slug: string }>; +}) { + const params = await props.params; + const [authToken, ecosystem, team] = await Promise.all([ + getAuthToken(), + fetchEcosystem(params.slug, params.team_slug), + getTeamBySlug(params.team_slug), + ]); + + if (!authToken) { + loginRedirect(`/team/${params.team_slug}/~/ecosystem/${params.slug}/users`); + } + + if (!ecosystem || !team) { + redirect(`/team/${params.team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: team.id, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/AdvancedSearchInput.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/AdvancedSearchInput.tsx new file mode 100644 index 00000000000..16d3a84c71f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/AdvancedSearchInput.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { SearchIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import type { SearchType } from "./types"; + +export function AdvancedSearchInput(props: { + onSearch: (searchType: SearchType, query: string) => void; + onClear: () => void; + isLoading: boolean; + hasResults: boolean; +}) { + const [searchType, setSearchType] = useState("email"); + const [query, setQuery] = useState(""); + + const handleSearch = () => { + if (query.trim()) { + props.onSearch(searchType, query.trim()); + } + }; + + const handleClear = () => { + setQuery(""); + props.onClear(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + return ( +
+
+ + +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+ + +
+ + {props.hasResults && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchInput.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchInput.tsx deleted file mode 100644 index 41eb3d62936..00000000000 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchInput.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { SearchIcon } from "lucide-react"; -import { Input } from "@/components/ui/input"; - -export function SearchInput(props: { - placeholder: string; - value: string; - onValueChange: (value: string) => void; -}) { - return ( -
- props.onValueChange(e.target.value)} - placeholder={props.placeholder} - value={props.value} - /> - -
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchResults.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchResults.tsx new file mode 100644 index 00000000000..d24ad8b489a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/SearchResults.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { format } from "date-fns"; +import type { ThirdwebClient } from "thirdweb"; +import type { WalletUser } from "thirdweb/wallets"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const getUserIdentifier = (user: WalletUser) => { + const mainDetail = user.linkedAccounts[0]?.details; + return ( + mainDetail?.email ?? + mainDetail?.phone ?? + mainDetail?.address ?? + mainDetail?.id ?? + user.id + ); +}; + +export function SearchResults(props: { + results: WalletUser[]; + client: ThirdwebClient; +}) { + if (props.results.length === 0) { + return ( + + +
+

No users found

+

+ Try searching with different criteria +

+
+
+
+ ); + } + + return ( +
+ {props.results.map((user) => { + const walletAddress = user.wallets?.[0]?.address; + const createdAt = user.wallets?.[0]?.createdAt; + const mainDetail = user.linkedAccounts?.[0]?.details; + const email = mainDetail?.email as string | undefined; + const phone = mainDetail?.phone as string | undefined; + + return ( + + + User Details + + +
+
+

+ User Identifier +

+

{getUserIdentifier(user)}

+
+ + {walletAddress && ( +
+

+ Wallet Address +

+ +
+ )} + + {email && ( +
+

+ Email +

+

{email}

+
+ )} + + {phone && ( +
+

+ Phone +

+

{phone}

+
+ )} + + {createdAt && ( +
+

+ Created +

+

+ {format(new Date(createdAt), "MMM dd, yyyy")} +

+
+ )} + +
+

+ Login Methods +

+
+ {user.linkedAccounts?.map((account, index) => ( + + + + + {account.type} + + + +
+ {Object.entries(account.details).map( + ([key, value]) => ( +
+ {key}:{" "} + {String(value)} +
+ ), + )} +
+
+
+
+ ))} +
+
+
+
+
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/index.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/index.tsx index 6eedaced070..da1e0cfdff1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/index.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/index.tsx @@ -21,7 +21,10 @@ import { useAllEmbeddedWallets, useEmbeddedWallets, } from "@/hooks/useEmbeddedWallets"; -import { SearchInput } from "./SearchInput"; +import { AdvancedSearchInput } from "./AdvancedSearchInput"; +import { SearchResults } from "./SearchResults"; +import { searchUsers } from "./searchUsers"; +import type { SearchType } from "./types"; const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => { const mainDetail = accounts[0]?.details; @@ -35,11 +38,16 @@ const getUserIdentifier = (accounts: WalletUser["linkedAccounts"]) => { const columnHelper = createColumnHelper(); -export function InAppWalletUsersPageContent(props: { - authToken: string; - projectClientId: string; - client: ThirdwebClient; -}) { +export function InAppWalletUsersPageContent( + props: { + authToken: string; + client: ThirdwebClient; + teamId: string; + } & ( + | { projectClientId: string; ecosystemSlug?: never } + | { ecosystemSlug: string; projectClientId?: never } + ), +) { const columns = useMemo(() => { return [ columnHelper.accessor("linkedAccounts", { @@ -110,49 +118,57 @@ export function InAppWalletUsersPageContent(props: { }, [props.client]); const [activePage, setActivePage] = useState(1); - const [searchValue, setSearchValue] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [hasSearchResults, setHasSearchResults] = useState(false); const walletsQuery = useEmbeddedWallets({ authToken: props.authToken, clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + teamId: props.teamId, page: activePage, }); const wallets = walletsQuery?.data?.users || []; - const filteredWallets = searchValue - ? wallets.filter((wallet) => { - const term = searchValue.toLowerCase(); - if (wallet.id.toLowerCase().includes(term)) { - return true; - } - if ( - wallet.wallets?.some((w) => w.address?.toLowerCase().includes(term)) - ) { - return true; - } - if ( - wallet.linkedAccounts?.some((acc) => { - return Object.values(acc.details).some( - (detail) => - typeof detail === "string" && - detail.toLowerCase().includes(term), - ); - }) - ) { - return true; - } - return false; - }) - : wallets; const { mutateAsync: getAllEmbeddedWallets, isPending } = useAllEmbeddedWallets({ authToken: props.authToken, }); + const handleSearch = async (searchType: SearchType, query: string) => { + setIsSearching(true); + try { + const results = await searchUsers( + props.authToken, + props.projectClientId, + props.ecosystemSlug, + props.teamId, + searchType, + query, + ); + setSearchResults(results); + setHasSearchResults(true); + } catch (error) { + console.error("Search failed:", error); + setSearchResults([]); + setHasSearchResults(true); + } finally { + setIsSearching(false); + } + }; + + const handleClearSearch = () => { + setSearchResults([]); + setHasSearchResults(false); + }; + const downloadCSV = useCallback(async () => { if (wallets.length === 0 || !getAllEmbeddedWallets) { return; } const usersWallets = await getAllEmbeddedWallets({ clientId: props.projectClientId, + ecosystemSlug: props.ecosystemSlug, + teamId: props.teamId, }); const csv = Papa.unparse( usersWallets.map((row) => { @@ -173,65 +189,80 @@ export function InAppWalletUsersPageContent(props: { tempLink.href = csvUrl; tempLink.setAttribute("download", "download.csv"); tempLink.click(); - }, [wallets, props.projectClientId, getAllEmbeddedWallets]); + }, [ + wallets, + props.projectClientId, + getAllEmbeddedWallets, + props.teamId, + props.ecosystemSlug, + ]); return (
{/* Top section */} -
-
- -
- -
- -
- - -
+
+
+
+ +
-
+ +
+ {hasSearchResults ? ( + + ) : ( + <> + + +
+ + +
+ + )} +
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/searchUsers.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/searchUsers.ts new file mode 100644 index 00000000000..3f60e0ae105 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/searchUsers.ts @@ -0,0 +1,56 @@ +import type { WalletUser } from "thirdweb/wallets"; +import { THIRDWEB_EWS_API_HOST } from "@/constants/urls"; +import type { SearchType } from "./types"; + +export async function searchUsers( + authToken: string, + clientId: string | undefined, + ecosystemSlug: string | undefined, + teamId: string, + searchType: SearchType, + query: string, +): Promise { + const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`); + + // Add clientId or ecosystemSlug parameter + if (ecosystemSlug) { + url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`); + } else if (clientId) { + url.searchParams.append("clientId", clientId); + } + + // Add search parameter based on search type + switch (searchType) { + case "email": + url.searchParams.append("email", query); + break; + case "phone": + url.searchParams.append("phone", query); + break; + case "id": + url.searchParams.append("id", query); + break; + case "address": + url.searchParams.append("address", query); + break; + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "x-thirdweb-team-id": teamId, + ...(clientId && { "x-client-id": clientId }), + }, + method: "GET", + }); + + if (!response.ok) { + throw new Error( + `Failed to search users: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.users as WalletUser[]; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/types.ts new file mode 100644 index 00000000000..207189d8897 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/components/types.ts @@ -0,0 +1 @@ +export type SearchType = "email" | "phone" | "id" | "address"; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/page.tsx index 4af11dac835..3856b22ab4d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/users/page.tsx @@ -34,6 +34,7 @@ export default async function Page(props: { authToken={authToken} client={client} projectClientId={project.publishableKey} + teamId={project.teamId} /> ); }