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
This commit is contained in:
Matthew Bunday
2024-11-07 15:26:45 -05:00
committed by GitHub
parent ac97665c4a
commit ce51c6e33d
16 changed files with 479 additions and 17 deletions

View File

@@ -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 });
}

View File

@@ -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<string, string>),
},
};
export default async function Page() {
return (
<ErrorsProvider context="registration">
<main className="mt-48">
<NamesList />
</main>
</ErrorsProvider>
);
}

View File

@@ -3,4 +3,4 @@
/// <reference types="next/navigation-types/compat/navigation" />
// 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.

View File

@@ -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",

View File

@@ -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<boolean>(false);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
const { removeNameFromUI } = useRemoveNameFromUI(domain as Basename);
return (
<li key={tokenId} className={pillNameClasses}>
<div className="flex items-center justify-between">
<Link href={`/name/${domain.split('.')[0]}`}>
<div className="flex items-center gap-4">
<BasenameAvatar
basename={domain as Basename}
wrapperClassName={avatarClasses}
width={4 * 16}
height={4 * 16}
/>
<div>
<p className="text-lg font-medium">{domain}</p>
<p className="text-sm opacity-75">Expires {expirationText}</p>
</div>
</div>
</Link>
<div className="flex items-center gap-2">
{isPrimary && (
<span className="rounded-full bg-white px-2 py-1 text-sm text-black">Primary</span>
)}
<Dropdown>
<DropdownToggle>
<Icon name="verticalDots" color="currentColor" width="2rem" height="2rem" />
</DropdownToggle>
<DropdownMenu>
<DropdownItem onClick={openModal}>
<span className="flex flex-row items-center gap-2">
<Icon name="transfer" color="currentColor" width="1rem" height="1rem" /> Transfer
name
</span>
</DropdownItem>
{!isPrimary ? (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<DropdownItem onClick={setPrimaryUsername}>
<span className="flex flex-row items-center gap-2">
<Icon name="plus" color="currentColor" width="1rem" height="1rem" /> Set as
primary
</span>
</DropdownItem>
) : null}
</DropdownMenu>
</Dropdown>
</div>
</div>
<UsernameProfileProvider username={domain as Basename}>
<ProfileTransferOwnershipProvider>
<UsernameProfileTransferOwnershipModal
isOpen={isOpen}
onClose={closeModal}
onSuccess={removeNameFromUI}
/>
</ProfileTransferOwnershipProvider>
</UsernameProfileProvider>
</li>
);
}

View File

@@ -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 (
<AnalyticsProvider context={usernameManagementListAnalyticContext}>
<div className="mx-auto max-w-2xl space-y-4 px-6 pb-16 pt-4">
<div className="flex items-center justify-between">
<h1 className="mb-4 text-3xl font-bold">My Basenames</h1>
<Link
className="rounded-lg bg-palette-backgroundAlternate p-2 text-sm text-palette-foreground"
href="/names/"
>
<Icon name="plus" color="currentColor" width="12px" height="12px" />
</Link>
</div>
{children}
</div>
</AnalyticsProvider>
);
}
export default function NamesList() {
const { namesData, isLoading, error } = useNameList();
if (error) {
return (
<NamesLayout>
<div className="text-palette-error">
<span className="text-lg">Failed to load names. Please try again later.</span>
</div>
</NamesLayout>
);
}
if (isLoading) {
return (
<NamesLayout>
<div>Loading names...</div>
</NamesLayout>
);
}
if (!namesData?.data?.length) {
return (
<NamesLayout>
<div>
<span className="text-lg">No names found.</span>
<br />
<br />
<Link href="/names/" className="text-lg font-bold text-palette-primary underline">
Get a Basename!
</Link>
</div>
</NamesLayout>
);
}
return (
<NamesLayout>
<ul className="mx-auto flex max-w-2xl flex-col gap-4">
{namesData.data.map((name) => (
<NameDisplay
key={name.token_id}
domain={name.domain}
isPrimary={name.is_primary}
tokenId={name.token_id}
expiresAt={name.expires_at}
/>
))}
</ul>
</NamesLayout>
);
}

View File

@@ -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<ManagedAddressesResponse>({
queryKey: ['usernames', address, network],
queryFn: async (): Promise<ManagedAddressesResponse> => {
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 };
}

View File

@@ -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],
);

View File

@@ -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 (
<Modal

View File

@@ -47,7 +47,10 @@ export default function Dropdown({ children }: DropdownProps) {
}, [open, lastCopiedId]);
const closeDropdown = useCallback(() => {
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 (
<DropdownContext.Provider value={values}>
<div onMouseLeave={closeDropdown} onMouseEnter={openDropdown}>
<div
onMouseLeave={closeDropdown}
onMouseEnter={openDropdown}
style={{
padding: '0 8px 0 120px',
margin: '0 -8px 0 -120px',
position: 'relative',
}}
>
{children}
</div>
</DropdownContext.Provider>

View File

@@ -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 (

View File

@@ -538,6 +538,59 @@ const ICONS: Record<string, (props: SvgProps) => JSX.Element> = {
/>
</svg>
),
list: ({ color, width, height }: SvgProps) => (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M16 0.75H5.5V3.25H16V0.75Z" fill={color} />
<path
d="M2 4C3.10457 4 4 3.10457 4 2C4 0.89543 3.10457 0 2 0C0.89543 0 0 0.89543 0 2C0 3.10457 0.89543 4 2 4Z"
fill={color}
/>
<path
d="M2 10C3.10457 10 4 9.10457 4 8C4 6.89543 3.10457 6 2 6C0.89543 6 0 6.89543 0 8C0 9.10457 0.89543 10 2 10Z"
fill={color}
/>
<path
d="M2 16C3.10457 16 4 15.1046 4 14C4 12.8954 3.10457 12 2 12C0.89543 12 0 12.8954 0 14C0 15.1046 0.89543 16 2 16Z"
fill={color}
/>
<path d="M16 6.75H5.5V9.25H16V6.75Z" fill={color} />
<path d="M16 12.75H5.5V15.25H16V12.75Z" fill={color} />
</svg>
),
verticalDots: ({ color, width, height }: SvgProps) => (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0"
fill={color}
/>
</svg>
),
transfer: ({ color, width, height }: SvgProps) => (
<svg
width={width}
height={height}
viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.43483 1.93433L5.5662 3.0657L3.93188 4.70001H10.2505V6.30001H3.93188L5.5662 7.93433L4.43483 9.0657L0.869141 5.50001L4.43483 1.93433ZM11.5662 7.93433L15.1319 11.5L11.5662 15.0657L10.4348 13.9343L12.0691 12.3H5.75051V10.7H12.0691L10.4348 9.0657L11.5662 7.93433Z"
fill={color}
/>
</svg>
),
};
export function Icon({ name, color = 'white', width = '24', height = '24' }: IconProps) {

View File

@@ -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() {
<ImageAdaptive src={usernameBaseLogo as StaticImageData} alt="Base" />
</Link>
<span className={walletStateClasses}>
{isConnected && (
<span className="text-md text-palette-primary">
<Link href="/manage-names" className="flex items-center gap-2">
<Icon name="list" color="currentColor" width="1rem" height="1rem" />
My Basenames
</Link>
</span>
)}
<Suspense>
<ConnectWalletButton
connectWalletButtonVariant={ConnectWalletButtonVariants.Basename}

View File

@@ -60,12 +60,12 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima
}
}, [logError, refetchPrimaryUsername, transactionIsSuccess]);
const setPrimaryName = useCallback(async () => {
const setPrimaryName = useCallback(async (): Promise<boolean | undefined> => {
// 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 };
}

View File

@@ -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;
};

View File

@@ -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"