mirror of
https://github.com/placeholder-soft/web.git
synced 2026-01-12 22:45:00 +08:00
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:
29
apps/web/app/(basenames)/api/basenames/getUsernames/route.ts
Normal file
29
apps/web/app/(basenames)/api/basenames/getUsernames/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
32
apps/web/app/(basenames)/manage-names/page.tsx
Normal file
32
apps/web/app/(basenames)/manage-names/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -3,4 +3,4 @@
|
|||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"base-ui": "0.1.1",
|
"base-ui": "0.1.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"cloudinary": "^2.5.1",
|
"cloudinary": "^2.5.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"dd-trace": "^5.21.0",
|
"dd-trace": "^5.21.0",
|
||||||
"ethers": "5.7.2",
|
"ethers": "5.7.2",
|
||||||
"framer-motion": "^11.9.0",
|
"framer-motion": "^11.9.0",
|
||||||
|
|||||||
111
apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx
Normal file
111
apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/src/components/Basenames/ManageNames/NamesList.tsx
Normal file
81
apps/web/src/components/Basenames/ManageNames/NamesList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/components/Basenames/ManageNames/hooks.tsx
Normal file
105
apps/web/src/components/Basenames/ManageNames/hooks.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@@ -337,9 +337,11 @@ export default function ProfileTransferOwnershipProvider({
|
|||||||
// Smart wallet: One transaction
|
// Smart wallet: One transaction
|
||||||
batchCallsStatus === BatchCallsStatus.Success ||
|
batchCallsStatus === BatchCallsStatus.Success ||
|
||||||
// Other wallet: 4 Transactions are successfull
|
// Other wallet: 4 Transactions are successfull
|
||||||
|
(ownershipSettings.length > 0 &&
|
||||||
ownershipSettings.every(
|
ownershipSettings.every(
|
||||||
(ownershipSetting) => ownershipSetting.status === WriteTransactionWithReceiptStatus.Success,
|
(ownershipSetting) =>
|
||||||
),
|
ownershipSetting.status === WriteTransactionWithReceiptStatus.Success,
|
||||||
|
)),
|
||||||
[batchCallsStatus, ownershipSettings],
|
[batchCallsStatus, ownershipSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ const ownershipStepsTitleForDisplay = {
|
|||||||
type UsernameProfileTransferOwnershipModalProps = {
|
type UsernameProfileTransferOwnershipModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsernameProfileTransferOwnershipModal({
|
export default function UsernameProfileTransferOwnershipModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
|
onSuccess,
|
||||||
}: UsernameProfileTransferOwnershipModalProps) {
|
}: UsernameProfileTransferOwnershipModalProps) {
|
||||||
// Hooks
|
// Hooks
|
||||||
const { address } = useAccount();
|
const { address } = useAccount();
|
||||||
@@ -103,8 +105,9 @@ export default function UsernameProfileTransferOwnershipModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
setCurrentOwnershipStep(OwnershipSteps.Success);
|
setCurrentOwnershipStep(OwnershipSteps.Success);
|
||||||
|
onSuccess?.();
|
||||||
}
|
}
|
||||||
}, [isSuccess, setCurrentOwnershipStep]);
|
}, [isSuccess, setCurrentOwnershipStep, onSuccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export default function Dropdown({ children }: DropdownProps) {
|
|||||||
}, [open, lastCopiedId]);
|
}, [open, lastCopiedId]);
|
||||||
|
|
||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openDropdown = useCallback(() => {
|
const openDropdown = useCallback(() => {
|
||||||
@@ -56,7 +59,15 @@ export default function Dropdown({ children }: DropdownProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownContext.Provider value={values}>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</DropdownContext.Provider>
|
</DropdownContext.Provider>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export default function DropdownMenu({
|
|||||||
let dropdownStyle: CSSProperties = {};
|
let dropdownStyle: CSSProperties = {};
|
||||||
if (dropdownToggleRef?.current) {
|
if (dropdownToggleRef?.current) {
|
||||||
const { top, height, right } = dropdownToggleRef.current.getBoundingClientRect();
|
const { top, height, right } = dropdownToggleRef.current.getBoundingClientRect();
|
||||||
dropdownStyle.top = top + height + 'px';
|
dropdownStyle.top = top + height + window.scrollY + 'px';
|
||||||
dropdownStyle.left = `${right}px`;
|
dropdownStyle.left = `${right + window.scrollX}px`;
|
||||||
dropdownStyle.transform = `translateX(-100%)`;
|
dropdownStyle.transform = `translateX(-100%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ export default function DropdownMenu({
|
|||||||
let arrowStyle: CSSProperties = {};
|
let arrowStyle: CSSProperties = {};
|
||||||
if (dropdownToggleRef?.current) {
|
if (dropdownToggleRef?.current) {
|
||||||
const { top, height, left, width } = dropdownToggleRef.current.getBoundingClientRect();
|
const { top, height, left, width } = dropdownToggleRef.current.getBoundingClientRect();
|
||||||
arrowStyle.top = top + height + 'px';
|
arrowStyle.top = top + height + window.scrollY + 'px';
|
||||||
arrowStyle.left = `${left + width / 2}px`;
|
arrowStyle.left = `${left + width / 2 + window.scrollX}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -538,6 +538,59 @@ const ICONS: Record<string, (props: SvgProps) => JSX.Element> = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</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) {
|
export function Icon({ name, color = 'white', width = '24', height = '24' }: IconProps) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
|
||||||
import usernameBaseLogo from './usernameBaseLogo.svg';
|
import usernameBaseLogo from './usernameBaseLogo.svg';
|
||||||
|
import Link from 'apps/web/src/components/Link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConnectWalletButton,
|
ConnectWalletButton,
|
||||||
@@ -42,7 +42,7 @@ export default function UsernameNav() {
|
|||||||
[switchChain],
|
[switchChain],
|
||||||
);
|
);
|
||||||
|
|
||||||
const walletStateClasses = classNames('p2 rounded', {
|
const walletStateClasses = classNames('p2 rounded flex items-center gap-6', {
|
||||||
'bg-white': isConnected,
|
'bg-white': isConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +111,14 @@ export default function UsernameNav() {
|
|||||||
<ImageAdaptive src={usernameBaseLogo as StaticImageData} alt="Base" />
|
<ImageAdaptive src={usernameBaseLogo as StaticImageData} alt="Base" />
|
||||||
</Link>
|
</Link>
|
||||||
<span className={walletStateClasses}>
|
<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>
|
<Suspense>
|
||||||
<ConnectWalletButton
|
<ConnectWalletButton
|
||||||
connectWalletButtonVariant={ConnectWalletButtonVariants.Basename}
|
connectWalletButtonVariant={ConnectWalletButtonVariants.Basename}
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima
|
|||||||
}
|
}
|
||||||
}, [logError, refetchPrimaryUsername, transactionIsSuccess]);
|
}, [logError, refetchPrimaryUsername, transactionIsSuccess]);
|
||||||
|
|
||||||
const setPrimaryName = useCallback(async () => {
|
const setPrimaryName = useCallback(async (): Promise<boolean | undefined> => {
|
||||||
// Already primary
|
// Already primary
|
||||||
if (secondaryUsername === primaryUsername) return;
|
if (secondaryUsername === primaryUsername) return undefined;
|
||||||
|
|
||||||
// No user is connected
|
// No user is connected
|
||||||
if (!address) return;
|
if (!address) return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initiateTransaction({
|
await initiateTransaction({
|
||||||
@@ -81,6 +81,7 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Set primary name transaction canceled');
|
logError(error, 'Set primary name transaction canceled');
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -95,5 +96,5 @@ export default function useSetPrimaryBasename({ secondaryUsername }: UseSetPrima
|
|||||||
|
|
||||||
const isLoading = transactionIsLoading || primaryUsernameIsLoading || primaryUsernameIsFetching;
|
const isLoading = transactionIsLoading || primaryUsernameIsLoading || primaryUsernameIsFetching;
|
||||||
|
|
||||||
return { setPrimaryName, canSetUsernameAsPrimary, isLoading };
|
return { setPrimaryName, canSetUsernameAsPrimary, isLoading, transactionIsSuccess };
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/web/src/types/ManagedAddresses.ts
Normal file
17
apps/web/src/types/ManagedAddresses.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -401,6 +401,7 @@ __metadata:
|
|||||||
classnames: ^2.5.1
|
classnames: ^2.5.1
|
||||||
cloudinary: ^2.5.1
|
cloudinary: ^2.5.1
|
||||||
csv-parser: ^3.0.0
|
csv-parser: ^3.0.0
|
||||||
|
date-fns: ^4.1.0
|
||||||
dd-trace: ^5.21.0
|
dd-trace: ^5.21.0
|
||||||
dotenv: ^16.0.3
|
dotenv: ^16.0.3
|
||||||
eslint-config-next: ^13.1.6
|
eslint-config-next: ^13.1.6
|
||||||
@@ -13298,6 +13299,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"dc-polyfill@npm:^0.1.4":
|
||||||
version: 0.1.6
|
version: 0.1.6
|
||||||
resolution: "dc-polyfill@npm:0.1.6"
|
resolution: "dc-polyfill@npm:0.1.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user