chore: register domain

This commit is contained in:
Fernando Falci
2024-04-22 11:42:54 +02:00
parent eb210480bc
commit ac6095e2b2
20 changed files with 658 additions and 38 deletions

View File

@@ -1,15 +1,24 @@
import { ConnectButton } from '@rainbow-me/rainbowkit';
import walletLogo from './assets/wallet.id.png';
import searchIcon from './assets/search.png';
import brandLogo from './assets/brand.png';
import hnsLogo from './assets/hns.id.png';
import { Search } from './components/search/Search';
import {
HERO_TEXT,
PAGE_TITLE,
SUB_TEXT,
TLD,
TWITTER_HANDLE,
} from './constants';
import { useDocumentTitle } from '@uidotdev/usehooks';
function App() {
useDocumentTitle(PAGE_TITLE);
return (
<div className="bg-neutral-50 h-screen flex">
<div className="flex-col justify-between items-center flex max-w-7xl mx-auto flex-1">
<div className="flex-col justify-center items-center gap-10 flex w-full">
<div className="h-20 p-4 justify-between items-center inline-flex w-full ">
<img className="w-36" src={walletLogo} />
<img className="w-36" src={brandLogo} />
<div className="justify-start items-center gap-6 flex ">
<ConnectButton />
</div>
@@ -18,22 +27,14 @@ function App() {
<div className="flex-col justify-start items-center gap-4 fle">
<div className="self-stretch flex-col justify-start items-center gap-4 flex">
<div className="self-stretch text-center text-neutral-950 text-5xl font-bold leading-relaxed">
Own your .wallet
{HERO_TEXT}
</div>
<div className="self-stretch h-10 text-center text-neutral-500 text-2xl font-bold leading-normal">
Decentralized domains for websites, wallets and web3
{SUB_TEXT}
</div>
</div>
</div>
<div className="w-full bg-white rounded-2xl shadow border border-zinc-300 justify-between items-center inline-flex relative">
<input
className="grow shrink basis-0 text-neutral-400 text-base font-medium leading-tight tracking-tight p-5 rounded-2xl "
placeholder="Find your .wallet"
/>
<div className="w-5 h-5 absolute right-3">
<img src={searchIcon} />
</div>
</div>
<Search />
</div>
</div>
<div className="px-4 py-6 border-t border-gray-200 flex-col justify-start items-center gap-6 flex w-full">
@@ -41,7 +42,7 @@ function App() {
<div className="justify-start items-center gap-6 flex">
<a
className="text-neutral-700 text-sm font-medium uppercase"
href="https://twitter.com/walletdomain"
href={`https://twitter.com/${TWITTER_HANDLE}`}
>
Twitter
</a>
@@ -53,7 +54,7 @@ function App() {
</a>
<a
className="text-neutral-700 text-sm font-medium uppercase"
href="https://opensea.io/collection/handshake-slds?search[stringTraits][0][name]=TLD&search[stringTraits][0][values][0]=wallet"
href={`https://opensea.io/collection/handshake-slds?search[stringTraits][0][name]=TLD&search[stringTraits][0][values][0]=${TLD}`}
>
Opensea
</a>

98
src/abi.js Normal file
View File

@@ -0,0 +1,98 @@
export const abi = [
{
type: 'function',
name: 'getDomainDetails',
inputs: [
{
name: '_buyer',
type: 'address',
internalType: 'address',
},
{
name: '_registrationDays',
type: 'uint256',
internalType: 'uint256',
},
{
name: '_parentHash',
type: 'bytes32',
internalType: 'bytes32',
},
{
name: '_label',
type: 'string',
internalType: 'string',
},
],
outputs: [
{
name: '',
type: 'tuple',
internalType: 'struct DomainDetails',
components: [
{
name: 'isAvailable',
type: 'bool',
internalType: 'bool',
},
{
name: 'labelValid',
type: 'bool',
internalType: 'bool',
},
{
name: 'publicRegistrationOpen',
type: 'bool',
internalType: 'bool',
},
{
name: 'owner',
type: 'address',
internalType: 'address',
},
{
name: 'expiry',
type: 'uint256',
internalType: 'uint256',
},
{
name: 'isPremium',
type: 'bool',
internalType: 'bool',
},
{
name: 'reservedAddress',
type: 'address',
internalType: 'address',
},
{
name: 'priceInDollars',
type: 'uint256',
internalType: 'uint256',
},
{
name: 'priceInWei',
type: 'uint256',
internalType: 'uint256',
},
],
},
],
stateMutability: 'view',
},
{
stateMutability: 'payable',
type: 'function',
inputs: [
{ name: '_label', internalType: 'string', type: 'string' },
{ name: '_registrationLength', internalType: 'uint256', type: 'uint256' },
{ name: '_parentNamehash', internalType: 'bytes32', type: 'bytes32' },
{ name: '_recipient', internalType: 'address', type: 'address' },
{ name: 'v', internalType: 'uint8', type: 'uint8' },
{ name: 'r', internalType: 'bytes32', type: 'bytes32' },
{ name: 's', internalType: 'bytes32', type: 'bytes32' },
],
name: 'registerWithSignature',
outputs: [],
},
];

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
src/assets/check-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

BIN
src/assets/loading.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

24
src/components/Badge.jsx Normal file
View File

@@ -0,0 +1,24 @@
import cn from 'classnames';
import PropTypes from 'prop-types';
export const Badge = ({ children, variant, title }) => (
<div
title={title}
className={cn(
'px-3 py-2 bg-opacity-10 rounded-2xl justify-center items-center gap-2 flex text-xs font-semibold leading-none',
{
'bg-emerald-500 text-emerald-500': variant === 'success',
'bg-amber-500 text-amber-500': variant === 'premium',
'bg-neutral-500 text-neutral-500': variant === 'taken',
}
)}
>
{children}
</div>
);
Badge.propTypes = {
children: PropTypes.node,
variant: PropTypes.oneOf(['success', 'premium', 'taken']),
title: PropTypes.string,
};

27
src/components/Button.jsx Normal file
View File

@@ -0,0 +1,27 @@
import cn from 'classnames';
import PropTypes from 'prop-types';
import icon from '../assets/loading.png';
export const Button = ({ children, onClick, loading }) => (
<button
className={cn(
'px-3 py-2 rounded-full justify-center flex',
'text-white text-sm font-semibold leading-none w-24 text-center',
loading ? 'bg-blue-400' : 'bg-blue-600'
)}
onClick={onClick}
disabled={loading}
>
{loading ? (
<img src={icon} alt="Loading" className="animate-spin w-4" />
) : (
children
)}
</button>
);
Button.propTypes = {
children: PropTypes.node,
onClick: PropTypes.func,
loading: PropTypes.bool,
};

View File

@@ -0,0 +1,35 @@
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useRegister } from '../hooks/useRegister';
import { domainDetails } from '../types';
import { Button } from './Button';
import { useState } from 'react';
export const RegisterButton = ({ details }) => {
const [loading, setLoading] = useState(false);
const register = useRegister(details);
const client = useQueryClient();
const onClick = () => {
setLoading(true);
register()
.then(() => {
toast.success('Domain registered');
client.refetchQueries();
})
.catch((e) => {
toast.error(e.cause?.shortMessage || e.cause?.message);
})
.finally(() => setLoading(false));
};
return (
<Button onClick={onClick} loading={loading}>
Register
</Button>
);
};
RegisterButton.propTypes = {
details: domainDetails,
};

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useDebounce } from '@uidotdev/usehooks';
import { SearchInput } from './SearchInput';
import { SearchStatus } from './SearchStatus';
import { SearchCTA } from './SearchCTA';
import { SearchPrice } from './SearchPrice';
import { DEV_MODE, TLD } from '../../constants';
import { useDomainStatus } from '../../hooks/useDomainStatus';
export const Search = () => {
// "term" is the value from the input (first part if it contains a ".")
const [term, setTerm] = useState('');
// label is the same as term, but delayed 300ms (while the user is typing)
const label = useDebounce(term, 300);
// data contains all the info about the domain
const { data, failureReason } = useDomainStatus({ label });
const error = failureReason?.cause?.shortMessage;
// only _show_ the second line if we have a label
const show = !!label;
// if there's a "." (like "foo.bar"), we only use the first part (foo)
const onChange = (term) => setTerm(term.split('.').at(0));
// ensure the current data is related to the final search term
const safeData = term === data?.label ? data : undefined;
return (
<>
<div className="w-full bg-white rounded-2xl border border-zinc-300 shadow">
<SearchInput expand={show} onChange={onChange} />
{show && (
<div className="w-full px-5 pt-3 pb-4 bg-white flex-col justify-start items-start gap-4 inline-flex rounded-b-2xl">
<div className="self-stretch py-1 justify-between items-center inline-flex">
<div className="rounded justify-start items-center gap-2 flex">
<div>
<span className="text-neutral-950 text-xl font-medium leading-loose">
{label}
</span>
<span className="text-neutral-400 text-xl font-medium leading-loose">
.{TLD}
</span>
</div>
<SearchStatus details={safeData} />
</div>
<div className="justify-start items-center gap-4 flex">
<SearchPrice details={safeData} />
<SearchCTA details={safeData} />
</div>
</div>
</div>
)}
</div>
{DEV_MODE && (
<div className="flex flex-col gap-2">
<span className="text-red-600 font-semibold bg-yellow-300 px-4 py-2 rounded-md">
TESTNET
</span>
{error && (
<div className="p-2 text-red-600 bg-red-100 rounded-md">
{error}
</div>
)}
{data && (
<pre className="p-2 text-gray-500 bg-gray-100 border border-gray-500 rounded-lg">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
)}
</>
);
};

View File

@@ -0,0 +1,49 @@
import { ConnectButton } from '@rainbow-me/rainbowkit';
import Skeleton from 'react-loading-skeleton';
import { useAccount } from 'wagmi';
import { TLD } from '../../constants';
import { domainDetails } from '../../types';
import { RegisterButton } from '../RegisterButton';
export const SearchCTA = ({ details }) => {
const { address, isConnected } = useAccount();
if (!details) {
return <Skeleton className="w-24 h-7" />;
}
if (isConnected && details.owner === address) {
const url = `https://hns.id/domain/${details.label}.${TLD}`;
return (
<a
className=" px-3 py-2 bg-white rounded-full border border-blue-600 justify-center items-center gap-2.5 inline-flex"
href={url}
>
<div className="text-blue-600 text-sm font-semibold leading-none">
Manage Domain
</div>
</a>
);
}
const canRegister =
details.publicRegistrationOpen &&
details.priceInWei > 0n &&
details.labelValid &&
details.isAvailable &&
(!details.reservedAddress || details.reservedAddress === address);
if (!canRegister) {
return null;
}
if (!isConnected) {
return <ConnectButton />;
}
return <RegisterButton details={details} />;
};
SearchCTA.propTypes = {
details: domainDetails,
};

View File

@@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import cn from 'classnames';
import searchIcon from '../../assets/search.png';
import { SEARCH_PLACEHOLDER } from '../../constants';
export const SearchInput = ({ expand, onChange }) => {
const handleChange = (e) => onChange(e.target.value);
return (
<div
className={cn(
'w-full justify-between items-center inline-flex relative',
expand ? 'rounded-t-2xl' : 'rounded-2xl'
)}
>
<input
className={cn(
'grow shrink basis-0 rounded-t-2xl text-neutral-400 text-base font-medium leading-tight tracking-tight p-5 border-zinc-300 focus:outline-none',
expand ? 'rounded-t-2xl border-b' : 'rounded-2xl'
)}
onChange={handleChange}
placeholder={SEARCH_PLACEHOLDER}
/>
<div className="w-5 h-5 absolute right-4">
<img src={searchIcon} />
</div>
</div>
);
};
SearchInput.propTypes = {
expand: PropTypes.bool,
onChange: PropTypes.func,
};

View File

@@ -0,0 +1,27 @@
import Skeleton from 'react-loading-skeleton';
import { domainDetails } from '../../types';
export const SearchPrice = ({ details }) => {
if (!details) {
return <Skeleton className="w-24 h-7" />;
}
if (!details.isAvailable || details.priceInWei === 0n) {
return null;
}
return (
<div className="flex h-5 items-center gap-1">
<span className="text-neutral-900 text-sm font-bold leading-normal">
${details.priceInDollars}
</span>
<span className="text-neutral-400 text-xs font-bold leading-none">
/ year
</span>
</div>
);
};
SearchPrice.propTypes = {
details: domainDetails,
};

View File

@@ -0,0 +1,51 @@
import { useAccount } from 'wagmi';
import Skeleton from 'react-loading-skeleton';
import { Badge } from '../Badge';
import { domainDetails } from '../../types';
import checkMark from '../../assets/check-mark.png';
export const SearchStatus = ({ details }) => {
const { address, isConnected } = useAccount();
if (!details) {
return <Skeleton className="w-20 h-7" />;
}
if (isConnected && details.owner === address) {
return <img src={checkMark} className="w-6" />;
}
if (!details.isAvailable) {
return <Badge variant="taken">Taken</Badge>;
}
if (!details.labelValid) {
return <Badge variant="taken">Invalid Domain</Badge>;
}
if (!details.publicRegistrationOpen) {
return <Badge variant="taken">Coming Soon</Badge>;
}
if (details.reservedAddress && details.reservedAddress !== address) {
return (
<Badge
variant="premium"
title={`Reserved for: ${details.reservedAddress}`}
>
Reserved
</Badge>
);
}
if (details.isPremium) {
return <Badge variant="premium">Premium</Badge>;
}
return <Badge variant="success">Available</Badge>;
};
SearchStatus.propTypes = {
details: domainDetails,
};

23
src/constants.js Normal file
View File

@@ -0,0 +1,23 @@
export const DEV_MODE = location.hostname === 'localhost';
// export const DEV_MODE = false;
export const TLD = 'wallet';
export const HERO_TEXT = 'Own your .wallet';
export const SUB_TEXT = 'Decentralized domains for websites, wallets and web3';
export const SEARCH_PLACEHOLDER = 'Find your .wallet';
export const PAGE_TITLE = HERO_TEXT;
export const TWITTER_HANDLE = 'walletdomain';
// Check https://cloud.walletconnect.com/
export const WALLET_CONNECT_APP_NAME = 'Wallet.id';
export const WALLET_CONNECT_PROJECT_ID = 'b29f96b2f68f4ce904f96b3c225845c3';
export const STATUS_CONTRACT_ADDR = DEV_MODE
? '0x075489a52BcF5cd91c589046C3F5807e7fFC3647'
: '0xa89356391fB34e18360E79102536daD46F4a4199';
export const REGISTER_CONTRACT_ADDR = DEV_MODE
? '0x529B2b5B576c27769Ae0aB811F1655012f756C00'
: '0xfda87CC032cD641ac192027353e5B25261dfe6b3';

View File

@@ -0,0 +1,51 @@
import { namehash, zeroAddress } from 'viem';
import { useAccount, useReadContract } from 'wagmi';
import { abi } from '../abi';
import { STATUS_CONTRACT_ADDR, TLD } from '../constants';
export const useDomainStatus = ({
label,
buyer,
registrationDays = BigInt(365),
parentHash = namehash(TLD),
} = {}) => {
const { address } = useAccount();
return useReadContract({
address: STATUS_CONTRACT_ADDR,
functionName: 'getDomainDetails',
abi: abi,
args: [
buyer || address || zeroAddress,
registrationDays,
parentHash,
label,
],
query: {
enabled: !!label,
select: (data) => {
// remove 0x00
if (data.owner === zeroAddress) {
data.owner = undefined;
}
if (data.reservedAddress === zeroAddress) {
data.reservedAddress = undefined;
}
if (data.expiry === 0n) {
data.expiry = undefined;
} else {
data.expiry = new Date(Number(data.expiry) * 1000);
}
// price comes in cents: 100 = 1.00
data.priceInDollars = Number(data.priceInDollars / 100n).toFixed(2);
data.label = label;
return data;
},
},
});
};

69
src/hooks/useRegister.js Normal file
View File

@@ -0,0 +1,69 @@
import { useAccount, useSimulateContract, useWriteContract } from 'wagmi';
import { useQuery } from '@tanstack/react-query';
import { namehash } from 'viem';
import { DEV_MODE, REGISTER_CONTRACT_ADDR, TLD } from '../constants';
import { abi } from '../abi';
const HashZero =
'0x0000000000000000000000000000000000000000000000000000000000000000';
const fetchSignature = async (buyer, subdomainHash, nonce) => {
const params = new URLSearchParams({
buyer,
subdomainHash,
nonce: nonce.toString(),
});
const host = DEV_MODE ? 'hnst.id' : 'hns.id';
return fetch(
`https://${host}/api/gateway/registration?${params.toString()}`,
{
headers: {
origin: window.location.origin,
},
}
)
.then((response) => response.json())
.then((data) => data.signature);
};
export const useRegister = ({
label,
priceInWei,
years = 1,
nonce = 0,
} = {}) => {
const tldHash = namehash(TLD);
const nameHash = namehash(`${label}.${TLD}`);
const registrationDays = BigInt(years * 365);
const { address, isConnected } = useAccount();
const { data: signature } = useQuery({
queryKey: ['signature', address, nameHash, nonce.toString()],
queryFn: () => fetchSignature(address, nameHash, nonce),
enabled: isConnected,
});
const { data } = useSimulateContract({
abi,
address: REGISTER_CONTRACT_ADDR,
functionName: 'registerWithSignature',
account: address,
value: priceInWei,
enabled: !!signature && !!priceInWei && !!address,
args: [
label,
registrationDays,
tldHash,
address,
signature?.v ?? 0,
signature?.r ?? HashZero,
signature?.s ?? HashZero,
],
});
const { writeContractAsync } = useWriteContract();
return () => writeContractAsync(data?.request);
};

View File

@@ -2,29 +2,42 @@ import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ToastContainer } from 'react-toastify';
import { WagmiProvider } from 'wagmi';
import { optimism } from 'wagmi/chains';
import { optimism, optimismSepolia } from 'wagmi/chains';
import App from './App.jsx';
import {
DEV_MODE,
WALLET_CONNECT_APP_NAME,
WALLET_CONNECT_PROJECT_ID,
} from './constants.js';
import { CustomAvatar } from './CustomAvatar.jsx';
import '@rainbow-me/rainbowkit/styles.css';
import 'react-loading-skeleton/dist/skeleton.css';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import { CustomAvatar } from './CustomAvatar.jsx';
const queryClient = new QueryClient();
const config = getDefaultConfig({
appName: 'Wallet.id',
projectId: 'YOUR_PROJECT_ID',
chains: [optimism],
ssr: true, // If your dApp uses server side rendering (SSR)
appName: WALLET_CONNECT_APP_NAME,
projectId: WALLET_CONNECT_PROJECT_ID,
chains: [DEV_MODE ? optimismSepolia : optimism],
});
BigInt.prototype.toJSON = function () {
return this.toString();
};
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider avatar={CustomAvatar}>
<App />
<ToastContainer />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>

14
src/types.js Normal file
View File

@@ -0,0 +1,14 @@
import PropTypes from 'prop-types';
export const domainDetails = PropTypes.shape({
label: PropTypes.string.isRequired,
isAvailable: PropTypes.bool.isRequired,
labelValid: PropTypes.bool.isRequired,
publicRegistrationOpen: PropTypes.bool.isRequired,
owner: PropTypes.string,
expiry: PropTypes.instanceOf(Date),
isPremium: PropTypes.bool.isRequired,
reservedAddress: PropTypes.string,
priceInDollars: PropTypes.string.isRequired,
priceInWei: PropTypes.bigint.isRequired,
});