From ce51c6e33d8e30036e4f338cad4148d6265ff979 Mon Sep 17 00:00:00 2001 From: Matthew Bunday Date: Thu, 7 Nov 2024 15:26:45 -0500 Subject: [PATCH] Multiname management (#1216) * Scaffold page * Add NamesList component * API endppint for getUsernames * Add NamesList component * Move route * Ugly list demo working * Style it up a bit * Manage names list styling and expiry display * Style the header * Add triple dot icon * Triple dot dropdown menu * Set as primary working * Work on transfers, checkpoint * Work on transfers * Lint unused dep * UI polish * Add empty state * Resolve type errors? * Reolve types * Slightly better empty state * Remove console.log * Only show My Basenames if the user has a wallet connected * Improve dropdown mechanics * Spacing feedback * Error handling ala Leo * Handle success / failure correctly * Lint * Add some mobile margin * Fix mobile padding --- .../api/basenames/getUsernames/route.ts | 29 +++++ .../web/app/(basenames)/manage-names/page.tsx | 32 +++++ apps/web/next-env.d.ts | 2 +- apps/web/package.json | 1 + .../Basenames/ManageNames/NameDisplay.tsx | 111 ++++++++++++++++++ .../Basenames/ManageNames/NamesList.tsx | 81 +++++++++++++ .../Basenames/ManageNames/hooks.tsx | 105 +++++++++++++++++ .../context.tsx | 8 +- .../index.tsx | 5 +- apps/web/src/components/Dropdown/index.tsx | 15 ++- .../web/src/components/DropdownMenu/index.tsx | 8 +- apps/web/src/components/Icon/Icon.tsx | 53 +++++++++ .../components/Layout/UsernameNav/index.tsx | 12 +- apps/web/src/hooks/useSetPrimaryBasename.ts | 9 +- apps/web/src/types/ManagedAddresses.ts | 17 +++ yarn.lock | 8 ++ 16 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 apps/web/app/(basenames)/api/basenames/getUsernames/route.ts create mode 100644 apps/web/app/(basenames)/manage-names/page.tsx create mode 100644 apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx create mode 100644 apps/web/src/components/Basenames/ManageNames/NamesList.tsx create mode 100644 apps/web/src/components/Basenames/ManageNames/hooks.tsx create mode 100644 apps/web/src/types/ManagedAddresses.ts diff --git a/apps/web/app/(basenames)/api/basenames/getUsernames/route.ts b/apps/web/app/(basenames)/api/basenames/getUsernames/route.ts new file mode 100644 index 0000000000..b1baae10cf --- /dev/null +++ b/apps/web/app/(basenames)/api/basenames/getUsernames/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import type { ManagedAddressesResponse } from 'apps/web/src/types/ManagedAddresses'; + +export async function GET(request: NextRequest) { + const address = request.nextUrl.searchParams.get('address'); + if (!address) { + return NextResponse.json({ error: 'No address provided' }, { status: 400 }); + } + + const network = request.nextUrl.searchParams.get('network') ?? 'base-mainnet'; + if (network !== 'base-mainnet' && network !== 'base-sepolia') { + return NextResponse.json({ error: 'Invalid network provided' }, { status: 400 }); + } + + const response = await fetch( + `https://api.cdp.coinbase.com/platform/v1/networks/${network}/addresses/${address}/identity?limit=50`, + { + headers: { + Authorization: `Bearer ${process.env.CDP_BEARER_TOKEN}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const data = (await response.json()) as ManagedAddressesResponse; + + return NextResponse.json(data, { status: 200 }); +} diff --git a/apps/web/app/(basenames)/manage-names/page.tsx b/apps/web/app/(basenames)/manage-names/page.tsx new file mode 100644 index 0000000000..1aec6958aa --- /dev/null +++ b/apps/web/app/(basenames)/manage-names/page.tsx @@ -0,0 +1,32 @@ +import ErrorsProvider from 'apps/web/contexts/Errors'; +import type { Metadata } from 'next'; +import { initialFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; +import NamesList from 'apps/web/src/components/Basenames/ManageNames/NamesList'; + +export const metadata: Metadata = { + metadataBase: new URL('https://base.org'), + title: `Basenames`, + description: + 'Basenames are a core onchain building block that enables anyone to establish their identity on Base by registering human-readable names for their address(es). They are a fully onchain solution which leverages ENS infrastructure deployed on Base.', + openGraph: { + title: `Basenames`, + url: `/manage-names`, + }, + twitter: { + site: '@base', + card: 'summary_large_image', + }, + other: { + ...(initialFrame as Record), + }, +}; + +export default async function Page() { + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index fd36f9494e..725dd6f245 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -3,4 +3,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 3c42a50734..718ac4131e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "base-ui": "0.1.1", "classnames": "^2.5.1", "cloudinary": "^2.5.1", + "date-fns": "^4.1.0", "dd-trace": "^5.21.0", "ethers": "5.7.2", "framer-motion": "^11.9.0", diff --git a/apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx b/apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx new file mode 100644 index 0000000000..0a1b44fd63 --- /dev/null +++ b/apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import UsernameProfileProvider from 'apps/web/src/components/Basenames/UsernameProfileContext'; +import ProfileTransferOwnershipProvider from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context'; +import UsernameProfileTransferOwnershipModal from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal'; +import BasenameAvatar from 'apps/web/src/components/Basenames/BasenameAvatar'; +import { Basename } from '@coinbase/onchainkit/identity'; +import { formatDistanceToNow, parseISO } from 'date-fns'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import Dropdown from 'apps/web/src/components/Dropdown'; +import DropdownItem from 'apps/web/src/components/DropdownItem'; +import DropdownMenu from 'apps/web/src/components/DropdownMenu'; +import DropdownToggle from 'apps/web/src/components/DropdownToggle'; +import classNames from 'classnames'; +import { + useUpdatePrimaryName, + useRemoveNameFromUI, +} from 'apps/web/src/components/Basenames/ManageNames/hooks'; +import Link from 'apps/web/src/components/Link'; + +const transitionClasses = 'transition-all duration-700 ease-in-out'; + +const pillNameClasses = classNames( + 'bg-blue-500 mx-auto text-white relative leading-[2em] overflow-hidden text-ellipsis max-w-full', + 'shadow-[0px_8px_16px_0px_rgba(0,82,255,0.32),inset_0px_8px_16px_0px_rgba(255,255,255,0.25)]', + transitionClasses, + 'rounded-[2rem] py-6 px-6 w-full', +); + +const avatarClasses = classNames( + 'flex items-center justify-center overflow-hidden rounded-full', + transitionClasses, + 'h-[2.5rem] w-[2.5rem] md:h-[4rem] md:w-[4rem] top-3 md:top-4 left-4', +); + +type NameDisplayProps = { + domain: string; + isPrimary: boolean; + tokenId: string; + expiresAt: string; +}; + +export default function NameDisplay({ domain, isPrimary, tokenId, expiresAt }: NameDisplayProps) { + const expirationText = formatDistanceToNow(parseISO(expiresAt), { addSuffix: true }); + + const { setPrimaryUsername } = useUpdatePrimaryName(domain as Basename); + + const [isOpen, setIsOpen] = useState(false); + const openModal = useCallback(() => setIsOpen(true), []); + const closeModal = useCallback(() => setIsOpen(false), []); + + const { removeNameFromUI } = useRemoveNameFromUI(domain as Basename); + + return ( +
  • +
    + +
    + +
    +

    {domain}

    +

    Expires {expirationText}

    +
    +
    + +
    + {isPrimary && ( + Primary + )} + + + + + + + + Transfer + name + + + {!isPrimary ? ( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + + + Set as + primary + + + ) : null} + + +
    +
    + + + + + +
  • + ); +} diff --git a/apps/web/src/components/Basenames/ManageNames/NamesList.tsx b/apps/web/src/components/Basenames/ManageNames/NamesList.tsx new file mode 100644 index 0000000000..c4838f1da2 --- /dev/null +++ b/apps/web/src/components/Basenames/ManageNames/NamesList.tsx @@ -0,0 +1,81 @@ +'use client'; + +import NameDisplay from './NameDisplay'; +import { useNameList } from 'apps/web/src/components/Basenames/ManageNames/hooks'; +import Link from 'apps/web/src/components/Link'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import AnalyticsProvider from 'apps/web/contexts/Analytics'; + +const usernameManagementListAnalyticContext = 'username_management_list'; + +function NamesLayout({ children }: { children: React.ReactNode }) { + return ( + +
    +
    +

    My Basenames

    + + + +
    + {children} +
    +
    + ); +} + +export default function NamesList() { + const { namesData, isLoading, error } = useNameList(); + + if (error) { + return ( + +
    + Failed to load names. Please try again later. +
    +
    + ); + } + + if (isLoading) { + return ( + +
    Loading names...
    +
    + ); + } + + if (!namesData?.data?.length) { + return ( + +
    + No names found. +
    +
    + + Get a Basename! + +
    +
    + ); + } + + return ( + +
      + {namesData.data.map((name) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/Basenames/ManageNames/hooks.tsx b/apps/web/src/components/Basenames/ManageNames/hooks.tsx new file mode 100644 index 0000000000..63d1a1b355 --- /dev/null +++ b/apps/web/src/components/Basenames/ManageNames/hooks.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect } from 'react'; +import { useErrors } from 'apps/web/contexts/Errors'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAccount, useChainId } from 'wagmi'; +import { ManagedAddressesResponse } from 'apps/web/src/types/ManagedAddresses'; +import useSetPrimaryBasename from 'apps/web/src/hooks/useSetPrimaryBasename'; +import { Basename } from '@coinbase/onchainkit/identity'; + +export function useNameList() { + const { address } = useAccount(); + const chainId = useChainId(); + const { logError } = useErrors(); + + const network = chainId === 8453 ? 'base-mainnet' : 'base-sepolia'; + + const { + data: namesData, + isLoading, + error, + } = useQuery({ + queryKey: ['usernames', address, network], + queryFn: async (): Promise => { + try { + const response = await fetch( + `/api/basenames/getUsernames?address=${address}&network=${network}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch usernames: ${response.statusText}`); + } + return (await response.json()) as ManagedAddressesResponse; + } catch (err) { + logError(err, 'Failed to fetch usernames'); + throw err; + } + }, + enabled: !!address, + }); + + return { namesData, isLoading, error }; +} + +export function useRemoveNameFromUI(domain: Basename) { + const { address } = useAccount(); + const chainId = useChainId(); + + const network = chainId === 8453 ? 'base-mainnet' : 'base-sepolia'; + const queryClient = useQueryClient(); + + const removeNameFromUI = useCallback(() => { + queryClient.setQueryData( + ['usernames', address, network], + (prevData: ManagedAddressesResponse) => { + return { ...prevData, data: prevData.data.filter((name) => name.domain !== domain) }; + }, + ); + }, [address, domain, network, queryClient]); + + return { removeNameFromUI }; +} + +export function useUpdatePrimaryName(domain: Basename) { + const { address } = useAccount(); + const chainId = useChainId(); + const { logError } = useErrors(); + + const queryClient = useQueryClient(); + + const network = chainId === 8453 ? 'base-mainnet' : 'base-sepolia'; + + // Hook to update primary name + const { setPrimaryName, transactionIsSuccess } = useSetPrimaryBasename({ + secondaryUsername: domain, + }); + + const setPrimaryUsername = useCallback(async () => { + try { + await setPrimaryName(); + } catch (error) { + logError(error, 'Failed to update primary name'); + throw error; + } + }, [logError, setPrimaryName]); + + useEffect(() => { + if (transactionIsSuccess) { + queryClient.setQueryData( + ['usernames', address, network], + (prevData: ManagedAddressesResponse) => { + return { + ...prevData, + data: prevData.data.map((name) => + name.domain === domain + ? { ...name, is_primary: true } + : name.is_primary + ? { ...name, is_primary: false } + : name, + ), + }; + }, + ); + } + }, [transactionIsSuccess, address, domain, network, queryClient]); + + return { setPrimaryUsername }; +} diff --git a/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context.tsx b/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context.tsx index 8a5dd1254d..04b785421a 100644 --- a/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context.tsx @@ -337,9 +337,11 @@ export default function ProfileTransferOwnershipProvider({ // Smart wallet: One transaction batchCallsStatus === BatchCallsStatus.Success || // Other wallet: 4 Transactions are successfull - ownershipSettings.every( - (ownershipSetting) => ownershipSetting.status === WriteTransactionWithReceiptStatus.Success, - ), + (ownershipSettings.length > 0 && + ownershipSettings.every( + (ownershipSetting) => + ownershipSetting.status === WriteTransactionWithReceiptStatus.Success, + )), [batchCallsStatus, ownershipSettings], ); diff --git a/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/index.tsx b/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/index.tsx index 0a6c90fc42..d03eb2b539 100644 --- a/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/index.tsx @@ -28,11 +28,13 @@ const ownershipStepsTitleForDisplay = { type UsernameProfileTransferOwnershipModalProps = { isOpen: boolean; onClose: () => void; + onSuccess?: () => void; }; export default function UsernameProfileTransferOwnershipModal({ isOpen, onClose, + onSuccess, }: UsernameProfileTransferOwnershipModalProps) { // Hooks const { address } = useAccount(); @@ -103,8 +105,9 @@ export default function UsernameProfileTransferOwnershipModal({ useEffect(() => { if (isSuccess) { setCurrentOwnershipStep(OwnershipSteps.Success); + onSuccess?.(); } - }, [isSuccess, setCurrentOwnershipStep]); + }, [isSuccess, setCurrentOwnershipStep, onSuccess]); return ( { - setOpen(false); + const timeoutId = setTimeout(() => { + setOpen(false); + }, 300); + return () => clearTimeout(timeoutId); }, []); const openDropdown = useCallback(() => { @@ -56,7 +59,15 @@ export default function Dropdown({ children }: DropdownProps) { return ( -
    +
    {children}
    diff --git a/apps/web/src/components/DropdownMenu/index.tsx b/apps/web/src/components/DropdownMenu/index.tsx index a2bdadcae3..0d17afd834 100644 --- a/apps/web/src/components/DropdownMenu/index.tsx +++ b/apps/web/src/components/DropdownMenu/index.tsx @@ -44,8 +44,8 @@ export default function DropdownMenu({ let dropdownStyle: CSSProperties = {}; if (dropdownToggleRef?.current) { const { top, height, right } = dropdownToggleRef.current.getBoundingClientRect(); - dropdownStyle.top = top + height + 'px'; - dropdownStyle.left = `${right}px`; + dropdownStyle.top = top + height + window.scrollY + 'px'; + dropdownStyle.left = `${right + window.scrollX}px`; dropdownStyle.transform = `translateX(-100%)`; } @@ -61,8 +61,8 @@ export default function DropdownMenu({ let arrowStyle: CSSProperties = {}; if (dropdownToggleRef?.current) { const { top, height, left, width } = dropdownToggleRef.current.getBoundingClientRect(); - arrowStyle.top = top + height + 'px'; - arrowStyle.left = `${left + width / 2}px`; + arrowStyle.top = top + height + window.scrollY + 'px'; + arrowStyle.left = `${left + width / 2 + window.scrollX}px`; } return ( diff --git a/apps/web/src/components/Icon/Icon.tsx b/apps/web/src/components/Icon/Icon.tsx index f68b34edfb..49e0a5fc45 100644 --- a/apps/web/src/components/Icon/Icon.tsx +++ b/apps/web/src/components/Icon/Icon.tsx @@ -538,6 +538,59 @@ const ICONS: Record JSX.Element> = { /> ), + list: ({ color, width, height }: SvgProps) => ( + + + + + + + + + ), + verticalDots: ({ color, width, height }: SvgProps) => ( + + + + ), + transfer: ({ color, width, height }: SvgProps) => ( + + + + ), }; export function Icon({ name, color = 'white', width = '24', height = '24' }: IconProps) { diff --git a/apps/web/src/components/Layout/UsernameNav/index.tsx b/apps/web/src/components/Layout/UsernameNav/index.tsx index 73d5e1a9bc..e042d9db71 100644 --- a/apps/web/src/components/Layout/UsernameNav/index.tsx +++ b/apps/web/src/components/Layout/UsernameNav/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; import usernameBaseLogo from './usernameBaseLogo.svg'; +import Link from 'apps/web/src/components/Link'; import { ConnectWalletButton, @@ -42,7 +42,7 @@ export default function UsernameNav() { [switchChain], ); - const walletStateClasses = classNames('p2 rounded', { + const walletStateClasses = classNames('p2 rounded flex items-center gap-6', { 'bg-white': isConnected, }); @@ -111,6 +111,14 @@ export default function UsernameNav() { + {isConnected && ( + + + + My Basenames + + + )} { + const setPrimaryName = useCallback(async (): Promise => { // Already primary - if (secondaryUsername === primaryUsername) return; + if (secondaryUsername === primaryUsername) return undefined; // No user is connected - if (!address) return; + if (!address) return undefined; try { await initiateTransaction({ @@ -81,6 +81,7 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima }); } catch (error) { logError(error, 'Set primary name transaction canceled'); + return undefined; } return true; @@ -95,5 +96,5 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima const isLoading = transactionIsLoading || primaryUsernameIsLoading || primaryUsernameIsFetching; - return { setPrimaryName, canSetUsernameAsPrimary, isLoading }; + return { setPrimaryName, canSetUsernameAsPrimary, isLoading, transactionIsSuccess }; } diff --git a/apps/web/src/types/ManagedAddresses.ts b/apps/web/src/types/ManagedAddresses.ts new file mode 100644 index 0000000000..5ffc4aa53a --- /dev/null +++ b/apps/web/src/types/ManagedAddresses.ts @@ -0,0 +1,17 @@ +export type ManagedAddressesData = { + domain: string; + expires_at: string; + is_primary: boolean; + manager_address: string; + network_id: string; + owner_address: string; + primary_address: string; + token_id: string; +}; + +export type ManagedAddressesResponse = { + data: ManagedAddressesData[]; + has_more: boolean; + next_page: string; + total_count: number; +}; diff --git a/yarn.lock b/yarn.lock index aee8802291..5cd976b724 100644 --- a/yarn.lock +++ b/yarn.lock @@ -401,6 +401,7 @@ __metadata: classnames: ^2.5.1 cloudinary: ^2.5.1 csv-parser: ^3.0.0 + date-fns: ^4.1.0 dd-trace: ^5.21.0 dotenv: ^16.0.3 eslint-config-next: ^13.1.6 @@ -13298,6 +13299,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: fb681b242cccabed45494468f64282a7d375ea970e0adbcc5dcc92dcb7aba49b2081c2c9739d41bf71ce89ed68dd73bebfe06ca35129490704775d091895710b + languageName: node + linkType: hard + "dc-polyfill@npm:^0.1.4": version: 0.1.6 resolution: "dc-polyfill@npm:0.1.6"