Skip to content

Commit

Permalink
Multiname management (#1216)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zencephalon authored Nov 7, 2024
1 parent ac97665 commit ce51c6e
Show file tree
Hide file tree
Showing 16 changed files with 479 additions and 17 deletions.
29 changes: 29 additions & 0 deletions apps/web/app/(basenames)/api/basenames/getUsernames/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
32 changes: 32 additions & 0 deletions apps/web/app/(basenames)/manage-names/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
2 changes: 1 addition & 1 deletion apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
81 changes: 81 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/NamesList.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
105 changes: 105 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/hooks.tsx
Original file line number Diff line number Diff line change
@@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);

Expand Down
Loading

0 comments on commit ce51c6e

Please sign in to comment.