Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiname management #1216

Merged
merged 30 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a581e98
Scaffold page
zencephalon Oct 31, 2024
8ecfa80
Add NamesList component
zencephalon Nov 4, 2024
cb6fa61
API endppint for getUsernames
zencephalon Nov 4, 2024
7536ed9
Add NamesList component
zencephalon Nov 4, 2024
a38862a
Move route
zencephalon Nov 4, 2024
518ca9b
Ugly list demo working
zencephalon Nov 4, 2024
b6e3354
Style it up a bit
zencephalon Nov 4, 2024
720bdac
Manage names list styling and expiry display
zencephalon Nov 4, 2024
cca3c2b
Style the header
zencephalon Nov 4, 2024
44f16d6
Add triple dot icon
zencephalon Nov 4, 2024
0a8c9da
Triple dot dropdown menu
zencephalon Nov 4, 2024
4be6b52
Set as primary working
zencephalon Nov 5, 2024
a2dad45
Work on transfers, checkpoint
zencephalon Nov 5, 2024
900ec6a
Work on transfers
zencephalon Nov 5, 2024
af56c0b
Lint unused dep
zencephalon Nov 6, 2024
efe4b87
UI polish
zencephalon Nov 6, 2024
ea30df4
Merge branch 'master' into feat/multiname-management
zencephalon Nov 6, 2024
fa1441e
Add empty state
zencephalon Nov 6, 2024
9e11588
Resolve type errors?
zencephalon Nov 6, 2024
967f3ef
Reolve types
zencephalon Nov 6, 2024
e09b223
Slightly better empty state
zencephalon Nov 6, 2024
6feb139
Remove console.log
zencephalon Nov 6, 2024
410eb0d
Only show My Basenames if the user has a wallet connected
zencephalon Nov 6, 2024
e10b5ae
Improve dropdown mechanics
zencephalon Nov 6, 2024
8a610a6
Spacing feedback
zencephalon Nov 6, 2024
7de653e
Error handling ala Leo
zencephalon Nov 6, 2024
25c2506
Handle success / failure correctly
zencephalon Nov 6, 2024
e5d4a1d
Lint
zencephalon Nov 6, 2024
b3c89e4
Add some mobile margin
zencephalon Nov 7, 2024
b478172
Fix mobile padding
zencephalon Nov 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 '../../../../../src/types/ManagedAddresses';
zencephalon marked this conversation as resolved.
Show resolved Hide resolved

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
110 changes: 110 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'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 ? (
<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>
);
}
71 changes: 71 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/NamesList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'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 p-8">
<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 } = useNameList();

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>
);
}
90 changes: 90 additions & 0 deletions apps/web/src/components/Basenames/ManageNames/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useCallback } 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 network = chainId === 8453 ? 'base-mainnet' : 'base-sepolia';

const { data: namesData, isLoading } = useQuery<ManagedAddressesResponse>({
queryKey: ['usernames', address, network],
queryFn: async (): Promise<ManagedAddressesResponse> => {
const response = await fetch(
`/api/basenames/getUsernames?address=${address}&network=${network}`,
);
if (!response.ok) {
throw new Error('Failed to fetch usernames');
}
return response.json() as Promise<ManagedAddressesResponse>;
},
enabled: !!address,
});

return { namesData, isLoading };
zencephalon marked this conversation as resolved.
Show resolved Hide resolved
}

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 } = useSetPrimaryBasename({
secondaryUsername: domain,
}) as { setPrimaryName: () => Promise<void> };

const setPrimaryUsername = useCallback(() => {
setPrimaryName()
.then(() => {
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,
),
};
},
);
})
.catch((error) => {
logError(error, 'Failed to update primary name');
});
}, [address, domain, logError, network, queryClient, setPrimaryName]);

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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -103,8 +105,9 @@ export default function UsernameProfileTransferOwnershipModal({
useEffect(() => {
if (isSuccess) {
setCurrentOwnershipStep(OwnershipSteps.Success);
onSuccess?.();
}
}, [isSuccess, setCurrentOwnershipStep]);
}, [isSuccess, setCurrentOwnershipStep, onSuccess]);

return (
<Modal
Expand Down
Loading
Loading