diff --git a/.eslintrc.js b/.eslintrc.js index 82c4456..593d2cf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -26,6 +26,7 @@ module.exports = { 'plugin:relay/strict', ], rules: { + 'import/extensions': ['error', 'never'], 'react/destructuring-assignment': 'off', 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx', '.mdx'] }], diff --git a/.gitignore b/.gitignore index dd428be..70cb9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env # Node stuff node_modules yarn-debug.log* @@ -71,4 +72,4 @@ persisted_queries.json **/*.graphql.ts # eslint -.eslintcache \ No newline at end of file +.eslintcache diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c1dec2..ea70eb1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,11 @@ { "pattern": "examples/*" }, { "pattern": "libs/*" }, { "pattern": "packages/*" } - ] + ], + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[svg]": { + "editor.defaultFormatter": "jock.svg" + } } diff --git a/apps/base-docs/docs/tools/data-indexers.md b/apps/base-docs/docs/tools/data-indexers.md index 2193dad..ab49619 100644 --- a/apps/base-docs/docs/tools/data-indexers.md +++ b/apps/base-docs/docs/tools/data-indexers.md @@ -153,7 +153,6 @@ Subsquid offers a powerful toolkit for creating custom data extraction and proce To get started, visit the [documentation](https://docs.subsquid.io/) or see this [quickstart with examples](https://docs.subsquid.io/sdk/examples/) on how to easily create subgraphs via Subsquid. - #### Supported Networks - Base Mainnet diff --git a/apps/base-docs/package.json b/apps/base-docs/package.json index f116c81..95f6fe0 100644 --- a/apps/base-docs/package.json +++ b/apps/base-docs/package.json @@ -24,7 +24,7 @@ "@mdx-js/react": "^1.6.22", "@microsoft/fetch-event-source": "^2.0.1", "@rainbow-me/rainbowkit": "^2.1.3", - "@tanstack/react-query": "^5.29.0", + "@tanstack/react-query": "^5.51.11", "@types/dompurify": "^3.0.5", "base-ui": "0.1.1", "body-parser": "^1.20.2", diff --git a/apps/base-docs/src/components/Banner/Banner.tsx b/apps/base-docs/src/components/Banner/Banner.tsx index 82adf12..a59e64a 100644 --- a/apps/base-docs/src/components/Banner/Banner.tsx +++ b/apps/base-docs/src/components/Banner/Banner.tsx @@ -12,12 +12,12 @@ import styles from './styles.module.css'; type BannerName = `${string}Banner`; type BannerProps = { - href: string; - text: string; - bannerName: BannerName; + href: string; + text: string; + bannerName: BannerName; }; -export default function Banner({ href, text, bannerName}: BannerProps) { +export default function Banner({ href, text, bannerName }: BannerProps) { const [isBannerVisible, setIsBannerVisible] = useLocalStorage(`${bannerName}Visible'`, true); const linkClick = useCallback(() => { diff --git a/apps/base-docs/src/components/FooterCategory/index.tsx b/apps/base-docs/src/components/FooterCategory/index.tsx index 0c16f37..8626736 100644 --- a/apps/base-docs/src/components/FooterCategory/index.tsx +++ b/apps/base-docs/src/components/FooterCategory/index.tsx @@ -1,10 +1,6 @@ import React, { useCallback } from 'react'; -import logEvent, { - AnalyticsEventData, - AnalyticsEventImportance, - CCAEventData, -} from 'base-ui/utils/logEvent'; +import logEvent, { AnalyticsEventData } from 'base-ui/utils/logEvent'; import styles from './styles.module.css'; type FooterLinkType = { diff --git a/apps/base-docs/src/components/ImageCard/index.tsx b/apps/base-docs/src/components/ImageCard/index.tsx index 93d565b..b1ab0ae 100644 --- a/apps/base-docs/src/components/ImageCard/index.tsx +++ b/apps/base-docs/src/components/ImageCard/index.tsx @@ -1,10 +1,6 @@ import React, { useCallback } from 'react'; -import logEvent, { - AnalyticsEventData, - AnalyticsEventImportance, - CCAEventData, -} from 'base-ui/utils/logEvent'; +import logEvent, { AnalyticsEventData } from 'base-ui/utils/logEvent'; import styles from './styles.module.css'; type ImageCardProps = { diff --git a/apps/base-docs/src/components/TextCard/index.tsx b/apps/base-docs/src/components/TextCard/index.tsx index 2fe4d5f..9d77820 100644 --- a/apps/base-docs/src/components/TextCard/index.tsx +++ b/apps/base-docs/src/components/TextCard/index.tsx @@ -2,11 +2,7 @@ import React, { useCallback } from 'react'; import Icon from '../Icon'; import styles from './styles.module.css'; -import logEvent, { - AnalyticsEventData, - AnalyticsEventImportance, - CCAEventData, -} from 'base-ui/utils/logEvent'; +import logEvent, { AnalyticsEventData } from 'base-ui/utils/logEvent'; type TextCardProps = { title: string; diff --git a/apps/base-docs/src/theme/DocSidebarItem/Html/styles.module.css b/apps/base-docs/src/theme/DocSidebarItem/Html/styles.module.css index 69ce544..414d699 100644 --- a/apps/base-docs/src/theme/DocSidebarItem/Html/styles.module.css +++ b/apps/base-docs/src/theme/DocSidebarItem/Html/styles.module.css @@ -1,6 +1,5 @@ @media (min-width: 997px) { .menuHtmlItem { - padding: var(--ifm-menu-link-padding-vertical) - var(--ifm-menu-link-padding-horizontal); + padding: var(--ifm-menu-link-padding-vertical) var(--ifm-menu-link-padding-horizontal); } } diff --git a/apps/bridge/package.json b/apps/bridge/package.json index 763261b..9541675 100644 --- a/apps/bridge/package.json +++ b/apps/bridge/package.json @@ -13,7 +13,7 @@ "@bugsnag/plugin-react": "^7.19.0", "@eth-optimism/contracts-ts": "^0.16.2", "@eth-optimism/indexer-api": "^0.0.4", - "@headlessui/react": "latest", + "@headlessui/react": "^1.7.19", "@heroicons/react": "^2.0.13", "@rainbow-me/rainbowkit": "^1", "@types/react-copy-to-clipboard": "^5.0.4", diff --git a/apps/bridge/src/components/Nav/MobileMenu.tsx b/apps/bridge/src/components/Nav/MobileMenu.tsx index 8559b18..dbf4c4c 100644 --- a/apps/bridge/src/components/Nav/MobileMenu.tsx +++ b/apps/bridge/src/components/Nav/MobileMenu.tsx @@ -219,13 +219,22 @@ function MobileMenu({ color }: MobileMenuProps) { target="_blank" rel="noreferrer noopener" title="Join us on Farcaster" + aria-label="Join us on Farcaster" > - + - + diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 1e18f17..0d2d0d8 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -9,3 +9,11 @@ MAINNET_LAUNCH_BLOG_POST_URL=https://base.mirror.xyz MAINNET_LAUNCH_FLAG=false NEXT_PUBLIC_ECOSYSTEM_LAUNCH_FLAG=false NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= +VERIFICATION_VERIFIED_ACCOUNT_SCHEMA_ID= +VERIFICATION_VERIFIED_CB1_ACCOUNT_SCHEMA_ID= +TRUSTED_SIGNER_ADDRESS= +TRUSTED_SIGNER_PRIVATE_KEY= + +NEXT_PUBLIC_USERNAMES_EARLY_ACCESS= +NEXT_PUBLIC_BASE_SEPOLIA_PAYMASTER_SERVICE= +NEXT_PUBLIC_BASE_PAYMASTER_SERVICE= \ No newline at end of file diff --git a/apps/web/contexts/Analytics.tsx b/apps/web/contexts/Analytics.tsx new file mode 100644 index 0000000..ea23043 --- /dev/null +++ b/apps/web/contexts/Analytics.tsx @@ -0,0 +1,59 @@ +import logEvent, { + ActionType, + AnalyticsEventImportance, + CCAEventData, +} from 'libs/base-ui/utils/logEvent'; +import { ReactNode, createContext, useCallback, useContext, useMemo } from 'react'; + +export type AnalyticsContextProps = { + logEventWithContext: (eventName: string, action: ActionType, eventData?: CCAEventData) => void; + fullContext: string; +}; + +export const AnalyticsContext = createContext({ + logEventWithContext: function () { + return undefined; + }, + fullContext: '', +}); + +export function useAnalytics() { + const context = useContext(AnalyticsContext); + if (context === undefined) { + throw new Error('useAnalytics must be used within a AnalyticsProvider'); + } + return context; +} + +type AnalyticsProviderProps = { + children?: ReactNode; + context: string; // TODO: This could be an enum in CCAEventData +}; + +export default function AnalyticsProvider({ children, context }: AnalyticsProviderProps) { + const { fullContext: previousContext } = useAnalytics(); + + const fullContext = [previousContext, context].filter((c) => !!c).join('_'); + const logEventWithContext = useCallback( + (eventName: string, action: ActionType, eventData?: CCAEventData) => { + const sanitizedEventName = eventName.toLocaleLowerCase(); + logEvent( + sanitizedEventName, // TODO: Do we want context here? + { + action: action, + context: fullContext, + page_path: window.location.pathname, + ...eventData, + }, + AnalyticsEventImportance.high, + ); + }, + [fullContext], + ); + + const values = useMemo(() => { + return { logEventWithContext, context, fullContext }; + }, [context, fullContext, logEventWithContext]); + + return {children}; +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 4f11a03..fd36f94 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js index bed6b11..7625c93 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -76,6 +76,8 @@ const contentSecurityPolicy = { ], 'connect-src': [ "'self'", + 'https://blob.vercel-storage.com', // Vercel File storage + 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // Vercel File storage walletconnectDomains, sprigDomains, greenhouseDomains, @@ -84,9 +86,14 @@ const contentSecurityPolicy = { 'wss://www.walletlink.org/rpc', // coinbase wallet connection 'https://analytics-service-dev.cbhq.net', 'mainnet.base.org', + 'sepolia.base.org', 'https://cloudflare-eth.com', 'https://i.seadn.io/', // ens avatars 'https://api.opensea.io', // enables getting ENS avatars + 'https://ipfs.io', // ipfs ens avatar resolution + 'wss://www.walletlink.org', + 'https://base.easscan.org/graphql', + 'https://api.guild.xyz/', isLocalDevelopment ? 'ws://localhost:3000/' : '', isLocalDevelopment ? 'http://localhost:3000/' : '', 'https://flag.lab.amplitude.com/sdk/v2/flags', @@ -96,9 +103,13 @@ const contentSecurityPolicy = { 'form-action': ["'self'", baseXYZDomains], 'img-src': [ "'self'", + 'blob:', + 'https://blob.vercel-storage.com', // Vercel File storage + 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // Vercel File storage 'data:', 'https://*.walletconnect.com/', // WalletConnect 'https://i.seadn.io/', // ens avatars + 'https://ipfs.io', // ipfs ens avatar resolution ], }; @@ -154,6 +165,24 @@ module.exports = extendBaseConfig( protocol: 'https', hostname: 'i.seadn.io', }, + { + protocol: 'https', + hostname: 'ipfs.io', + }, + { + protocol: 'https', + hostname: 'cf-ipfs.com', + }, + { + protocol: 'https', + hostname: 'blob.vercel-storage.com', + port: '', + }, + { + protocol: 'https', + hostname: 'zku9gdedgba48lmr.public.blob.vercel-storage.com', + port: '', + }, ], }, async headers() { diff --git a/apps/web/package.json b/apps/web/package.json index fd13eb8..2e16e90 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,17 +13,29 @@ "dependencies": { "@coinbase/cookie-banner": "^1.0.3", "@coinbase/cookie-manager": "^1.1.1", - "@coinbase/onchainkit": "^0.6.0", - "@heroicons/react": "^2.0.18", + "@coinbase/onchainkit": "^0.26.3", + "@guildxyz/sdk": "2.6.0", + "@headlessui/react": "^1.7.19", + "@heroicons/react": "^2.1.3", + "@next/font": "^13.1.5", + "@radix-ui/react-tooltip": "^1.1.2", "@rainbow-me/rainbowkit": "^2.1.3", - "@tanstack/react-query": "^5.29.2", + "@tanstack/react-query": "^5.51.11", + "@types/jsonwebtoken": "^9.0.6", + "@vercel/blob": "^0.23.4", "@vercel/kv": "^1.0.1", + "@vercel/og": "^0.6.2", "@vercel/postgres-kysely": "^0.8.0", "base-ui": "0.1.1", + "classnames": "^2.5.1", "ethers": "5.7.2", "framer-motion": "^8.5.5", + "jose": "^5.4.1", + "jsonwebtoken": "^9.0.2", "kysely": "^0.27.3", - "next": "^13.2.0", + "next": "^13.5.6", + "node-fetch": "^3.3.0", + "permissionless": "^0.1.41", "pg": "^8.12.0", "react": "^18.2.0", "react-blockies": "^1.4.1", @@ -31,21 +43,24 @@ "react-dom": "^18.2.0", "react-intersection-observer": "^9.10.2", "react-intl": "^6.2.1", + "satori": "^0.10.14", "sharp": "^0.33.4", + "twemoji": "^14.0.2", "usehooks-ts": "^3.1.0", "uuidv4": "^6.2.13", "viem": "2.x", - "wagmi": "^2.5.20" + "wagmi": "^2.11.3" }, "devDependencies": { "@types/node": "18.11.18", "@types/pg": "^8.11.6", + "@types/react": "^18", "@types/react-copy-to-clipboard": "^5.0.7", + "@types/react-dom": "^18", "autoprefixer": "^10.4.13", "csv-parser": "^3.0.0", "dotenv": "^16.0.3", "eslint-config-next": "^13.1.6", - "node-fetch": "^3.3.0", "postcss": "^8.4.21", "prettier-plugin-tailwindcss": "^0.2.5", "tailwindcss": "^3.2.4", diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index d808bc9..37a60c9 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -1,3 +1,4 @@ +import '@coinbase/onchainkit/styles.css'; import './global.css'; import { @@ -6,6 +7,8 @@ import { TrackingCategory, TrackingPreference, } from '@coinbase/cookie-manager'; +import { OnchainKitProvider } from '@coinbase/onchainkit'; +import { Provider as TooltipProvider } from '@radix-ui/react-tooltip'; import { connectorsForWallets, RainbowKitProvider } from '@rainbow-me/rainbowkit'; import '@rainbow-me/rainbowkit/styles.css'; import { @@ -16,14 +19,16 @@ import { walletConnectWallet, } from '@rainbow-me/rainbowkit/wallets'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UserAvatar } from 'apps/web/src/components/ConnectWalletButton/UserAvatar'; import useSprig from 'base-ui/hooks/useSprig'; import { MotionConfig } from 'framer-motion'; +import { NextPage } from 'next'; import { AppProps } from 'next/app'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { createConfig, http, WagmiProvider } from 'wagmi'; import { base, baseSepolia, mainnet, sepolia } from 'wagmi/chains'; import ClientAnalyticsScript from '../src/components/ClientAnalyticsScript/ClientAnalyticsScript'; -import { Layout } from '../src/components/Layout/Layout'; +import { Layout, NavigationType } from '../src/components/Layout/Layout'; import { cookieManagerConfig } from '../src/utils/cookieManagerConfig'; import ExperimentsProvider from 'base-ui/contexts/Experiments'; @@ -57,11 +62,18 @@ const config = createConfig({ }, ssr: true, }); - const queryClient = new QueryClient(); const sprigEnvironmentId = process.env.NEXT_PUBLIC_SPRIG_ENVIRONMENT_ID; -export default function StaticApp({ Component, pageProps }: AppProps) { +export type NextPageWithLayout

= NextPage & { + getLayout?: (page: ReactElement) => ReactNode; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout; +}; + +export default function StaticApp({ Component, pageProps }: AppPropsWithLayout) { // Cookie Manager Provider Configuration const [isMounted, setIsMounted] = useState(false); const trackingPreference = useRef(); @@ -99,6 +111,10 @@ export default function StaticApp({ Component, pageProps }: AppProps) { useSprig(sprigEnvironmentId); + const getLayout = + Component.getLayout ?? + ((page) => {page}); + if (!isMounted) return null; return ( @@ -115,13 +131,18 @@ export default function StaticApp({ Component, pageProps }: AppProps) { - - - - - - - + + + + + {getLayout()} + + + + diff --git a/apps/web/pages/api/addresses/[address]/proof/index.ts b/apps/web/pages/api/addresses/[address]/proof/index.ts deleted file mode 100644 index 7e431ea..0000000 --- a/apps/web/pages/api/addresses/[address]/proof/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getProofsByNamespaceAndAddress, ProofTableNamespace } from 'apps/web/src/utils/proofs'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'GET') { - return res.status(405).json({ error: 'method not allowed' }); - } - const { address, namespace } = req.query; - if (!address) { - return res.status(400).json({ error: 'address is required' }); - } - - try { - const content = await getProofsByNamespaceAndAddress( - address as string, - namespace as ProofTableNamespace, - ); - - return res.status(200).json({ result: content }); - } catch (error) { - console.error(error); - } - - return res.status(404).json({ error: 'address is not eligible for this project' }); -} diff --git a/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx new file mode 100644 index 0000000..83cc39d --- /dev/null +++ b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx @@ -0,0 +1,111 @@ +import satori from 'satori'; +import { NextRequest } from 'next/server'; +import { getUserNamePicture } from 'apps/web/src/utils/usernames'; +import twemoji from 'twemoji'; + +const emojiCache: Record> = {}; + +export async function loadEmoji(emojiString: string) { + const code = twemoji.convert.toCodePoint(emojiString); + + if (code in emojiCache) { + return emojiCache[code]; + } + + // TODO: Is this okay? Vercel's OG image already does these calls + const url = `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`; + + return (emojiCache[code] = fetch(url).then(async (r) => r.text())); +} + +export const config = { + runtime: 'edge', +}; + +// TODO: Do we want to check if the name actually exists? +export default async function handler(request: NextRequest) { + const fontData = await fetch( + new URL('../../../../../src/fonts/CoinbaseDisplay-Regular.ttf', import.meta.url), + ).then(async (res) => res.arrayBuffer()); + + // TODO: Check this works in live/production + const url = new URL(request.url); + const username = url.searchParams.get('name') ?? 'yourname'; + const domainName = `${url.protocol}//${url.host}`; + const profilePicture = getUserNamePicture(username); + + // Using Satori for a SVG response + const svg = await satori( +

+
+
+ {username} +
+ + {username} + +
+
, + { + width: 1000, + height: 1000, + fonts: [ + { + name: 'CoinbaseDisplay', + data: fontData, + weight: 500, + style: 'normal', + }, + ], + loadAdditionalAsset: async (code: string, segment: string) => { + if (code === 'emoji') { + return `data:image/svg+xml;base64,${btoa(await loadEmoji(segment))}`; + } + + return code; + }, + }, + ); + + return new Response(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + }, + }); +} diff --git a/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx b/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx new file mode 100644 index 0000000..70d5460 --- /dev/null +++ b/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx @@ -0,0 +1,86 @@ +import { ImageResponse } from '@vercel/og'; +import { getUserNamePicture } from 'apps/web/src/utils/usernames'; +import { NextRequest } from 'next/server'; +import tempPendingAnimation from 'apps/web/src/components/Basenames/tempPendingAnimation.png'; +import { openGraphImageHeight, openGraphImageWidth } from 'apps/web/src/utils/opengraphs'; + +export const config = { + runtime: 'edge', +}; + +// TODO: Do we want to check if the name actually exists? +export default async function handler(request: NextRequest) { + const fontData = await fetch( + new URL('../../../../../src/fonts/CoinbaseDisplay-Regular.ttf', import.meta.url), + ).then(async (res) => res.arrayBuffer()); + + // TODO: Check this works in live/production + const url = new URL(request.url); + const username = url.searchParams.get('name') ?? 'yourname'; + const domainName = `${url.protocol}//${url.host}`; + const profilePicture = getUserNamePicture(username); + + // Using vercel's OG image for a PNG response + return new ImageResponse( + ( +
+
+
+ {username} +
+ + {username} + +
+
+ ), + { + width: openGraphImageWidth, + height: openGraphImageHeight, + fonts: [ + { + name: 'Typewriter', + data: fontData, + style: 'normal', + }, + ], + }, + ); +} diff --git a/apps/web/pages/api/basenames/avatar/upload.ts b/apps/web/pages/api/basenames/avatar/upload.ts new file mode 100644 index 0000000..5de5da5 --- /dev/null +++ b/apps/web/pages/api/basenames/avatar/upload.ts @@ -0,0 +1,51 @@ +import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; +import type { NextApiResponse, NextApiRequest } from 'next'; + +export const ALLOWED_IMAGE_TYPE = [ + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]; + +export const MAX_IMAGE_SIZE_IN_MB = 1; // max 1mb + +export default async function handler(request: NextApiRequest, response: NextApiResponse) { + const body = request.body as HandleUploadBody; + const username = request.query.username; + if (!username) return; + + try { + const jsonResponse = await handleUpload({ + body, + request, + onBeforeGenerateToken: async (pathname) => { + // TODO: We can maybe compare username to an address for additional security + // Currently this endpoints allows anonymous upload + + // This should prevent random upload(s), but does not authenticate the source + if (!pathname.includes(`basenames/avatar/${username}`)) { + throw new Error('Issue with upload'); + } + + return { + pathname: pathname, + allowedContentTypes: ALLOWED_IMAGE_TYPE, + maximumSizeInBytes: MAX_IMAGE_SIZE_IN_MB * (1024 * 1024), + tokenPayload: JSON.stringify({ + username, + pathname, + }), + }; + }, + onUploadCompleted: async () => { + // TODO: Maybe analytics? + }, + }); + + return response.status(200).json(jsonResponse); + } catch (error) { + return response.status(400).json({ error: (error as Error).message }); + } +} diff --git a/apps/web/pages/api/basenames/contract-uri.json.ts b/apps/web/pages/api/basenames/contract-uri.json.ts new file mode 100644 index 0000000..76061c4 --- /dev/null +++ b/apps/web/pages/api/basenames/contract-uri.json.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { base } from 'viem/chains'; + +export const config = { + runtime: 'edge', +}; + +export default async function GET(request: Request) { + // TODO: Check this works in live/production + const url = new URL(request.url); + const domainName = `${url.protocol}//${url.host}`; + + const chainId = url.searchParams.get('chainId') ?? base.id; + if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 }); + + const name = Number(chainId) === base.id ? 'Basename' : 'Basename (Sepolia testnet)'; + + const tokenMedata = { + name: name, + description: 'TODO', + image: 'TODO', // TODO + banner_image: 'TODO.png', // TODO + featured_image: 'TODO.png', // TODO + external_link: `${domainName}/name`, + collaborators: [], + }; + + return NextResponse.json(tokenMedata); +} diff --git a/apps/web/pages/api/basenames/contract-uri.ts b/apps/web/pages/api/basenames/contract-uri.ts new file mode 100644 index 0000000..9c983b0 --- /dev/null +++ b/apps/web/pages/api/basenames/contract-uri.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { base } from 'viem/chains'; + +export const config = { + runtime: 'edge', +}; + +export default async function GET(request: Request) { + // TODO: Check this works in live/production + const url = new URL(request.url); + const chainId = url.searchParams.get('chainId') ?? base.id; + if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 }); + + return NextResponse.redirect( + new URL(`/api/basenames/contract-uri.json?chainId=${chainId}`, request.url), + ); +} diff --git a/apps/web/pages/api/basenames/metadata/[tokenId].ts b/apps/web/pages/api/basenames/metadata/[tokenId].ts new file mode 100644 index 0000000..06a8b7d --- /dev/null +++ b/apps/web/pages/api/basenames/metadata/[tokenId].ts @@ -0,0 +1,63 @@ +import L2Resolver from 'apps/web/src/abis/L2Resolver'; +import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames'; +import { getBasenamePublicClient } from 'apps/web/src/hooks/useBasenameChain'; +import { USERNAME_DOMAINS } from 'apps/web/src/utils/usernames'; +import { NextResponse } from 'next/server'; +import { encodePacked, keccak256, namehash, toHex } from 'viem'; +import { base } from 'viem/chains'; + +export const config = { + runtime: 'edge', +}; + +export default async function GET(request: Request) { + // TODO: Check this works in live/production + const url = new URL(request.url); + const domainName = `${url.protocol}//${url.host}`; + let tokenId = url.searchParams.get('tokenId'); + if (tokenId?.endsWith('.json')) tokenId = tokenId.slice(0, -5); + + const chainId = url.searchParams.get('chainId') ?? base.id; + const baseDomainName = USERNAME_DOMAINS[Number(chainId)]; + + if (!tokenId) return NextResponse.json({ error: '406: tokenId is missing' }, { status: 406 }); + if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 }); + if (!baseDomainName) + return NextResponse.json({ error: '406: base domain name is missing' }, { status: 406 }); + + // Get labelhash from tokenId + const labelHash = toHex(BigInt(tokenId)); + + // Convert labelhash to namehash + const namehashNode = keccak256( + encodePacked(['bytes32', 'bytes32'], [namehash(baseDomainName), labelHash]), + ); + + const client = getBasenamePublicClient(Number(chainId)); + const basename = await client.readContract({ + abi: L2Resolver, + address: USERNAME_L2_RESOLVER_ADDRESSES[Number(chainId)], + args: [namehashNode], + functionName: 'name', + }); + + if (!basename) return NextResponse.json({ error: '404: Basename not found' }, { status: 404 }); + + const tokenMedata = { + // This is the URL to the image of the item. + image: `${domainName}/api/basenames/${basename}/assets/cardImage.svg`, + + // This is the URL that will appear below the asset's image on OpenSea and will allow users to leave OpenSea and view the item on your site. + external_url: `${domainName}/name/${basename}`, + + // A human-readable description of the item. Markdown is supported. + description: `${basename}, a Basename`, + + // A human-readable description of the item. Markdown is supported. + name: basename, + + // TODO: attributes? + }; + + return NextResponse.json(tokenMedata); +} diff --git a/apps/web/pages/api/basenames/talentprotocol/[address].ts b/apps/web/pages/api/basenames/talentprotocol/[address].ts new file mode 100644 index 0000000..804519a --- /dev/null +++ b/apps/web/pages/api/basenames/talentprotocol/[address].ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.status(405).json({ error: 'method not allowed' }); + return; + } + const { address } = req.query; + + if (typeof address !== 'string') { + res.status(400).json({ error: 'address is required' }); + return; + } + + try { + const response = await fetch( + `https://api.talentprotocol.com/api/v2/passports/${encodeURIComponent(address || '')}`, + { + method: 'GET', + headers: { + 'X-API-KEY': process.env.TALENT_PROTOCOL_API_KEY, + }, + }, + ); + const data = await response.json(); + + if (data) { + return res.status(200).json(data); + } + } catch (error) { + console.error(error); + } + + return res.status(404).json({ error: 'address not found' }); +} diff --git a/apps/web/pages/api/name/[alreadyClaimedName].ts b/apps/web/pages/api/name/[alreadyClaimedName].ts new file mode 100644 index 0000000..71fdbaa --- /dev/null +++ b/apps/web/pages/api/name/[alreadyClaimedName].ts @@ -0,0 +1,62 @@ +import { queryCbGpt } from 'apps/web/src/cdp/api/cb-gpt'; +import { NextApiRequest, NextApiResponse } from 'next'; + +export type NameSuggestionResponseData = { + suggestion: string[]; +}; + +type ErrorResponseData = { + error: string; +}; + +type ApiResponse = NameSuggestionResponseData | ErrorResponseData; + +const NAME_COUNT = 3; +const SYSTEM_PROMPT = `You are an AI assistant tasked with providing alternative username recommendations +for the Ethereum Name Service (ENS). Users come to you when their desired ENS name is unavailable, +and you need to suggest alternative names that are both desirable and likely to be available. +You will be given one input. This is the name that the user originally wanted but is unavailable. +Follow these guidelines when generating alternative names: +1. Create names that are similar in style or +meaning to the unavailable name. +2. Suggestions should be very unlikely to already be taken. +3. You may use emoji in your suggestions. +4. Do not include any suffixes (i.e., .ens) in your suggestions. +5. Keep the names short and memorable. +6. Be creative and think of unique variations that the user might find appealing. +Your output should be a JSON array containing exactly ${NAME_COUNT} alternative name suggestions. Do not +include any explanation or additional text outside of the JSON array. +Remember, the goal is to provide alternatives that users will find desirable and that are likely to be available on ENS. +Focus on quality and creativity in your suggestions.`; +const chatLlm = 'claude-3-5-sonnet@20240620'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { alreadyClaimedName } = req.query; + if (typeof alreadyClaimedName !== 'string') { + res.status(400).json({ error: 'name must be a string' }); + return; + } + if (alreadyClaimedName.length > 50) { + res.status(400).json({ error: 'name too long to fetch recommendations' }); + return; + } + try { + const suggestion = await queryCbGpt({ + taskConfig: { + actionLlm: { + chatLlm: chatLlm, + }, + action_prompt_template: { + init_llm_chain: SYSTEM_PROMPT, + }, + }, + query: alreadyClaimedName, + }); + res.status(200).json({ suggestion: JSON.parse(suggestion.response) as string[] }); + } catch (e) { + console.error(e); + if (e instanceof Error) { + res.status(500).json({ error: `failed to generate suggestions ${e.message}` }); + } + } +} diff --git a/apps/web/pages/api/paymaster/index.ts b/apps/web/pages/api/paymaster/index.ts new file mode 100644 index 0000000..b77a52c --- /dev/null +++ b/apps/web/pages/api/paymaster/index.ts @@ -0,0 +1,40 @@ +import { UserOperation } from 'permissionless'; +import { paymasterClient } from '../../../src/utils/paymasterConfig'; +import { willSponsor } from '../../../src/utils/paymasterSponsor'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +type RequestBody = { + params: [ + UserOperation<'v0.6'>, // userOp + string, // endpoint + number, // chainId + ]; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const method = req.method; + if (!req.body?.params && !Array.isArray(req.body?.params)) { + return res.status(400).json({ error: 'invalid body' }); + } + const { params } = req.body as RequestBody; + const [userOp, entrypoint, chainId] = params; + try { + const willSponsorDecision = await willSponsor({ chainId, entrypoint, userOp }); + if (!willSponsorDecision) { + return res.json({ error: 'Not a sponsorable operation' }); + } + + if (method === 'pm_getPaymasterStubData') { + // @ts-expect-error verified functional by @keshavSinghal + const result = await paymasterClient.getPaymasterStubData({ userOperation: userOp }); + return res.json({ result }); + } else if (method === 'pm_getPaymasterData') { + // @ts-expect-error verified functional by @keshavSinghal + const result = await paymasterClient.getPaymasterData({ userOperation: userOp }); + return res.json({ result }); + } + } catch (e) { + console.error(e); + return res.status(500).json({ error: 'something went wrong validating ' }); + } +} diff --git a/apps/web/pages/api/proofs/cb1/index.ts b/apps/web/pages/api/proofs/cb1/index.ts new file mode 100644 index 0000000..b3d62a1 --- /dev/null +++ b/apps/web/pages/api/proofs/cb1/index.ts @@ -0,0 +1,70 @@ +import { trustedSignerPKey } from 'apps/web/src/constants'; +import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain'; +import { DiscountType } from 'apps/web/src/utils/proofs'; +import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { isAddress } from 'viem'; + +/** + * This endpoint checks if the provided address has access to the cb1 attestation. + * + * Possible Error Responses: + * - 400: Invalid address or missing verifications. + * - 405: Unauthorized method. + * - 409: User has already claimed a username. + * - 500: Internal server error. + * + * @returns {Object} - An object with the signed message, attestations, and discount validator address. + * Example response: + * { + * "signedMessage": "0x0000000000000000000000009c02e8e28d8b706f67dcf0fc7f46a9ee1f9649fa000000000000000000000000000000000000000000000000000000000000012c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000416f4b871a02406ddddbf6f6df1c58416830c5ce45becad5b4f30cf32f74ee39a5559659f9e29479bc76bb1ebf40fffc7119d09ed7c8dcaf6075956f83935263851b00000000000000000000000000000000000000000000000000000000000000", + * "attestations": [ + * { + * "name": "verifiedCoinbaseOne", + * "type": "bool", + * "signature": "bool verifiedCoinbaseOne", + * "value": { + * "name": "verifiedCoinbaseOne", + * "type": "bool", + * "value": true + * } + * } + * ], + * "discountValidatorAddress": "0x502df754f25f492cad45ed85a4de0ee7540717e7" + * } + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.status(405).json({ error: 'method not allowed' }); + return; + } + const { address, chain } = req.query; + + if (!address || Array.isArray(address) || !isAddress(address)) { + return res.status(400).json({ error: 'valid address is required' }); + } + + if (!trustedSignerPKey) { + return res.status(500).json({ error: 'currently unable to sign' }); + } + if (!chain || Array.isArray(chain)) { + return res.status(400).json({ error: 'chain must be a single value' }); + } + let parsedChain = parseInt(chain); + if (!isBasenameSupportedChain(parsedChain)) { + return res.status(400).json({ error: 'chain must be Base or Base Sepolia' }); + } + + try { + const result = await sybilResistantUsernameSigning(address, DiscountType.CB1, parsedChain); + return res.status(200).json(result); + } catch (error) { + console.error(error); + if (error instanceof Error) { + return res.status(409).json({ error: error.message }); + } + + // If error is not an instance of Error, return a generic error message + return res.status(409).json({ error: 'An unexpected error occurred' }); + } +} diff --git a/apps/web/pages/api/proofs/cbid/index.ts b/apps/web/pages/api/proofs/cbid/index.ts new file mode 100644 index 0000000..9feb5b2 --- /dev/null +++ b/apps/web/pages/api/proofs/cbid/index.ts @@ -0,0 +1,68 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { + getProofsByNamespaceAndAddress, + hasRegisteredWithDiscount, + ProofTableNamespace, +} from 'apps/web/src/utils/proofs'; +import { Address, isAddress } from 'viem'; +import { USERNAME_CB_ID_DISCOUNT_VALIDATORS } from 'apps/web/src/addresses/usernames'; +import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain'; + +export type CBIDProofResponse = { + discountValidatorAddress: Address; + address: Address; + namespace: string; + proofs: `0x${string}`[]; +}; + +/* +this endpoint returns whether or not the account has a cb.id +if result array is empty, user has no cb.id +example return: +{ + "address": "0xB18e4C959bccc8EF86D78DC297fb5efA99550d85", + "namespace": "usernames", + "proofs": "[0x56ce3bbc909b90035ae373d32c56a9d81d26bb505dd935cdee6afc384bcaed8d, 0x99e940ed9482bf59ba5ceab7df0948798978a1acaee0ecb41f64fe7f40eedd17]" + "discountValidatorAddress": "0x..." +} +*/ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'method not allowed' }); + } + const { address, chain } = req.query; + if (!address || Array.isArray(address) || !isAddress(address)) { + return res.status(400).json({ error: 'A single valid address is required' }); + } + + if (!chain || Array.isArray(chain)) { + return res.status(400).json({ error: 'invalid chain' }); + } + let parsedChain = parseInt(chain); + if (!isBasenameSupportedChain(parsedChain)) { + return res.status(400).json({ error: 'chain must be Base or Base Sepolia' }); + } + + try { + const hasPreviouslyRegistered = await hasRegisteredWithDiscount([address], parsedChain); + // if any linked address registered previously return an error + if (hasPreviouslyRegistered) { + return res.status(400).json({ error: 'This address has already claimed a username.' }); + } + const [content] = await getProofsByNamespaceAndAddress(address, ProofTableNamespace.Usernames); + const proofs = content?.proofs ? (JSON.parse(content.proofs) as `0x${string}`[]) : []; + if (proofs.length === 0) { + return res.status(404).json({ error: 'address is not eligible for a cbid discount' }); + } + const responseData: CBIDProofResponse = { + ...content, + proofs, + discountValidatorAddress: USERNAME_CB_ID_DISCOUNT_VALIDATORS[parsedChain], + }; + return res.status(200).json(responseData); + } catch (error: unknown) { + console.error(error); + } + + return res.status(404).json({ error: 'address is not eligible for a cbid discount' }); +} diff --git a/apps/web/pages/api/proofs/coinbase/index.ts b/apps/web/pages/api/proofs/coinbase/index.ts new file mode 100644 index 0000000..ec3c245 --- /dev/null +++ b/apps/web/pages/api/proofs/coinbase/index.ts @@ -0,0 +1,71 @@ +import { trustedSignerPKey } from 'apps/web/src/constants'; +import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain'; +import { DiscountType, VerifiedAccount } from 'apps/web/src/utils/proofs'; +import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { Address, isAddress } from 'viem'; + +// Coinbase verified account *and* CB1 structure +export type CoinbaseProofResponse = { + signedMessage?: string; + attestations: VerifiedAccount[]; + discountValidatorAddress: Address; + expires?: string; +}; + +/** + * This endpoint reports whether or not the provided access has access to the verified account attestation + * + * Error responses: + * 400: if address is invalid or missing verifications + * 405: for unauthorized methods + * 409: if user has already claimed a username + * 500: for internal server errors + * + * @param req + * { + * address: address to check if user is allowed to claim a new username with discount + * } + * @param res + * { + * signedMessage: this is to be passed into the contract to claim a username + * attestations: will show the attestations that the user has for verified account and verified cb1 account + * } + * @returns + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.status(405).json({ error: 'method not allowed' }); + return; + } + const { address, chain } = req.query; + + if (!address || Array.isArray(address) || !isAddress(address)) { + return res.status(400).json({ error: 'valid address is required' }); + } + + if (!trustedSignerPKey) { + return res.status(500).json({ error: 'currently unable to sign' }); + } + + if (!chain || Array.isArray(chain)) { + return res.status(400).json({ error: 'chain must be a single value' }); + } + let parsedChain = parseInt(chain); + if (!isBasenameSupportedChain(parsedChain)) { + return res.status(400).json({ error: 'chain must be Base or Base Sepolia' }); + } + + try { + const result = await sybilResistantUsernameSigning(address, DiscountType.CB, parsedChain); + return res.status(200).json(result); + } catch (error) { + console.error(error); + if (error instanceof Error) { + return res.status(409).json({ error: error.message }); + } + + // If error is not an instance of Error, return a generic error message + return res.status(409).json({ error: 'An unexpected error occurred' }); + } +} diff --git a/apps/web/pages/api/proofs/earlyAccess/index.ts b/apps/web/pages/api/proofs/earlyAccess/index.ts new file mode 100644 index 0000000..f4bbf30 --- /dev/null +++ b/apps/web/pages/api/proofs/earlyAccess/index.ts @@ -0,0 +1,81 @@ +import { USERNAME_EA_DISCOUNT_VALIDATORS } from 'apps/web/src/addresses/usernames'; +import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain'; +import { + getProofsByNamespaceAndAddress, + hasRegisteredWithDiscount, + ProofTableNamespace, +} from 'apps/web/src/utils/proofs'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { Address, isAddress } from 'viem'; + +export type EarlyAccessProofResponse = { + discountValidatorAddress: Address; + address: Address; + namespace: string; + proofs: `0x${string}`[]; +}; + +/* +this endpoint returns whether or not the account has a cb.id +if result array is empty, user has no cb.id +example return: +{ + "address": "0xB18e4C959bccc8EF86D78DC297fb5efA99550d85", + "namespace": "usernames", + "proofs": "[0x56ce3bbc909b90035ae373d32c56a9d81d26bb505dd935cdee6afc384bcaed8d, 0x99e940ed9482bf59ba5ceab7df0948798978a1acaee0ecb41f64fe7f40eedd17]" + "discountValidatorAddress": "0x..." +} +*/ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'method not allowed' }); + } + const { address, chain } = req.query; + if (!address || Array.isArray(address) || !isAddress(address)) { + return res.status(400).json({ error: 'A single valid address is required' }); + } + + if (!chain || Array.isArray(chain)) { + return res.status(400).json({ error: 'invalid chain' }); + } + + let parsedChain = parseInt(chain); + if (!isBasenameSupportedChain(parsedChain)) { + return res.status(400).json({ error: 'chain must be Base or Base Sepolia' }); + } + + if (!isBasenameSupportedChain(parsedChain)) { + return res.status(400).json({ error: 'chain must be Base or Base Sepolia' }); + } + + try { + const hasPreviouslyRegistered = await hasRegisteredWithDiscount([address], parsedChain); + + // if any linked address registered previously return an error + if (hasPreviouslyRegistered) { + return res.status(400).json({ error: 'This address has already claimed a username.' }); + } + const [content] = await getProofsByNamespaceAndAddress( + address, + ProofTableNamespace.UsernamesEarlyAccess, + ); + + const proofs = content?.proofs ? (JSON.parse(content.proofs) as `0x${string}`[]) : []; + if (proofs.length === 0) { + return res.status(404).json({ error: 'address is not eligible for early access' }); + } + + const responseData: EarlyAccessProofResponse = { + ...content, + proofs, + discountValidatorAddress: USERNAME_EA_DISCOUNT_VALIDATORS[parsedChain], + }; + + console.log({ responseData }); + return res.status(200).json(responseData); + } catch (error: unknown) { + console.error(error); + } + + return res.status(404).json({ error: 'address is not eligible for early access' }); +} diff --git a/apps/web/pages/ecosystem.tsx b/apps/web/pages/ecosystem.tsx index 62ba5fe..553bf01 100644 --- a/apps/web/pages/ecosystem.tsx +++ b/apps/web/pages/ecosystem.tsx @@ -1,7 +1,7 @@ import EcosystemHeroLogos from 'apps/web/public/images/ecosystem-hero-logos-new.png'; import { Divider } from 'apps/web/src/components/Divider/Divider'; import { List } from 'apps/web/src/components/Ecosystem/List'; -import { Button } from 'apps/web/src/components/Button/Button'; +import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button'; import Head from 'next/head'; import Image from 'next/image'; @@ -21,7 +21,7 @@ function EcosystemHero() { target="_blank" rel="noreferrer noopener" > - diff --git a/apps/web/pages/index.tsx b/apps/web/pages/index.tsx index cfdaa2f..db9c57a 100644 --- a/apps/web/pages/index.tsx +++ b/apps/web/pages/index.tsx @@ -9,7 +9,7 @@ import { Features } from '../src/components/Features/Features'; import { Hero } from '../src/components/Home/Hero'; import { JoinTheCommunity } from '../src/components/JoinTheCommunity/JoinTheCommunity'; import { Partnerships } from '../src/components/Partnerships/Partnerships'; -import { FrameButtonMetadata, FrameMetadata } from '@coinbase/onchainkit'; +import { FrameButtonMetadata, FrameMetadata } from '@coinbase/onchainkit/frame'; import { useMemo } from 'react'; export default function Home() { diff --git a/apps/web/pages/name/[username].tsx b/apps/web/pages/name/[username].tsx new file mode 100644 index 0000000..51b485b --- /dev/null +++ b/apps/web/pages/name/[username].tsx @@ -0,0 +1,53 @@ +import AnalyticsProvider from 'apps/web/contexts/Analytics'; +import UsernameProfile from 'apps/web/src/components/Basenames/UsernameProfile'; +import UsernameProfileProvider from 'apps/web/src/components/Basenames/UsernameProfileContext'; +import { Layout, NavigationType } from 'apps/web/src/components/Layout/Layout'; +import { + openGraphImageHeight, + openGraphImageType, + openGraphImageWidth, +} from 'apps/web/src/utils/opengraphs'; +import { BaseName } from 'apps/web/src/utils/usernames'; +import { NextPageContext } from 'next'; +import Head from 'next/head'; +import { useParams } from 'next/navigation'; +import { ReactElement } from 'react'; + +// Do not change this unless you know what you're doing (it'll break analytics) +const usernameProfileAnalyticContext = 'username_profile'; + +export function Username({ domain }: { domain: string }) { + const params = useParams<{ username: BaseName }>(); + const profileUsername = params?.username; + const ogImageUrl = `${domain}/api/basenames/${profileUsername}/assets/coverImage.png`; + + return ( + <> + + Basenames | {profileUsername} + + + + + + + + + + + + + + ); +} + +Username.getInitialProps = async ({ req }: NextPageContext) => { + const domain = req?.headers.host ?? ''; + return { domain }; +}; + +Username.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Username; diff --git a/apps/web/pages/name/index.tsx b/apps/web/pages/name/index.tsx new file mode 100644 index 0000000..e0156f2 --- /dev/null +++ b/apps/web/pages/name/index.tsx @@ -0,0 +1,34 @@ +import AnalyticsProvider from 'apps/web/contexts/Analytics'; +import RegistrationProvider from 'apps/web/src/components/Basenames/RegistrationContext'; +import RegistrationFlow from 'apps/web/src/components/Basenames/RegistrationFlow'; +import { Layout, NavigationType } from 'apps/web/src/components/Layout/Layout'; +import Head from 'next/head'; +import { ReactElement } from 'react'; + +// Do not change this unless you know what you're doing (it'll break analytics) +const usernameRegistrationAnalyticContext = 'username_registration'; + +export function Usernames() { + return ( + <> + + Basenames + + + + + + + + + ); +} + +Usernames.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Usernames; diff --git a/apps/web/public/icons/copy.svg b/apps/web/public/icons/copy.svg deleted file mode 100644 index e958a76..0000000 --- a/apps/web/public/icons/copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/apps/web/public/icons/default-avatar.svg b/apps/web/public/icons/default-avatar.svg deleted file mode 100644 index 9909ccf..0000000 --- a/apps/web/public/icons/default-avatar.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/web/public/images/avatars/aflock.eth.png b/apps/web/public/images/avatars/aflock.eth.png new file mode 100644 index 0000000..012166c Binary files /dev/null and b/apps/web/public/images/avatars/aflock.eth.png differ diff --git a/apps/web/public/images/avatars/dcj.eth.avif b/apps/web/public/images/avatars/dcj.eth.avif new file mode 100644 index 0000000..c9605b9 Binary files /dev/null and b/apps/web/public/images/avatars/dcj.eth.avif differ diff --git a/apps/web/public/images/avatars/ianlakes.eth.png b/apps/web/public/images/avatars/ianlakes.eth.png new file mode 100644 index 0000000..ddb57dd Binary files /dev/null and b/apps/web/public/images/avatars/ianlakes.eth.png differ diff --git a/apps/web/public/images/avatars/jfrankfurt.eth.jpeg b/apps/web/public/images/avatars/jfrankfurt.eth.jpeg new file mode 100644 index 0000000..a6b8234 Binary files /dev/null and b/apps/web/public/images/avatars/jfrankfurt.eth.jpeg differ diff --git a/apps/web/public/images/avatars/johnpalmer.eth.svg b/apps/web/public/images/avatars/johnpalmer.eth.svg new file mode 100644 index 0000000..7c05e27 --- /dev/null +++ b/apps/web/public/images/avatars/johnpalmer.eth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/images/avatars/wilsoncusack.eth.png b/apps/web/public/images/avatars/wilsoncusack.eth.png new file mode 100644 index 0000000..ace3246 Binary files /dev/null and b/apps/web/public/images/avatars/wilsoncusack.eth.png differ diff --git a/apps/web/public/images/avatars/zencephalon.eth.webp b/apps/web/public/images/avatars/zencephalon.eth.webp new file mode 100644 index 0000000..2035a82 Binary files /dev/null and b/apps/web/public/images/avatars/zencephalon.eth.webp differ diff --git a/apps/web/src/abis/AddrResolver.ts b/apps/web/src/abis/AddrResolver.ts new file mode 100644 index 0000000..d571cd8 --- /dev/null +++ b/apps/web/src/abis/AddrResolver.ts @@ -0,0 +1,200 @@ +export default [ + { + type: 'function', + name: 'addr', + inputs: [ + { + name: 'node', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: '', + type: 'address', + internalType: 'address payable', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'addr', + inputs: [ + { + name: 'node', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'coinType', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [ + { + name: '', + type: 'bytes', + internalType: 'bytes', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'clearRecords', + inputs: [ + { + name: 'node', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'recordVersions', + inputs: [ + { + name: '', + type: 'bytes32', + internalType: 'bytes32', + }, + ], + outputs: [ + { + name: '', + type: 'uint64', + internalType: 'uint64', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'setAddr', + inputs: [ + { + name: 'node', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'coinType', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'a', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setAddr', + inputs: [ + { + name: 'node', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'a', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [ + { + name: 'interfaceID', + type: 'bytes4', + internalType: 'bytes4', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'event', + name: 'AddrChanged', + inputs: [ + { + name: 'node', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'a', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'AddressChanged', + inputs: [ + { + name: 'node', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'coinType', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'newAddress', + type: 'bytes', + indexed: false, + internalType: 'bytes', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'VersionChanged', + inputs: [ + { + name: 'node', + type: 'bytes32', + indexed: true, + internalType: 'bytes32', + }, + { + name: 'newVersion', + type: 'uint64', + indexed: false, + internalType: 'uint64', + }, + ], + anonymous: false, + }, +] as const; diff --git a/apps/web/src/abis/AttestationValidator.ts b/apps/web/src/abis/AttestationValidator.ts new file mode 100644 index 0000000..eb2e3b1 --- /dev/null +++ b/apps/web/src/abis/AttestationValidator.ts @@ -0,0 +1,315 @@ +export default [ + { + type: 'constructor', + inputs: [ + { + name: 'owner_', + type: 'address', + internalType: 'address', + }, + { + name: 'signer_', + type: 'address', + internalType: 'address', + }, + { + name: 'schemaID_', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'indexer_', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'cancelOwnershipHandover', + inputs: [], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'completeOwnershipHandover', + inputs: [ + { + name: 'pendingOwner', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'indexer', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IAttestationIndexer', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isValidDiscountRegistration', + inputs: [ + { + name: 'claimer', + type: 'address', + internalType: 'address', + }, + { + name: 'validationData', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [ + { + name: 'result', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'ownershipHandoverExpiresAt', + inputs: [ + { + name: 'pendingOwner', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: 'result', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'renounceOwnership', + inputs: [], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'requestOwnershipHandover', + inputs: [], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'setSigner', + inputs: [ + { + name: 'signer_', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [ + { + name: 'newOwner', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'event', + name: 'IndexerUpdated', + inputs: [ + { + name: 'previousIndexer', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'updatedIndexer', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipHandoverCanceled', + inputs: [ + { + name: 'pendingOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipHandoverRequested', + inputs: [ + { + name: 'pendingOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { + name: 'oldOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'newOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'AlreadyInitialized', + inputs: [], + }, + { + type: 'error', + name: 'AttestationExpired', + inputs: [ + { + name: 'attestationUid', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'expirationTime', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'AttestationInvariantViolation', + inputs: [ + { + name: 'reason', + type: 'string', + internalType: 'string', + }, + ], + }, + { + type: 'error', + name: 'AttestationNotFound', + inputs: [], + }, + { + type: 'error', + name: 'AttestationRevoked', + inputs: [ + { + name: 'attestationUid', + type: 'bytes32', + internalType: 'bytes32', + }, + { + name: 'revocationTime', + type: 'uint256', + internalType: 'uint256', + }, + ], + }, + { + type: 'error', + name: 'ClaimerAddressMismatch', + inputs: [ + { + name: 'expectedClaimer', + type: 'address', + internalType: 'address', + }, + { + name: 'claimer', + type: 'address', + internalType: 'address', + }, + ], + }, + { + type: 'error', + name: 'InvalidIndexer', + inputs: [], + }, + { + type: 'error', + name: 'NewOwnerIsZeroAddress', + inputs: [], + }, + { + type: 'error', + name: 'NoHandoverRequest', + inputs: [], + }, + { + type: 'error', + name: 'SignatureExpired', + inputs: [], + }, + { + type: 'error', + name: 'Unauthorized', + inputs: [], + }, +] as const; diff --git a/apps/web/src/abis/CBIdDiscountValidator.ts b/apps/web/src/abis/CBIdDiscountValidator.ts new file mode 100644 index 0000000..afbb16e --- /dev/null +++ b/apps/web/src/abis/CBIdDiscountValidator.ts @@ -0,0 +1,108 @@ +export default [ + { + inputs: [ + { internalType: 'address', name: 'owner_', type: 'address' }, + { internalType: 'bytes32', name: 'root_', type: 'bytes32' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { inputs: [], name: 'AlreadyInitialized', type: 'error' }, + { inputs: [], name: 'NewOwnerIsZeroAddress', type: 'error' }, + { inputs: [], name: 'NoHandoverRequest', type: 'error' }, + { inputs: [], name: 'Unauthorized', type: 'error' }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverCanceled', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverRequested', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'oldOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + inputs: [], + name: 'cancelOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'completeOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'claimer', type: 'address' }, + { internalType: 'bytes', name: 'validationData', type: 'bytes' }, + ], + name: 'isValidDiscountRegistration', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: 'result', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'ownershipHandoverExpiresAt', + outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'requestOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'root', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'root_', type: 'bytes32' }], + name: 'setRoot', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const; diff --git a/apps/web/src/abis/ERC1155DiscountValidator.ts b/apps/web/src/abis/ERC1155DiscountValidator.ts new file mode 100644 index 0000000..0378256 --- /dev/null +++ b/apps/web/src/abis/ERC1155DiscountValidator.ts @@ -0,0 +1,42 @@ +export default [ + { + type: 'constructor', + inputs: [ + { + name: 'tokenAddress', + type: 'address', + internalType: 'address', + }, + { + name: 'tokenId_', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'isValidDiscountRegistration', + inputs: [ + { + name: 'claimer', + type: 'address', + internalType: 'address', + }, + { + name: '', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [ + { + name: '', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, +] as const; diff --git a/apps/web/src/abis/EarlyAccessValidator.ts b/apps/web/src/abis/EarlyAccessValidator.ts new file mode 100644 index 0000000..afbb16e --- /dev/null +++ b/apps/web/src/abis/EarlyAccessValidator.ts @@ -0,0 +1,108 @@ +export default [ + { + inputs: [ + { internalType: 'address', name: 'owner_', type: 'address' }, + { internalType: 'bytes32', name: 'root_', type: 'bytes32' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { inputs: [], name: 'AlreadyInitialized', type: 'error' }, + { inputs: [], name: 'NewOwnerIsZeroAddress', type: 'error' }, + { inputs: [], name: 'NoHandoverRequest', type: 'error' }, + { inputs: [], name: 'Unauthorized', type: 'error' }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverCanceled', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverRequested', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'oldOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + inputs: [], + name: 'cancelOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'completeOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'claimer', type: 'address' }, + { internalType: 'bytes', name: 'validationData', type: 'bytes' }, + ], + name: 'isValidDiscountRegistration', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: 'result', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'ownershipHandoverExpiresAt', + outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'requestOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'root', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'root_', type: 'bytes32' }], + name: 'setRoot', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const; diff --git a/apps/web/src/abis/L2Resolver.ts b/apps/web/src/abis/L2Resolver.ts new file mode 100644 index 0000000..7af78f3 --- /dev/null +++ b/apps/web/src/abis/L2Resolver.ts @@ -0,0 +1,574 @@ +export default [ + { + inputs: [ + { internalType: 'contract ENS', name: 'ens_', type: 'address' }, + { internalType: 'address', name: 'registrarController_', type: 'address' }, + { internalType: 'address', name: 'reverseRegistrar_', type: 'address' }, + { internalType: 'address', name: 'owner_', type: 'address' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { inputs: [], name: 'AlreadyInitialized', type: 'error' }, + { inputs: [], name: 'CantSetSelfAsDelegate', type: 'error' }, + { inputs: [], name: 'CantSetSelfAsOperator', type: 'error' }, + { inputs: [], name: 'NewOwnerIsZeroAddress', type: 'error' }, + { inputs: [], name: 'NoHandoverRequest', type: 'error' }, + { inputs: [], name: 'Unauthorized', type: 'error' }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: true, internalType: 'uint256', name: 'contentType', type: 'uint256' }, + ], + name: 'ABIChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'address', name: 'a', type: 'address' }, + ], + name: 'AddrChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'uint256', name: 'coinType', type: 'uint256' }, + { indexed: false, internalType: 'bytes', name: 'newAddress', type: 'bytes' }, + ], + name: 'AddressChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'owner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'operator', type: 'address' }, + { indexed: false, internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'ApprovalForAll', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'address', name: 'owner', type: 'address' }, + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: true, internalType: 'address', name: 'delegate', type: 'address' }, + { indexed: true, internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'Approved', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'bytes', name: 'hash', type: 'bytes' }, + ], + name: 'ContenthashChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'bytes', name: 'name', type: 'bytes' }, + { indexed: false, internalType: 'uint16', name: 'resource', type: 'uint16' }, + { indexed: false, internalType: 'bytes', name: 'record', type: 'bytes' }, + ], + name: 'DNSRecordChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'bytes', name: 'name', type: 'bytes' }, + { indexed: false, internalType: 'uint16', name: 'resource', type: 'uint16' }, + ], + name: 'DNSRecordDeleted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'bytes', name: 'lastzonehash', type: 'bytes' }, + { indexed: false, internalType: 'bytes', name: 'zonehash', type: 'bytes' }, + ], + name: 'DNSZonehashChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: true, internalType: 'bytes4', name: 'interfaceID', type: 'bytes4' }, + { indexed: false, internalType: 'address', name: 'implementer', type: 'address' }, + ], + name: 'InterfaceChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'string', name: 'name', type: 'string' }, + ], + name: 'NameChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverCanceled', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverRequested', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'oldOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'bytes32', name: 'x', type: 'bytes32' }, + { indexed: false, internalType: 'bytes32', name: 'y', type: 'bytes32' }, + ], + name: 'PubkeyChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'newRegistrarController', type: 'address' }, + ], + name: 'RegistrarControllerUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'newReverseRegistrar', type: 'address' }, + ], + name: 'ReverseRegistrarUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: true, internalType: 'string', name: 'indexedKey', type: 'string' }, + { indexed: false, internalType: 'string', name: 'key', type: 'string' }, + { indexed: false, internalType: 'string', name: 'value', type: 'string' }, + ], + name: 'TextChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { indexed: false, internalType: 'uint64', name: 'newVersion', type: 'uint64' }, + ], + name: 'VersionChanged', + type: 'event', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'uint256', name: 'contentTypes', type: 'uint256' }, + ], + name: 'ABI', + outputs: [ + { internalType: 'uint256', name: '', type: 'uint256' }, + { internalType: 'bytes', name: '', type: 'bytes' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'addr', + outputs: [{ internalType: 'address payable', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'uint256', name: 'coinType', type: 'uint256' }, + ], + name: 'addr', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'delegate', type: 'address' }, + { internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'cancelOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'clearRecords', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'completeOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'contenthash', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes32', name: 'name', type: 'bytes32' }, + { internalType: 'uint16', name: 'resource', type: 'uint16' }, + ], + name: 'dnsRecord', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'ens', + outputs: [{ internalType: 'contract ENS', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes32', name: 'name', type: 'bytes32' }, + ], + name: 'hasDNSRecords', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes4', name: 'interfaceID', type: 'bytes4' }, + ], + name: 'interfaceImplementer', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'delegate', type: 'address' }, + ], + name: 'isApprovedFor', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'account', type: 'address' }, + { internalType: 'address', name: 'operator', type: 'address' }, + ], + name: 'isApprovedForAll', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes[]', name: 'data', type: 'bytes[]' }], + name: 'multicall', + outputs: [{ internalType: 'bytes[]', name: 'results', type: 'bytes[]' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'nodehash', type: 'bytes32' }, + { internalType: 'bytes[]', name: 'data', type: 'bytes[]' }, + ], + name: 'multicallWithNodeCheck', + outputs: [{ internalType: 'bytes[]', name: 'results', type: 'bytes[]' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: 'result', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'ownershipHandoverExpiresAt', + outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'pubkey', + outputs: [ + { internalType: 'bytes32', name: 'x', type: 'bytes32' }, + { internalType: 'bytes32', name: 'y', type: 'bytes32' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + name: 'recordVersions', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'registrarController', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'requestOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes', name: '', type: 'bytes' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + name: 'resolve', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'reverseRegistrar', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'uint256', name: 'contentType', type: 'uint256' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + name: 'setABI', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'uint256', name: 'coinType', type: 'uint256' }, + { internalType: 'bytes', name: 'a', type: 'bytes' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'address', name: 'a', type: 'address' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'operator', type: 'address' }, + { internalType: 'bool', name: 'approved', type: 'bool' }, + ], + name: 'setApprovalForAll', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes', name: 'hash', type: 'bytes' }, + ], + name: 'setContenthash', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + name: 'setDNSRecords', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes4', name: 'interfaceID', type: 'bytes4' }, + { internalType: 'address', name: 'implementer', type: 'address' }, + ], + name: 'setInterface', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'string', name: 'newName', type: 'string' }, + ], + name: 'setName', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes32', name: 'x', type: 'bytes32' }, + { internalType: 'bytes32', name: 'y', type: 'bytes32' }, + ], + name: 'setPubkey', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'registrarController_', type: 'address' }], + name: 'setRegistrarController', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'reverseRegistrar_', type: 'address' }], + name: 'setReverseRegistrar', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'string', name: 'key', type: 'string' }, + { internalType: 'string', name: 'value', type: 'string' }, + ], + name: 'setText', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'bytes', name: 'hash', type: 'bytes' }, + ], + name: 'setZonehash', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes4', name: 'interfaceID', type: 'bytes4' }], + name: 'supportsInterface', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes32', name: 'node', type: 'bytes32' }, + { internalType: 'string', name: 'key', type: 'string' }, + ], + name: 'text', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'node', type: 'bytes32' }], + name: 'zonehash', + outputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/apps/web/src/abis/RegistrarControllerABI.ts b/apps/web/src/abis/RegistrarControllerABI.ts new file mode 100644 index 0000000..f88d74e --- /dev/null +++ b/apps/web/src/abis/RegistrarControllerABI.ts @@ -0,0 +1,445 @@ +export default [ + { + inputs: [ + { internalType: 'contract BaseRegistrar', name: 'base_', type: 'address' }, + { internalType: 'contract IPriceOracle', name: 'prices_', type: 'address' }, + { internalType: 'contract IReverseRegistrar', name: 'reverseRegistrar_', type: 'address' }, + { internalType: 'address', name: 'owner_', type: 'address' }, + { internalType: 'bytes32', name: 'rootNode_', type: 'bytes32' }, + { internalType: 'string', name: 'rootName_', type: 'string' }, + { internalType: 'address', name: 'paymentReceiver_', type: 'address' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [{ internalType: 'address', name: 'target', type: 'address' }], + name: 'AddressEmptyCode', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'AddressInsufficientBalance', + type: 'error', + }, + { inputs: [], name: 'AlreadyInitialized', type: 'error' }, + { + inputs: [{ internalType: 'address', name: 'sender', type: 'address' }], + name: 'AlreadyRegisteredWithDiscount', + type: 'error', + }, + { + inputs: [{ internalType: 'uint256', name: 'duration', type: 'uint256' }], + name: 'DurationTooShort', + type: 'error', + }, + { inputs: [], name: 'FailedInnerCall', type: 'error' }, + { + inputs: [{ internalType: 'bytes32', name: 'key', type: 'bytes32' }], + name: 'InactiveDiscount', + type: 'error', + }, + { inputs: [], name: 'InsufficientValue', type: 'error' }, + { + inputs: [ + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + name: 'InvalidDiscount', + type: 'error', + }, + { + inputs: [{ internalType: 'bytes32', name: 'key', type: 'bytes32' }], + name: 'InvalidDiscountAmount', + type: 'error', + }, + { inputs: [], name: 'InvalidPaymentReceiver', type: 'error' }, + { + inputs: [ + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'address', name: 'validator', type: 'address' }, + ], + name: 'InvalidValidator', + type: 'error', + }, + { + inputs: [{ internalType: 'string', name: 'name', type: 'string' }], + name: 'NameNotAvailable', + type: 'error', + }, + { inputs: [], name: 'NewOwnerIsZeroAddress', type: 'error' }, + { inputs: [], name: 'NoHandoverRequest', type: 'error' }, + { inputs: [], name: 'ResolverRequiredWhenDataSupplied', type: 'error' }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'SafeERC20FailedOperation', + type: 'error', + }, + { inputs: [], name: 'TransferFailed', type: 'error' }, + { inputs: [], name: 'Unauthorized', type: 'error' }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'registrant', type: 'address' }, + { indexed: true, internalType: 'bytes32', name: 'discountKey', type: 'bytes32' }, + ], + name: 'DiscountApplied', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'bytes32', name: 'discountKey', type: 'bytes32' }, + { + components: [ + { internalType: 'bool', name: 'active', type: 'bool' }, + { internalType: 'address', name: 'discountValidator', type: 'address' }, + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'uint256', name: 'discount', type: 'uint256' }, + ], + indexed: false, + internalType: 'struct EARegistrarController.DiscountDetails', + name: 'details', + type: 'tuple', + }, + ], + name: 'DiscountUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'payee', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'price', type: 'uint256' }, + ], + name: 'ETHPaymentProcessed', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'string', name: 'name', type: 'string' }, + { indexed: true, internalType: 'bytes32', name: 'label', type: 'bytes32' }, + { indexed: true, internalType: 'address', name: 'owner', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'expires', type: 'uint256' }, + ], + name: 'NameRegistered', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverCanceled', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'OwnershipHandoverRequested', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'oldOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'address', name: 'newPaymentReceiver', type: 'address' }, + ], + name: 'PaymentReceiverUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: 'address', name: 'newPrices', type: 'address' }], + name: 'PriceOracleUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'address', name: 'newReverseRegistrar', type: 'address' }, + ], + name: 'ReverseRegistrarUpdated', + type: 'event', + }, + { + inputs: [], + name: 'MIN_NAME_LENGTH', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'MIN_REGISTRATION_DURATION', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'string', name: 'name', type: 'string' }], + name: 'available', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'cancelOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'completeOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'uint256', name: 'duration', type: 'uint256' }, + { internalType: 'address', name: 'resolver', type: 'address' }, + { internalType: 'bytes[]', name: 'data', type: 'bytes[]' }, + { internalType: 'bool', name: 'reverseRecord', type: 'bool' }, + ], + internalType: 'struct EARegistrarController.RegisterRequest', + name: 'request', + type: 'tuple', + }, + { internalType: 'bytes32', name: 'discountKey', type: 'bytes32' }, + { internalType: 'bytes', name: 'validationData', type: 'bytes' }, + ], + name: 'discountedRegister', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'uint256', name: 'duration', type: 'uint256' }, + { internalType: 'bytes32', name: 'discountKey', type: 'bytes32' }, + ], + name: 'discountedRegisterPrice', + outputs: [{ internalType: 'uint256', name: 'price', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'registrant', type: 'address' }], + name: 'discountedRegistrants', + outputs: [{ internalType: 'bool', name: 'hasRegisteredWithDiscount', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: 'key', type: 'bytes32' }], + name: 'discounts', + outputs: [ + { internalType: 'bool', name: 'active', type: 'bool' }, + { internalType: 'address', name: 'discountValidator', type: 'address' }, + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'uint256', name: 'discount', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getActiveDiscounts', + outputs: [ + { + components: [ + { internalType: 'bool', name: 'active', type: 'bool' }, + { internalType: 'address', name: 'discountValidator', type: 'address' }, + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'uint256', name: 'discount', type: 'uint256' }, + ], + internalType: 'struct EARegistrarController.DiscountDetails[]', + name: '', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address[]', name: 'addresses', type: 'address[]' }], + name: 'hasRegisteredWithDiscount', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: 'result', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'pendingOwner', type: 'address' }], + name: 'ownershipHandoverExpiresAt', + outputs: [{ internalType: 'uint256', name: 'result', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'paymentReceiver', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'prices', + outputs: [{ internalType: 'contract IPriceOracle', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_token', type: 'address' }, + { internalType: 'address', name: '_to', type: 'address' }, + { internalType: 'uint256', name: '_amount', type: 'uint256' }, + ], + name: 'recoverFunds', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'uint256', name: 'duration', type: 'uint256' }, + ], + name: 'registerPrice', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'string', name: 'name', type: 'string' }, + { internalType: 'uint256', name: 'duration', type: 'uint256' }, + ], + name: 'rentPrice', + outputs: [ + { + components: [ + { internalType: 'uint256', name: 'base', type: 'uint256' }, + { internalType: 'uint256', name: 'premium', type: 'uint256' }, + ], + internalType: 'struct IPriceOracle.Price', + name: 'price', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'requestOwnershipHandover', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [], + name: 'reverseRegistrar', + outputs: [{ internalType: 'contract IReverseRegistrar', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'rootName', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'rootNode', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'bool', name: 'active', type: 'bool' }, + { internalType: 'address', name: 'discountValidator', type: 'address' }, + { internalType: 'bytes32', name: 'key', type: 'bytes32' }, + { internalType: 'uint256', name: 'discount', type: 'uint256' }, + ], + internalType: 'struct EARegistrarController.DiscountDetails', + name: 'details', + type: 'tuple', + }, + ], + name: 'setDiscountDetails', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'paymentReceiver_', type: 'address' }], + name: 'setPaymentReceiver', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'contract IPriceOracle', name: 'prices_', type: 'address' }], + name: 'setPriceOracle', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'contract IReverseRegistrar', name: 'reverse_', type: 'address' }], + name: 'setReverseRegistrar', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'string', name: 'name', type: 'string' }], + name: 'valid', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'pure', + type: 'function', + }, + { inputs: [], name: 'withdrawETH', outputs: [], stateMutability: 'nonpayable', type: 'function' }, +] as const; diff --git a/apps/web/src/abis/UniswapV2Pair.ts b/apps/web/src/abis/UniswapV2Pair.ts new file mode 100644 index 0000000..f46efd1 --- /dev/null +++ b/apps/web/src/abis/UniswapV2Pair.ts @@ -0,0 +1,713 @@ +export default [ + { + inputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount0', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount1', + type: 'uint256', + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'Burn', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount0', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount1', + type: 'uint256', + }, + ], + name: 'Mint', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount0In', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount1In', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount0Out', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount1Out', + type: 'uint256', + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'Swap', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'uint112', + name: 'reserve0', + type: 'uint112', + }, + { + indexed: false, + internalType: 'uint112', + name: 'reserve1', + type: 'uint112', + }, + ], + name: 'Sync', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'from', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, + { + constant: true, + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'MINIMUM_LIQUIDITY', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'PERMIT_TYPEHASH', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'allowance', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'burn', + outputs: [ + { + internalType: 'uint256', + name: 'amount0', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'amount1', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'decimals', + outputs: [ + { + internalType: 'uint8', + name: '', + type: 'uint8', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'factory', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'getReserves', + outputs: [ + { + internalType: 'uint112', + name: '_reserve0', + type: 'uint112', + }, + { + internalType: 'uint112', + name: '_reserve1', + type: 'uint112', + }, + { + internalType: 'uint32', + name: '_blockTimestampLast', + type: 'uint32', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: '_token0', + type: 'address', + }, + { + internalType: 'address', + name: '_token1', + type: 'address', + }, + ], + name: 'initialize', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'kLast', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'mint', + outputs: [ + { + internalType: 'uint256', + name: 'liquidity', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'name', + outputs: [ + { + internalType: 'string', + name: '', + type: 'string', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'nonces', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { + internalType: 'uint8', + name: 'v', + type: 'uint8', + }, + { + internalType: 'bytes32', + name: 'r', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: 's', + type: 'bytes32', + }, + ], + name: 'permit', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'price0CumulativeLast', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'price1CumulativeLast', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'skim', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'uint256', + name: 'amount0Out', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'amount1Out', + type: 'uint256', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'swap', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'symbol', + outputs: [ + { + internalType: 'string', + name: '', + type: 'string', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [], + name: 'sync', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'token0', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'token1', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'totalSupply', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'transfer', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, + { + constant: false, + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'transferFrom', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/apps/web/src/addresses/usernames.ts b/apps/web/src/addresses/usernames.ts new file mode 100644 index 0000000..999eb49 --- /dev/null +++ b/apps/web/src/addresses/usernames.ts @@ -0,0 +1,43 @@ +import { Address } from 'viem'; +import { base, baseSepolia } from 'viem/chains'; + +type AddressMap = Record; + +export const USERNAME_L2_RESOLVER_ADDRESSES: AddressMap = { + [baseSepolia.id]: '0x6533C94869D28fAA8dF77cc63f9e2b2D6Cf77eBA', + [base.id]: '0x', +}; + +export const USERNAME_REGISTRAR_CONTROLLER_ADDRESSES: AddressMap = { + [baseSepolia.id]: '0x3a0e8c2a0a28f396a5e5b69edb2e630311f1517a', + [base.id]: '0x', +}; + +export const USERNAME_CB_ID_DISCOUNT_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0x1079eF978d3c2A6CD4db142118D3C904E0Ac4Fc7', + [base.id]: '0x', +}; + +export const USERNAME_CB1_DISCOUNT_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0x502df754f25f492cad45ed85a4de0ee7540717e7', + [base.id]: '0x', +}; + +export const USERNAME_CB_DISCOUNT_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0x87B6Bb5d4F43f7bfF78fcFAE7227B2d918828a92', + [base.id]: '0x', +}; + +export const USERNAME_EA_DISCOUNT_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0x4944a8Ea7ec6fA356B159a2c363d83076B8f276D', + [base.id]: '0x', +}; + +export const USERNAME_1155_DISCOUNT_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0xE41Cd25f429E10744938d5048646E721ac630aF3', + [base.id]: '0x', +}; + +export const UNISWAP_USDC_WETH_POOL: AddressMap = { + [base.id]: '0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C', +}; diff --git a/apps/web/src/cdp/api/cb-gpt.ts b/apps/web/src/cdp/api/cb-gpt.ts new file mode 100644 index 0000000..ba97335 --- /dev/null +++ b/apps/web/src/cdp/api/cb-gpt.ts @@ -0,0 +1,56 @@ +import { cdpPost } from 'apps/web/src/cdp/utils'; + +type ErrorResponse = { + code: number; +}; + +type QueryCbGptResponse = { + response: string; + chat_session_id: string; + num_tokens_used: number; + output_tokens_used: number; +}; + +type CbGptQuery = { + taskConfig: CbGptTaskConfig; + query: string; +}; + +type CbGptTaskConfig = { + actionLlm: { chatLlm: string }; + action_prompt_template: { init_llm_chain: string }; +}; + +export async function queryCbGpt(query: CbGptQuery): Promise { + try { + const response = await cdpPost(`cb-gpt-api/v1/query`, query, true); + + if (response.ok) { + const res = (await response.json()) as QueryCbGptResponse; + return res; + } + const contentType = response.headers.get('content-type'); + let errorResponse: ErrorResponse | string; + + if (contentType?.includes('application/json')) { + errorResponse = (await response.json()) as ErrorResponse; + } else { + errorResponse = await response.text(); + } + + if (response.status === 401 || response.status === 403) { + throw new Error('Unauthorized access: ensure the calling IP is permitted.'); + } + + if (response.status === 500 && typeof errorResponse !== 'string' && errorResponse.code === 13) { + throw new Error('No user found'); + } + + throw new Error( + `Unexpected error: ${response.statusText}, Response: ${JSON.stringify(errorResponse)}`, + ); + } catch (error) { + console.error('Error querying cb-gpt:', error); + throw error; + } +} diff --git a/apps/web/src/cdp/api/get_linked_addresses.ts b/apps/web/src/cdp/api/get_linked_addresses.ts new file mode 100644 index 0000000..f730dda --- /dev/null +++ b/apps/web/src/cdp/api/get_linked_addresses.ts @@ -0,0 +1,48 @@ +import { cdpGet } from 'apps/web/src/cdp/utils'; +import { Address, isAddress } from 'viem'; + +export type LinkedAddresses = { + idemKey: string; + linkedAddresses: Address[]; +}; + +type ErrorResponse = { + code: number; + data: LinkedAddresses; +}; + +export async function getLinkedAddresses(address: string): Promise { + if (!isAddress(address)) { + throw new Error('A valid address is required'); + } + + try { + const response = await cdpGet(`verifications/v1/recipients/${address}/linked-addresses`, true); + + if (response.ok) { + return (await response.json()) as LinkedAddresses; + } + + const contentType = response.headers.get('content-type'); + let errorResponse: ErrorResponse | string; + + if (contentType?.includes('application/json')) { + errorResponse = (await response.json()) as ErrorResponse; + } else { + errorResponse = await response.text(); + } + + if (response.status === 401) { + throw new Error('Unauthorized access: ensure the calling IP is permitted.'); + } + + if (response.status === 500 && typeof errorResponse !== 'string' && errorResponse.code === 13) { + throw new Error('No user found'); + } + + throw new Error(`Unexpected error: ${response.statusText}, Response: ${errorResponse}`); + } catch (error) { + console.error('Error fetching linked addresses:', error); + throw error; + } +} diff --git a/apps/web/src/cdp/api/index.ts b/apps/web/src/cdp/api/index.ts new file mode 100644 index 0000000..96641cf --- /dev/null +++ b/apps/web/src/cdp/api/index.ts @@ -0,0 +1 @@ +export * from './get_linked_addresses'; diff --git a/apps/web/src/cdp/constants.ts b/apps/web/src/cdp/constants.ts new file mode 100644 index 0000000..bd6dc92 --- /dev/null +++ b/apps/web/src/cdp/constants.ts @@ -0,0 +1,6 @@ +export const cdpKeySecret = process.env.CDP_KEY_SECRET ?? ''; +export const cdpKeyName = process.env.CDP_KEY_NAME ?? ''; +export const cdpBaseRpcEndpoint = process.env.CDP_BASE_RPC_ENDPOINT ?? 'https://mainnet.base.org'; +export const cdpBaseSepoliaRpcEndpoint = + process.env.CDP_BASE_SEPOLIA_RPC_ENDPOINT ?? 'https://sepolia.base.org'; +export const cdpBaseUri = process.env.CDP_BASE_URI ?? 'api.coinbase.com'; diff --git a/apps/web/src/cdp/jwt.ts b/apps/web/src/cdp/jwt.ts new file mode 100644 index 0000000..bf22a5d --- /dev/null +++ b/apps/web/src/cdp/jwt.ts @@ -0,0 +1,36 @@ +import { SignJWT } from 'jose'; +import crypto from 'crypto'; +import { cdpBaseUri, cdpKeyName, cdpKeySecret } from 'apps/web/src/cdp/constants'; + +const algorithm = 'ES256'; + +type APIKeyClaims = { + iss: string; + sub: string; + nbf: number; + exp: number; + uri: string; + aud: string[]; +}; + +export async function generateCdpJwt(requestMethod: string, requestPath: string): Promise { + const uri = `${requestMethod} ${cdpBaseUri}/${requestPath}`; + const nonce = crypto.randomBytes(16).toString('hex'); + const claims: APIKeyClaims = { + iss: 'cdp', + sub: cdpKeyName, + nbf: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60, + uri: uri, + aud: ['cb-gpt-api'], + }; + const key = crypto.createPrivateKey(cdpKeySecret.replace(/\\n/g, '\n')); + + const jwt = await new SignJWT(claims) + .setProtectedHeader({ alg: algorithm, kid: cdpKeyName, nonce }) + .setIssuedAt() + .setExpirationTime('60s') + .sign(key); + + return jwt; +} diff --git a/apps/web/src/cdp/utils.ts b/apps/web/src/cdp/utils.ts new file mode 100644 index 0000000..bc6c5f1 --- /dev/null +++ b/apps/web/src/cdp/utils.ts @@ -0,0 +1,31 @@ +import { cdpBaseUri } from 'apps/web/src/cdp/constants'; +import { generateCdpJwt } from 'apps/web/src/cdp/jwt'; +import { Response } from 'node-fetch'; + +export async function cdpGet(endpoint: string, authed: boolean): Promise { + const headers = new Headers(); + + const uri = `https://${cdpBaseUri}/${endpoint}`; + if (authed) { + const jwt = await generateCdpJwt('GET', endpoint); + headers.set('Authorization', `Bearer ${jwt}`); + } + return fetch(uri, { + method: 'GET', + headers, + }); +} + +export async function cdpPost(endpoint: string, body: unknown, authed: boolean): Promise { + const uri = `https://${cdpBaseUri}/${endpoint}`; + const headers = new Headers(); + if (authed) { + const jwt = await generateCdpJwt('POST', endpoint); + headers.set('Authorization', `Bearer ${jwt}`); + } + return fetch(uri, { + method: 'POST', + body: JSON.stringify(body), + headers, + }); +} diff --git a/apps/web/src/components/Basenames/FloatingENSPills.tsx b/apps/web/src/components/Basenames/FloatingENSPills.tsx new file mode 100644 index 0000000..b622a61 --- /dev/null +++ b/apps/web/src/components/Basenames/FloatingENSPills.tsx @@ -0,0 +1,200 @@ +import { + registrationTransitionDuration, + useRegistration, +} from 'apps/web/src/components/Basenames/RegistrationContext'; +import classNames from 'classnames'; +import Image from 'next/image'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; + +const useMousePosition = () => { + const [position, setPosition] = useState({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + + useEffect(() => { + let animationFrameId: number; + + const handleMouseMove = (event: MouseEvent) => { + const updatePosition = () => { + setPosition({ x: event.clientX, y: event.clientY }); + animationFrameId = requestAnimationFrame(updatePosition); + }; + cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(updatePosition); + }; + + window.addEventListener('mousemove', handleMouseMove); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + cancelAnimationFrame(animationFrameId); + }; + }, []); + + return position; +}; + +const NAMES = [ + { name: 'ianlakes', avatar: '/images/avatars/ianlakes.eth.png' }, + { name: 'wilsoncusack', avatar: '/images/avatars/wilsoncusack.eth.png' }, + { name: 'aflock', avatar: '/images/avatars/aflock.eth.png' }, + { name: 'johnpalmer', avatar: '/images/avatars/johnpalmer.eth.svg' }, + { name: 'jfrankfurt', avatar: '/images/avatars/jfrankfurt.eth.jpeg' }, + { name: 'lsr', avatar: '/images/avatars/lsr.eth.png' }, + { name: 'dcj', avatar: '/images/avatars/dcj.eth.avif' }, + { name: 'zencephalon', avatar: '/images/avatars/zencephalon.eth.webp' }, +]; +const PILL_COUNT = NAMES.length; +const initialBlurStates = Array.from({ length: PILL_COUNT }).map((_, index) => index % 2 === 0); +const intervals = [2000, 4000, 6000]; +const useBlurCycle = () => { + const [blurredIndices, setBlurredIndices] = useState(initialBlurStates); + + useEffect(() => { + const timeoutIds: ReturnType[] = []; + + const toggleBlur = (index: number) => { + setBlurredIndices((prev) => prev.map((blurred, i) => (i === index ? !blurred : blurred))); + + const randomInterval = intervals[Math.floor(Math.random() * intervals.length)]; + timeoutIds[index] = setTimeout(() => toggleBlur(index), randomInterval); + }; + + for (let i = 0; i < PILL_COUNT; i++) { + const randomInterval = intervals[Math.floor(Math.random() * intervals.length)]; + timeoutIds[i] = setTimeout(() => toggleBlur(i), randomInterval); + } + + return () => { + timeoutIds.forEach(clearTimeout); + }; + }, []); + + return blurredIndices; +}; + +type PillProps = { + name: string; + avatar: string; + x: number; + y: number; + isBlurred: boolean; + transform: string; +}; +const Pill = forwardRef( + ({ avatar, name, x, y, isBlurred, transform }: PillProps, ref: React.Ref) => { + const { searchInputFocused, searchInputHovered } = useRegistration(); + + const pillClasses = classNames( + 'absolute flex items-center justify-center rounded-full border opacity-60', + 'transition-all duration-500', + 'border-[#d9dce2] text-[#666]', + 'px-2 py-2 gap-2 text-sm', + 'sm:px-3 sm:py-2 sm:gap-2 sm:text-sm', + 'md:px-4 md:py-3 md:gap-3 md:text-base', + { + 'blur-sm': isBlurred, + 'bg-blue-600/10 border-blue-600/20 text-blue-600': + !searchInputFocused && searchInputHovered, + 'bg-white/20 border-white/80 text-white': searchInputFocused, + }, + ); + + return ( +
+ {`${name}-avatar`} +

{name}.base.eth

+
+ ); + }, +); + +Pill.displayName = 'Pill'; + +const X_VECTOR_SCALER = 0.4; +const Y_VECTOR_SCALER = 0.32; +export function FloatingENSPills() { + const [radiusX, setRadiusX] = useState(window.innerWidth * X_VECTOR_SCALER); + const [radiusY, setRadiusY] = useState(window.innerHeight * Y_VECTOR_SCALER); + const pillRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + const handleResize = () => { + setRadiusX(window.innerWidth * X_VECTOR_SCALER); + setRadiusY(window.innerHeight * Y_VECTOR_SCALER); + }; + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + const angleStep = (2 * Math.PI) / PILL_COUNT; + const mousePosition = useMousePosition(); + + const pills = Array.from({ length: PILL_COUNT }).map((_, index) => { + const pill = pillRefs.current[index]; + const pillWidth = pill ? pill.offsetWidth : 170; + const pillHeight = pill ? pill.offsetHeight : 170; + + const angle = index * angleStep; + const x = centerX + radiusX * Math.cos(angle) - pillWidth / 2; + const y = centerY + radiusY * Math.sin(angle) - pillHeight / 2; + + const dx = x + pillWidth / 2 - mousePosition.x; + const dy = y + pillHeight / 2 - mousePosition.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Normalize the direction vector + const length = Math.sqrt(dx * dx + dy * dy); + const nx = dx / length; + const ny = dy / length; + + const rotationAngle = Math.min(distance / 20, 20); + const transform = `rotate3d(${-ny}, ${-nx}, 0, ${rotationAngle}deg)`; + const { name, avatar } = NAMES[index]; + return { name, avatar, x, y, transform }; + }); + + const blurredIndices = useBlurCycle(); + const setRef = useCallback((index: number, el: HTMLDivElement | null) => { + if (!el) return; + pillRefs.current[index] = el; + }, []); + + const { searchInputFocused } = useRegistration(); + + return ( +
+ {pills.map(({ avatar, name, x, y, transform }, i) => ( + setRef(i, el)} + /> + ))} +
+ ); +} diff --git a/apps/web/src/components/Basenames/RegistrationBackground/index.tsx b/apps/web/src/components/Basenames/RegistrationBackground/index.tsx new file mode 100644 index 0000000..913bf32 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationBackground/index.tsx @@ -0,0 +1,70 @@ +import { Transition } from '@headlessui/react'; +import { FloatingENSPills } from 'apps/web/src/components/Basenames/FloatingENSPills'; +import { + RegistrationSteps, + registrationTransitionDuration, + useRegistration, +} from 'apps/web/src/components/Basenames/RegistrationContext'; +import tempPendingAnimation from 'apps/web/src/components/Basenames/tempPendingAnimation.png'; +import classNames from 'classnames'; + +export default function RegistrationBackground() { + const { registrationStep } = useRegistration(); + + const isSearch = registrationStep === RegistrationSteps.Search; + const isClaim = registrationStep === RegistrationSteps.Claim; + const isPending = registrationStep === RegistrationSteps.Pending; + const isSuccess = registrationStep === RegistrationSteps.Success; + + const claimBackgroundClasses = classNames( + 'pointer-events-none absolute inset-0 w-full h-full bg-cover bg-center -z-10', + ); + + const successBackgroundClasses = classNames( + 'pointer-events-none absolute inset-0 w-full h-full bg-cover bg-center -z-10 bg-blue-600', + ); + + return ( + <> + + + + + {/* TODO: Lottie animation file */} +
+ + + + {/* TODO: Lottie animation file */} +
+ + + ); +} diff --git a/apps/web/src/components/Basenames/RegistrationBrand/index.tsx b/apps/web/src/components/Basenames/RegistrationBrand/index.tsx new file mode 100644 index 0000000..b3a8de0 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationBrand/index.tsx @@ -0,0 +1,50 @@ +import { Transition } from '@headlessui/react'; +import { useRegistration } from 'apps/web/src/components/Basenames/RegistrationContext'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import classNames from 'classnames'; +import { useState } from 'react'; +import { useInterval } from 'usehooks-ts'; + +const SEARCH_LABEL_COPY_STRINGS = ['Build your Based profile', 'Connect with Based builders']; + +const useRotatingText = (strings: string[]) => { + const [currentIndex, setCurrentIndex] = useState(0); + useInterval(() => { + setCurrentIndex((prevIndex) => (prevIndex + 1) % strings.length); + }, 3000); + return strings[currentIndex]; +}; + +export default function RegistrationBrand() { + const rotatingText = useRotatingText(SEARCH_LABEL_COPY_STRINGS); + const { searchInputFocused } = useRegistration(); + return ( +
+
+ + + +

Basenames

+
+ {SEARCH_LABEL_COPY_STRINGS.map((string) => ( + +

{string}

+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx new file mode 100644 index 0000000..d3b4c88 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -0,0 +1,228 @@ +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { + DiscountData, + findFirstValidDiscount, + useAggregatedDiscountValidators, +} from 'apps/web/src/hooks/useAggregatedDiscountValidators'; +import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import { Discount, isValidDiscount } from 'apps/web/src/utils/usernames'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { Address, TransactionReceipt } from 'viem'; +import { useAccount, useWaitForTransactionReceipt } from 'wagmi'; + +export enum RegistrationSteps { + Search = 'search', + Claim = 'claim', + Pending = 'pending', + Success = 'success', + Profile = 'profile', +} + +export type RegistrationContextProps = { + searchInputFocused: boolean; + setSearchInputFocused: Dispatch>; + searchInputHovered: boolean; + setSearchInputHovered: Dispatch>; + registrationStep: RegistrationSteps; + setRegistrationStep: Dispatch>; + selectedName: string; + setSelectedName: Dispatch>; + registerNameTransactionHash: `0x${string}` | undefined; + setRegisterNameTransactionHash: Dispatch>; + loadingDiscounts: boolean; + discount: DiscountData | undefined; + allActiveDiscounts: Set; + transactionData: TransactionReceipt | undefined; + transactionError: unknown | null; +}; + +export const RegistrationContext = createContext({ + searchInputFocused: false, + searchInputHovered: false, + registrationStep: RegistrationSteps.Search, + selectedName: '', + setSearchInputFocused: function () { + return undefined; + }, + setSearchInputHovered: function () { + return undefined; + }, + setRegistrationStep: function () { + return undefined; + }, + setSelectedName: function () { + return undefined; + }, + registerNameTransactionHash: '0x', + setRegisterNameTransactionHash: function () { + return undefined; + }, + loadingDiscounts: true, + discount: undefined, + allActiveDiscounts: new Set(), + transactionData: undefined, + transactionError: null, +}); + +type RegistrationProviderProps = { + children?: ReactNode; +}; + +// Maybe not the best place for this +export const registrationTransitionDuration = 'duration-700'; + +export default function RegistrationProvider({ children }: RegistrationProviderProps) { + // UI state + const [searchInputFocused, setSearchInputFocused] = useState(false); + const [searchInputHovered, setSearchInputHovered] = useState(false); + const [selectedName, setSelectedName] = useState(''); + const [registrationStep, setRegistrationStep] = useState( + RegistrationSteps.Search, + ); + + const { basenameChain } = useBasenameChain(); + + // Analytics + const { logEventWithContext } = useAnalytics(); + + // Web3 data + const { address } = useAccount(); + const { refetch: baseEnsNameRefetch } = useBaseEnsName({ + address, + }); + + // Username discount states + const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(); + const discount = findFirstValidDiscount(discounts); + + const allActiveDiscounts = useMemo( + () => + new Set( + Object.keys(discounts) + .filter(isValidDiscount) + .map((key) => Discount[key]), + ), + [discounts], + ); + + // TODO: Not a big fan of this, I think ideally we'd have useRegisterNameCallback here + const [registerNameTransactionHash, setRegisterNameTransactionHash] = useState< + Address | undefined + >(); + + // Wait for text record transaction to be processed + const { + data: transactionData, + isFetching: transactionIsFetching, + isSuccess: transactionIsSuccess, + error: transactionError, + } = useWaitForTransactionReceipt({ + hash: registerNameTransactionHash, + chainId: basenameChain.id, + query: { + enabled: !!registerNameTransactionHash, + }, + }); + + useEffect(() => { + if (transactionIsFetching) { + logEventWithContext('register_name_transaction_processing', ActionType.change); + + setRegistrationStep(RegistrationSteps.Pending); + } + + if (transactionIsSuccess) { + if (transactionData.status === 'success') { + logEventWithContext('register_name_transaction_success', ActionType.change); + // Reload current ENS name + baseEnsNameRefetch() + .then(() => { + setRegistrationStep(RegistrationSteps.Success); + }) + .catch(() => {}); + } + + if (transactionData.status === 'reverted') { + logEventWithContext('register_name_transaction_reverted', ActionType.change, { + error: `Transaction reverted: ${transactionData.transactionHash}`, + }); + } + } + }, [ + baseEnsNameRefetch, + logEventWithContext, + setRegistrationStep, + transactionData, + transactionIsFetching, + transactionIsSuccess, + ]); + + useEffect(() => { + if (selectedName.length) { + setRegistrationStep(RegistrationSteps.Claim); + } + }, [selectedName.length]); + + // Log user moving through the flow + useEffect(() => { + logEventWithContext(`step_${registrationStep}`, ActionType.change); + }, [logEventWithContext, registrationStep]); + + // Log user selecting a name + useEffect(() => { + if (!selectedName) return; + logEventWithContext('selected_name', ActionType.change); + }, [logEventWithContext, selectedName]); + + const values = useMemo(() => { + return { + searchInputFocused, + searchInputHovered, + setSearchInputFocused, + setSearchInputHovered, + selectedName, + setSelectedName, + registrationStep, + setRegistrationStep, + registerNameTransactionHash, + setRegisterNameTransactionHash, + loadingDiscounts, + discount, + allActiveDiscounts, + transactionData, + transactionError, + }; + }, [ + allActiveDiscounts, + discount, + loadingDiscounts, + registerNameTransactionHash, + registrationStep, + searchInputFocused, + searchInputHovered, + selectedName, + transactionData, + transactionError, + ]); + + return {children}; +} + +export function useRegistration() { + const context = useContext(RegistrationContext); + if (context === undefined) { + throw new Error('useCount must be used within a CountProvider'); + } + return context; +} diff --git a/apps/web/src/components/Basenames/RegistrationFlow.tsx b/apps/web/src/components/Basenames/RegistrationFlow.tsx new file mode 100644 index 0000000..965700d --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationFlow.tsx @@ -0,0 +1,275 @@ +import { Transition } from '@headlessui/react'; +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import RegistrationBackground from 'apps/web/src/components/Basenames/RegistrationBackground'; +import RegistrationBrand from 'apps/web/src/components/Basenames/RegistrationBrand'; +import { + RegistrationSteps, + registrationTransitionDuration, + useRegistration, +} from 'apps/web/src/components/Basenames/RegistrationContext'; +import RegistrationForm from 'apps/web/src/components/Basenames/RegistrationForm'; +import RegistrationProfileForm from 'apps/web/src/components/Basenames/RegistrationProfileForm'; +import RegistrationSearchInput, { + RegistrationSearchInputVariant, +} from 'apps/web/src/components/Basenames/RegistrationSearchInput'; +import RegistrationSuccessMessage from 'apps/web/src/components/Basenames/RegistrationSuccessMessage'; +import { UsernamePill, UsernamePillVariants } from 'apps/web/src/components/Basenames/UsernamePill'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import { formatBaseEthDomain, USERNAME_DOMAINS } from 'apps/web/src/utils/usernames'; +import classNames from 'classnames'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; +import { ExclamationCircleIcon } from '@heroicons/react/16/solid'; +import { InformationCircleIcon } from '@heroicons/react/16/solid'; +import { useAccount } from 'wagmi'; + +/* +test addresses w/ different verifications + 0xB18e4C959bccc8EF86D78DC297fb5efA99550d85 - cb.id + 0xB18e4C959bccc8EF86D78DC297fb5efA99550d85, 0xB6944B3074F40959E1166fe010a3F86B02cF2b7c- verified account + 0x9C02E8E28D8b706F67dcf0FC7F46A9ee1f9649FA - cb1 +*/ + +export const claimQueryKey = 'claim'; + +export function RegistrationFlow() { + const { isConnected } = useAccount(); + const { logEventWithContext } = useAnalytics(); + const searchParams = useSearchParams(); + const { discount, registrationStep, searchInputFocused, selectedName, setSelectedName } = + useRegistration(); + const { basenameChain } = useBasenameChain(); + const isEarlyAccess = process.env.NEXT_PUBLIC_USERNAMES_EARLY_ACCESS == 'true'; + const isSearch = registrationStep === RegistrationSteps.Search; + const isClaim = registrationStep === RegistrationSteps.Claim; + const isPending = registrationStep === RegistrationSteps.Pending; + const isSuccess = registrationStep === RegistrationSteps.Success; + const isProfile = registrationStep === RegistrationSteps.Profile; + + const layoutPadding = 'px-4 md:px-8'; + const absoluteLayoutPosition = 'top-[40vh] md:top-[50vh]'; + + const mainClasses = classNames( + 'w-full relative min-h-screen pb-40', + 'transition-[padding]', + layoutPadding, + registrationTransitionDuration, + { + 'pt-[calc(40vh-24px)] md:pt-[calc(50vh-24px)]': isSearch || isClaim || isPending || isSuccess, + 'delay-500': isSuccess || isProfile, + 'pt-32 md:pt-40': isProfile, + }, + ); + + const currentUsernamePillVariant = isProfile + ? UsernamePillVariants.Card + : UsernamePillVariants.Inline; + + useEffect(() => { + logEventWithContext('initial_render', ActionType.render); + }, [logEventWithContext]); + + useEffect(() => { + const claimQuery = searchParams?.get(claimQueryKey); + if (claimQuery) { + setSelectedName(claimQuery.replace(`.${USERNAME_DOMAINS[basenameChain.id]}`, '')); + } + }, [basenameChain.id, searchParams, setSelectedName]); + + return ( +
+ {/* 1. Brand & Search */} + +
+ +
+ + + +
+ {/* 2 - Username Pill */} +
+ + {/* 2.1 - Small search input - positioned based on username pill, only for claim */} + + + + + {/* 2.2 - The pill */} + + + + + {/* 2.2 - Pending registration - positioned based on username pill, only visible when registration is pending*/} + + {isPending && ( +

+ Registering... +

+ )} +
+
+ + {/* 3. Registration Form */} + + {!isEarlyAccess || (isEarlyAccess && discount) ? ( +
+ +
+ ) : isConnected ? ( +
+ +

+ The connected wallet is not eligible for early access +

+
+ ) : ( +
+ +

Connect a wallet to register a name

+
+ )} +
+ + {/* 4. Registration Success Message */} + + + +
+ {/* 5. Registration: Edit Profile flow */} + + + + + {/* Misc: Animated background for each steps */} + +
+ ); +} + +export default RegistrationFlow; diff --git a/apps/web/src/components/Basenames/RegistrationForm/index.tsx b/apps/web/src/components/Basenames/RegistrationForm/index.tsx new file mode 100644 index 0000000..12ff213 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationForm/index.tsx @@ -0,0 +1,244 @@ +import { MinusIcon, PlusIcon } from '@heroicons/react/16/solid'; +import { ConnectButton, useConnectModal } from '@rainbow-me/rainbowkit'; +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { useRegistration } from 'apps/web/src/components/Basenames/RegistrationContext'; +import RegistrationLearnMoreModal from 'apps/web/src/components/Basenames/RegistrationLearnMoreModal'; +import { Button, ButtonSizes, ButtonVariants } from 'apps/web/src/components/Button/Button'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import TransactionError from 'apps/web/src/components/TransactionError'; +import TransactionStatus from 'apps/web/src/components/TransactionStatus'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import { useEthPriceFromUniswap } from 'apps/web/src/hooks/useEthPriceFromUniswap'; +import { + useDiscountedNameRegistrationPrice, + useNameRegistrationPrice, +} from 'apps/web/src/hooks/useNameRegistrationPrice'; +import { useRegisterNameCallback } from 'apps/web/src/hooks/useRegisterNameCallback'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { useCallback, useEffect, useState } from 'react'; +import { formatEther } from 'viem'; + +function formatEtherPrice(price?: bigint) { + if (price === undefined) { + return '...'; + } + const value = parseFloat(formatEther(price)); + if (value < 0.001) { + return parseFloat(value.toFixed(4)); + } else { + return parseFloat(value.toFixed(3)); + } +} + +function formatUsdPrice(price: bigint, ethUsdPrice: number) { + if (price === 0n) return '0'; + const parsed = (parseFloat(formatEther(price)) * Number(ethUsdPrice)).toFixed(2); + if (parsed === '0.00') return '0'; + return parsed; +} + +export default function RegistrationForm() { + const { openConnectModal } = useConnectModal(); + const { logEventWithContext } = useAnalytics(); + const { basenameChain } = useBasenameChain(); + + const { + transactionData, + transactionError, + selectedName, + setRegisterNameTransactionHash, + discount, + loadingDiscounts, + } = useRegistration(); + const [years, setYears] = useState(1); + + const [learnMoreModalOpen, setLearnMoreModalOpen] = useState(false); + + const toggleLearnMoreModal = useCallback(() => { + logEventWithContext('open_learn_more_modal', ActionType.change); + setLearnMoreModalOpen((open) => !open); + }, [logEventWithContext]); + + const increment = useCallback(() => { + logEventWithContext('registration_form_increment_year', ActionType.click); + + setYears((n) => n + 1); + }, [logEventWithContext]); + + const decrement = useCallback(() => { + logEventWithContext('registration_form_decement_year', ActionType.click); + + setYears((n) => (n > 1 ? n - 1 : n)); + }, [logEventWithContext]); + + const ethUsdPrice = useEthPriceFromUniswap(); + const { data: initialPrice } = useNameRegistrationPrice(selectedName, years); + const { data: discountedPrice } = useDiscountedNameRegistrationPrice( + selectedName, + years, + discount?.discountKey, + ); + + const price = discountedPrice ?? initialPrice; + + const { + callback: registerName, + data: registerNameTransactionHash, + error, + isPending: registerNameTransactionIsPending, + error: registerNameError, + } = useRegisterNameCallback( + selectedName, + price, + years, + discount?.discountKey, + discount?.validationData, + ); + + if (error) { + // todo: handle this error in the UI + // it is likely the user doesn't have sufficient funds to register and they've failed simulation + console.error(error); + } + + useEffect(() => { + if (registerNameTransactionHash) { + logEventWithContext('register_name_transaction_approved', ActionType.change); + + setRegisterNameTransactionHash(registerNameTransactionHash); + } + }, [logEventWithContext, registerNameTransactionHash, setRegisterNameTransactionHash]); + + const registerNameCallback = useCallback(() => { + registerName() + .then(() => {}) + .catch(() => {}); + }, [registerName]); + + const usdPrice = + price !== undefined && ethUsdPrice !== undefined ? formatUsdPrice(price, ethUsdPrice) : '--.--'; + const nameIsFree = price === 0n; + + return ( + <> +
+
+
+

Claim for

+
+ + + {years} year{years > 1 && 's'} + + +
+
+
+

Amount

+
+ {discountedPrice !== undefined ? ( +
+

+ {formatEtherPrice(initialPrice)} +

+

+ {formatEtherPrice(discountedPrice)} ETH +

+
+ ) : ( +

+ {formatEtherPrice(price)} ETH +

+ )} + {loadingDiscounts ? ( +
+ +
+ ) : ( + ${usdPrice} + )} +
+ {nameIsFree &&

Free with your verification

} +
+ + + {({ account, chain, mounted }) => { + const ready = mounted; + const connected = ready && account && chain; + + if (!connected) { + return ( + + ); + } + + return ( + + ); + }} + +
+ + {transactionError !== null && ( + + )} + {registerNameError && ( + + )} + {transactionData && transactionData.status === 'reverted' && ( + + )} +
+

+ {nameIsFree ? "You've qualified for a free name! " : 'Unlock your username for free! '} +

+ +
+
+ + + ); +} diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/base-buildathon-participant.svg b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/base-buildathon-participant.svg new file mode 100644 index 0000000..1d8732b --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/base-buildathon-participant.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/cbid-verification.svg b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/cbid-verification.svg new file mode 100644 index 0000000..e7ec186 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/cbid-verification.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-one-verification.svg b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-one-verification.svg new file mode 100644 index 0000000..4f23e9e --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-one-verification.svg @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-verification.svg b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-verification.svg new file mode 100644 index 0000000..9f67d27 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/coinbase-verification.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/summer-pass-lvl-3.svg b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/summer-pass-lvl-3.svg new file mode 100644 index 0000000..2fe7b0f --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/images/summer-pass-lvl-3.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/components/Basenames/RegistrationLearnMoreModal/index.tsx b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/index.tsx new file mode 100644 index 0000000..48d4b5d --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationLearnMoreModal/index.tsx @@ -0,0 +1,196 @@ +import { InformationCircleIcon } from '@heroicons/react/20/solid'; +import { useRegistration } from 'apps/web/src/components/Basenames/RegistrationContext'; +import Modal from 'apps/web/src/components/Modal'; +import Tooltip from 'apps/web/src/components/Tooltip'; +import { Discount } from 'apps/web/src/utils/usernames'; +import classNames from 'classnames'; +import Link from 'next/link'; +import baseBuildathonParticipant from './images/base-buildathon-participant.svg'; +import summerPassLvl3 from './images/summer-pass-lvl-3.svg'; +import cbidVerification from './images/cbid-verification.svg'; +import coinbaseOneVerification from './images/coinbase-one-verification.svg'; +import coinbaseVerification from './images/coinbase-verification.svg'; +import { StaticImageData } from 'next/dist/shared/lib/get-img-props'; +import ImageWithLoading from 'apps/web/src/components/ImageWithLoading'; + +function InfoIcon() { + return ; +} + +export default function RegistrationLearnMoreModal({ + isOpen, + toggleModal, +}: { + isOpen: boolean; + toggleModal: () => void; +}) { + const { allActiveDiscounts } = useRegistration(); + + const hasDiscount = allActiveDiscounts.size > 0; + const rowClasses = 'flex flex-row items-center justify-start'; + const CBRowClasses = classNames(rowClasses, { + 'opacity-40': hasDiscount && !allActiveDiscounts.has(Discount.COINBASE_VERIFIED_ACCOUNT), + }); + const CB1RowClasses = classNames(rowClasses, { + 'opacity-40': hasDiscount && !allActiveDiscounts.has(Discount.CB1), + }); + const CBIDRowClasses = classNames(rowClasses, { + 'opacity-40': hasDiscount && !allActiveDiscounts.has(Discount.CBID), + }); + const BuildathonRowClasses = classNames(rowClasses, { + 'opacity-40': hasDiscount && !allActiveDiscounts.has(Discount.BASE_BUILDATHON_PARTICIPANT), + }); + const SummerPassRowClasses = classNames(rowClasses, { + 'opacity-40': hasDiscount && !allActiveDiscounts.has(Discount.SUMMER_PASS_LVL_3), + }); + + const qualifiedClasses = classNames( + 'flex flex-row items-center justify-center py-3 px-1 h-5 text-xs bg-green-0 rounded ml-3', + ); + return ( + +
+ + {hasDiscount ? "You're getting a discounted name" : 'Register for free'} + +

+ {hasDiscount + ? "You're receiving your name for free (5+ characters for 1 year) because your wallet has one of the following:" + : "You'll receive a name for free (5+ characters for 1 year) if your wallet has any of the following:"} +

+
    +
  • + +
    + +

    Coinbase verification

    + +
    +
    + {allActiveDiscounts.has(Discount.COINBASE_VERIFIED_ACCOUNT) && ( +
    +

    Qualified

    +
    + )} +
  • +
  • + +
    + +

    Coinbase One verification

    + +
    +
    + {allActiveDiscounts.has(Discount.CB1) && ( +
    +

    Qualified

    +
    + )} +
  • +
  • + +
    + +

    A cb.id username

    + +
    +
    + {allActiveDiscounts.has(Discount.CBID) && ( +
    +

    Qualified

    +
    + )} +
  • +
  • + +
    + +

    Base buildathon participant

    + +
    +
    + {allActiveDiscounts.has(Discount.BASE_BUILDATHON_PARTICIPANT) && ( +
    +

    Qualified

    +
    + )} +
  • +
  • + +
    + +

    Summer Pass Level 3

    + +
    +
    + {allActiveDiscounts.has(Discount.SUMMER_PASS_LVL_3) && ( +
    +

    Qualified

    +
    + )} +
  • +
+ {!hasDiscount && ( + <> +

+ Your registration will be gasless with{' '} + + a smart wallet + + . +

+
+ Don't have any of these?{' '} + + Get a verification + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx b/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx new file mode 100644 index 0000000..5b31643 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx @@ -0,0 +1,298 @@ +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { + registrationTransitionDuration, + useRegistration, +} from 'apps/web/src/components/Basenames/RegistrationContext'; +import UsernameDescriptionField from 'apps/web/src/components/Basenames/UsernameDescriptionField'; +import UsernameKeywordsField from 'apps/web/src/components/Basenames/UsernameKeywordsField'; +import UsernameTextRecordInlineField from 'apps/web/src/components/Basenames/UsernameTextRecordInlineField'; +import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button'; +import Fieldset from 'apps/web/src/components/Fieldset'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import Label from 'apps/web/src/components/Label'; +import TransactionError from 'apps/web/src/components/TransactionError'; +import TransactionStatus from 'apps/web/src/components/TransactionStatus'; +import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import useReadBaseEnsTextRecords from 'apps/web/src/hooks/useReadBaseEnsTextRecords'; +import useWriteBaseEnsTextRecords from 'apps/web/src/hooks/useWriteBaseEnsTextRecords'; +import { + UsernameTextRecords, + UsernameTextRecordKeys, + textRecordsSocialFieldsEnabled, + formatBaseEthDomain, +} from 'apps/web/src/utils/usernames'; +import classNames from 'classnames'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { useAccount, useWaitForTransactionReceipt } from 'wagmi'; + +export enum FormSteps { + Description = 'description', + Socials = 'socials', + Keywords = 'keywords', +} + +export default function RegistrationProfileForm() { + const [currentFormStep, setCurrentFormStep] = useState(FormSteps.Description); + const [transitionStep, setTransitionStep] = useState(false); + const { selectedName } = useRegistration(); + const { address } = useAccount(); + const router = useRouter(); + const { logEventWithContext } = useAnalytics(); + const { basenameChain } = useBasenameChain(); + const { data: baseEnsName } = useBaseEnsName({ + address, + }); + + const { + existingTextRecords, + existingTextRecordsIsLoading, + refetchExistingTextRecords, + existingTextRecordsError, + } = useReadBaseEnsTextRecords({ + address: address, + username: baseEnsName, + }); + + // Write text records + const { + writeTextRecords, + writeTextRecordsIsPending, + writeTextRecordsTransactionHash, + writeTextRecordsError, + } = useWriteBaseEnsTextRecords({ + address: address, + username: baseEnsName, + }); + + // Wait for text record transaction to be processed + const { + data: transactionData, + isFetching: transactionIsFetching, + error: transactionError, + } = useWaitForTransactionReceipt({ + hash: writeTextRecordsTransactionHash, + chainId: basenameChain.id, + query: { + enabled: !!writeTextRecordsTransactionHash, + refetchOnWindowFocus: false, + }, + }); + + const [textRecords, setTextRecords] = useState(existingTextRecords); + + useEffect(() => { + if (!transactionData) return; + + if (transactionData.status === 'success') { + logEventWithContext('update_text_records_transaction_success', ActionType.change); + + refetchExistingTextRecords() + .then(() => { + router.push(`name/${formatBaseEthDomain(selectedName, basenameChain.id)}`); + }) + .catch(() => { + // console.log({ error }); + }); + } + + if (transactionData.status === 'reverted') { + logEventWithContext('update_text_records_transaction_reverted', ActionType.change, { + error: `Transaction reverted: ${transactionData.transactionHash}`, + }); + } + }, [ + logEventWithContext, + refetchExistingTextRecords, + router, + baseEnsName, + transactionData, + selectedName, + basenameChain.id, + ]); + + useEffect(() => { + if (transactionIsFetching) { + logEventWithContext('update_text_records_transaction_processing', ActionType.change); + } + }, [logEventWithContext, transactionIsFetching]); + + useEffect(() => { + setTextRecords(existingTextRecords); + }, [existingTextRecords]); + + const updateTextRecords = useCallback((key: UsernameTextRecordKeys, value: string) => { + setTextRecords((previousTextRecords) => { + return { + ...previousTextRecords, + [key]: value, + }; + }); + }, []); + + const transitionFormOpacity = useCallback((callbackFunction: () => void) => { + // Hide the form + setTransitionStep(true); + + setTimeout(() => { + // Display the next step + callbackFunction(); + setTimeout(() => { + // Show the form + setTransitionStep(false); + }, 700); + }, 700); + }, []); + + const onClickSave = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (currentFormStep === FormSteps.Description) { + transitionFormOpacity(() => setCurrentFormStep(FormSteps.Socials)); + } + + if (currentFormStep === FormSteps.Socials) { + transitionFormOpacity(() => setCurrentFormStep(FormSteps.Keywords)); + } + + if (currentFormStep === FormSteps.Keywords) { + logEventWithContext('update_text_records_transaction_initiated', ActionType.change); + + writeTextRecords(textRecords) + .then((result) => { + // We updated some text records + if (result) { + logEventWithContext('update_text_records_transaction_approved', ActionType.change); + } else { + // no text records had to be updated, simply go to profile + router.push(`names/${formatBaseEthDomain(selectedName, basenameChain.id)}`); + } + }) + .catch(console.error); + } + + event.preventDefault(); + }, + [ + basenameChain.id, + currentFormStep, + logEventWithContext, + router, + selectedName, + textRecords, + transitionFormOpacity, + writeTextRecords, + ], + ); + + const onChangeTextRecord = useCallback( + (key: UsernameTextRecordKeys, value: string) => { + updateTextRecords(key, value); + }, + [updateTextRecords], + ); + + const formClasses = classNames( + 'flex flex-col justify-between gap-4 text-gray-60 md:items-center rounded-3xl shadow-xl p-8 transition-all', + registrationTransitionDuration, + { 'opacity-0': transitionStep, 'opacity-100': !transitionStep }, + ); + + const descriptionLabelChildren = ( +
+

+

+ + Add Bio +
+ Step 1 of 3 +

+
+ ); + + const socialsLabelChildren = ( +
+

+

+ + Add Socials +
+ Step 2 of 3 +

+
+ ); + + const keywordsLabelChildren = ( +
+

+

+ + Add areas of expertise +
+ Step 3 of 3 +

+
+ ); + + const isLoading = + existingTextRecordsIsLoading || writeTextRecordsIsPending || transactionIsFetching; + + useEffect(() => { + logEventWithContext(`registration_profile_form_step_${currentFormStep}`, ActionType.change); + }, [currentFormStep, logEventWithContext]); + + return ( +
+ {currentFormStep === FormSteps.Description && ( + + )} + {currentFormStep === FormSteps.Socials && ( +
+ + {textRecordsSocialFieldsEnabled.map((textRecordKey) => ( + + ))} +
+ )} + {currentFormStep === FormSteps.Keywords && ( +
+ +
+ )} + + {writeTextRecordsError && } + {existingTextRecordsError && } + {transactionError && } + {transactionData && transactionData.status === 'reverted' && ( + + )} + + ); +} diff --git a/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx b/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx new file mode 100644 index 0000000..de22344 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx @@ -0,0 +1,319 @@ +import { ChevronRightIcon } from '@heroicons/react/24/outline'; +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { useRegistration } from 'apps/web/src/components/Basenames/RegistrationContext'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import Input from 'apps/web/src/components/Input'; +import { useAlternativeNameSuggestions } from 'apps/web/src/hooks/useAlternativeNameSuggestions'; +import { useFocusWithin } from 'apps/web/src/hooks/useFocusWithin'; +import { useIsNameAvailable } from 'apps/web/src/hooks/useIsNameAvailable'; +import { formatBaseEthDomain, validateEnsDomainName } from 'apps/web/src/utils/usernames'; +import classNames from 'classnames'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { useDebounceValue } from 'usehooks-ts'; +import Tooltip from 'apps/web/src/components/Tooltip'; +import { InformationCircleIcon } from '@heroicons/react/16/solid'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; + +export enum RegistrationSearchInputVariant { + Small, + Large, +} + +type RegistrationSearchInputProps = { + variant: RegistrationSearchInputVariant; + placeholder: string; +}; + +export default function RegistrationSearchInput({ + variant, + placeholder, +}: RegistrationSearchInputProps) { + const { ref, focused } = useFocusWithin(); + const { logEventWithContext } = useAnalytics(); + const [search, setSearch] = useState(''); + const inputRef = useRef(null); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [debouncedSearch] = useDebounceValue(search, 400); + const { basenameChain } = useBasenameChain(); + const { + isLoading: isLoadingNameAvailability, + data: isNameAvailable, + isError: errorCheckingNameAvailability, + isFetching, + } = useIsNameAvailable(debouncedSearch); + const { + data: suggestions, + error: alternativeNameSuggestionError, + isLoading: isLoadingAlternatives, + } = useAlternativeNameSuggestions(debouncedSearch, isNameAvailable === false); + const isLoading = isLoadingAlternatives || isLoadingNameAvailability; + + const { valid, message } = validateEnsDomainName(debouncedSearch); + const invalidWithMessage = !valid && !!message; + + const { setSearchInputFocused, setSearchInputHovered, setSelectedName } = useRegistration(); + + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + const { value } = event.target; + setSearch(value.replace(/\s/g, '')); + }, []); + + useEffect(() => { + setDropdownOpen(valid || invalidWithMessage); + }, [debouncedSearch, invalidWithMessage, valid]); + + const RegistrationSearchInputClasses = classNames( + 'relative z-10 transition-all duration-500 w-full mx-auto group text-black', + ); + + // This will change/animate the border when hovering the whole component + const groupBorderClasses = classNames('transition-colors', { + 'border-2 border-gray-40/20 group-hover:border-blue-600 ': + variant === RegistrationSearchInputVariant.Large, + 'border border-transparent group-hover:border-gray-40/20 ': + variant === RegistrationSearchInputVariant.Small, + + 'shadow-lg': variant === RegistrationSearchInputVariant.Large, + }); + + const inputClasses = classNames( + 'w-full outline-0 placeholder:uppercase peer ', + // Padding & Font sizes + { + 'py-7 pl-6 pr-16 text-2xl': variant === RegistrationSearchInputVariant.Large, + 'py-2 pl-3 pr-6': variant === RegistrationSearchInputVariant.Small, + }, + // Background + { + 'bg-white': variant === RegistrationSearchInputVariant.Large, + 'bg-transparent focus:bg-white': variant === RegistrationSearchInputVariant.Small, + }, + // border colors + { + 'border-gray-40/20 focus:border-blue-600 ': variant === RegistrationSearchInputVariant.Large, + 'focus:border-gray-40/20 hover:border-gray-40/20': + variant === RegistrationSearchInputVariant.Small, + }, + // Borders Radius + { + 'rounded-3xl': variant === RegistrationSearchInputVariant.Large, + 'rounded-xl': variant === RegistrationSearchInputVariant.Small, + 'rounded-b-none border-b-none border-b-0': dropdownOpen, + }, + groupBorderClasses, + ); + + const dropdownClasses = classNames( + 'flex flex-col items-start bg-white text-black', + 'absolute left-0 right-0 top-full z-10 border-t-0 ', + groupBorderClasses, + // radius, Padding & Font sizes + { + 'pb-4 rounded-b-3xl': variant === RegistrationSearchInputVariant.Large, + 'pb-2 rounded-b-xl': variant === RegistrationSearchInputVariant.Small, + }, + // border colors + { + 'border-2 peer-focus:border-blue-600': variant === RegistrationSearchInputVariant.Large, + 'border peer-focus:border-gray-40/20': variant === RegistrationSearchInputVariant.Small, + }, + // Visible or not + { + 'opacity-0': !dropdownOpen, + 'opacity-1': dropdownOpen, + }, + + // // Animation + 'transition-all overflow-scroll ', + { + 'max-h-[20rem]': dropdownOpen, + 'max-h-0 p-0 overflow-hidden border-none': !dropdownOpen, + }, + ); + + const dropdownLabelClasses = classNames( + 'w-full uppercase text-gray-60 font-bold pointer-events-none', + { + 'text-sm ml-6 mb-4 mt-4': variant === RegistrationSearchInputVariant.Large, + 'text-xs ml-3 mb-2 mt-2': variant === RegistrationSearchInputVariant.Small, + }, + ); + + const buttonClasses = classNames( + 'flex w-full flex-row items-center justify-between transition-colors hover:bg-[#F9F9F9] active:bg-[#EAEAEB] text-ellipsis', + { + 'px-6 py-3 text': variant === RegistrationSearchInputVariant.Large, + 'px-3 py-2 text-sm': variant === RegistrationSearchInputVariant.Small, + }, + ); + + const inputIconClasses = classNames('absolute top-1/2 z-20 flex -translate-y-1/2 items-center', { + 'right-8': variant === RegistrationSearchInputVariant.Large, + 'right-3': variant === RegistrationSearchInputVariant.Small, + }); + + const lineClasses = classNames('w-full', { + 'px-6': variant === RegistrationSearchInputVariant.Large, + 'px-3': variant === RegistrationSearchInputVariant.Small, + }); + + const mutedMessage = classNames('text-gray-60', { + 'px-6 py-4 text': variant === RegistrationSearchInputVariant.Large, + 'px-3 py-2 text-sm': variant === RegistrationSearchInputVariant.Small, + }); + + const spinnerWrapperClasses = classNames('flex w-full items-center justify-center', { + // Equivalent to the dropdown when one name is available + 'h-[6.75rem]': variant === RegistrationSearchInputVariant.Large, + 'h-[4.25rem]': variant === RegistrationSearchInputVariant.Small, + }); + + const iconSize = variant === RegistrationSearchInputVariant.Large ? 24 : 16; + + const inputId = useId(); + + useEffect(() => { + if (!focused) { + setDropdownOpen(false); + return; + } + + if (focused && valid) { + setDropdownOpen(true); + } + }, [focused, valid]); + + const handleSelectName = useCallback( + (name: string) => { + setDropdownOpen(false); + setSelectedName(name.trim()); + }, + [setSelectedName], + ); + + const resetSearch = useCallback(() => { + setSearch(''); + }, []); + + const onMouseEnterFieldset = useCallback( + () => setSearchInputHovered(true), + [setSearchInputHovered], + ); + const onMouseLeaveFieldset = useCallback( + () => setSearchInputHovered(false), + [setSearchInputHovered], + ); + + useEffect(() => { + setSearchInputFocused(focused); + }, [focused, setSearchInputFocused]); + + useEffect(() => { + if (!invalidWithMessage) return; + + // Log invalid + logEventWithContext('search_available_name_invalid', ActionType.error, { error: message }); + }, [invalidWithMessage, logEventWithContext, message, setSearchInputFocused]); + + return ( +
+ +
+
+
+
+ {invalidWithMessage ? ( +

{message}

+ ) : isNameAvailable === true ? ( + <> +

Available

+ + + ) : isLoading || isFetching ? ( +
+ +
+ ) : errorCheckingNameAvailability ? ( +

+ There was an error checking if your desired name is available +

+ ) : alternativeNameSuggestionError ? ( +

+ There was an error coming up with alternative name suggestions. +

+ ) : ( + <> +

+ {formatBaseEthDomain(debouncedSearch, basenameChain.id)} is not available +

+ {suggestions.length > 0 ? ( + <> + +
+

Suggested

+ +
+
+ {suggestions.map((suggestion) => ( + + ))} + + ) : ( +

+ We are currently unable to offer alternative name suggestions +

+ )} + + )} +
+ + {search.length > 0 ? ( + + ) : ( + + + + )} + +
+ ); +} diff --git a/apps/web/src/components/Basenames/RegistrationSuccessMessage/index.tsx b/apps/web/src/components/Basenames/RegistrationSuccessMessage/index.tsx new file mode 100644 index 0000000..3f35ff6 --- /dev/null +++ b/apps/web/src/components/Basenames/RegistrationSuccessMessage/index.tsx @@ -0,0 +1,72 @@ +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { + RegistrationSteps, + useRegistration, +} from 'apps/web/src/components/Basenames/RegistrationContext'; +import ShareUsernameModal from 'apps/web/src/components/Basenames/ShareUsernameModal'; +import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import { formatBaseEthDomain } from 'apps/web/src/utils/usernames'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import React, { useCallback, useState } from 'react'; + +export default function RegistrationSuccessMessage() { + const { setRegistrationStep, selectedName } = useRegistration(); + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const { logEventWithContext } = useAnalytics(); + const { basenameChain } = useBasenameChain(); + const openModal = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + logEventWithContext('open_share_on_social_modal', ActionType.click); + setIsOpen(true); + }, + [logEventWithContext], + ); + + const closeModal = useCallback(() => { + setIsOpen(false); + }, []); + + const customizeProfileOnClick = useCallback(() => { + logEventWithContext('customize_profile', ActionType.click); + setRegistrationStep(RegistrationSteps.Profile); + }, [logEventWithContext, setRegistrationStep]); + + const goToProfileOnClick = useCallback(() => { + logEventWithContext('go_to_profile', ActionType.click); + router.push(`name/${formatBaseEthDomain(selectedName, basenameChain.id)}`); + }, [basenameChain.id, logEventWithContext, router, selectedName]); + + return ( + <> +
+

+ Congrats! This name is yours! +

+
+ + + +
+
+ +

+ + Share your name on socials + +

+ + ); +} diff --git a/apps/web/src/components/Basenames/ShareUsernameModal/index.tsx b/apps/web/src/components/Basenames/ShareUsernameModal/index.tsx new file mode 100644 index 0000000..cc53d02 --- /dev/null +++ b/apps/web/src/components/Basenames/ShareUsernameModal/index.tsx @@ -0,0 +1,138 @@ +import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button'; +import Modal from 'apps/web/src/components/Modal'; +import { useCallback, useState } from 'react'; +import classNames from 'classnames'; +import { + SocialMediaShareParams, + SocialPlatform, + socialPlatformCtaForDisplay, + socialPlatformHandle, + socialPlatformIconName, + socialPlatformShareLinkFunction, + socialPlatformsNameForDisplay, +} from 'apps/web/src/utils/socialPlatforms'; +import { openGraphImageHeight, openGraphImageWidth } from 'apps/web/src/utils/opengraphs'; +import { Icon } from 'apps/web/src/components/Icon/Icon'; +import { ActionType } from 'libs/base-ui/utils/logEvent'; +import { useAnalytics } from 'apps/web/contexts/Analytics'; +import { formatBaseEthDomain } from 'apps/web/src/utils/usernames'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import Image from 'next/image'; + +export const socialPlatformsEnabled = [SocialPlatform.Twitter, SocialPlatform.Farcaster]; + +function SocialPlatformButton({ + socialPlatform, + openPopup, +}: { + socialPlatform: SocialPlatform; + openPopup: (socialPlatform: SocialPlatform) => void; +}) { + const onClick = useCallback(() => { + openPopup(socialPlatform); + }, [socialPlatform, openPopup]); + + return ( +
  • + +
  • + ); +} + +export default function ShareUsernameModal({ + isOpen, + toggleModal, + selectedName, +}: { + isOpen: boolean; + toggleModal: () => void; + selectedName: string; +}) { + const [imageIsLoading, setImageIsLoading] = useState(true); + const { logEventWithContext } = useAnalytics(); + const coverImageWrapperClasses = classNames( + 'min-h-[10.25rem] w-full overflow-hidden rounded-2xl border border-gray-40/20 bg-gray-40/10', + { + 'animate-pulse': imageIsLoading, + }, + ); + + const { basenameChain } = useBasenameChain(); + + const coverImageClasses = classNames('transition-opacity duration-500', { + 'opacity-0': imageIsLoading, + 'opacity-100': !imageIsLoading, + }); + + const onLoadImage = useCallback(() => { + setImageIsLoading(false); + }, []); + + const popupWidth = 600; + const popupHeight = 600; + + const openPopup = (socialPlatform: SocialPlatform) => { + const socialMediaShareParams: SocialMediaShareParams = { + text: `I just got a name from ${socialPlatformHandle[socialPlatform]} during Onchain Summer! You can get yours too at base.org/name`, + url: `https://base.org/name/${selectedName}`, + }; + const shareLinkFunction = socialPlatformShareLinkFunction[socialPlatform]; + if (shareLinkFunction) { + logEventWithContext(`share_on_social_${socialPlatform}`, ActionType.click); + + const shareLink = shareLinkFunction(socialMediaShareParams); + const left = window.innerWidth / 2 - popupWidth / 2; + const top = window.innerHeight / 2 - popupHeight / 2; + const options = `width=${popupWidth},height=${popupHeight},left=${left},top=${top}`; + + window.open(shareLink, '_blank', options); + } + }; + + return ( + +
    +

    + I just got a name from @base during Onchain Summer! + You can get yours too at base.org/name +

    +
    + {selectedName} +
    +
    +
      + {socialPlatformsEnabled.map((socialPlatform) => ( + + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/Basenames/UsernameAvatarField/cameraIcon.svg b/apps/web/src/components/Basenames/UsernameAvatarField/cameraIcon.svg new file mode 100644 index 0000000..8ee9cd9 --- /dev/null +++ b/apps/web/src/components/Basenames/UsernameAvatarField/cameraIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx b/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx new file mode 100644 index 0000000..1a98bb0 --- /dev/null +++ b/apps/web/src/components/Basenames/UsernameAvatarField/index.tsx @@ -0,0 +1,115 @@ +import Fieldset from 'apps/web/src/components/Fieldset'; +import FileInput from 'apps/web/src/components/FileInput'; +import Hint, { HintVariants } from 'apps/web/src/components/Hint'; +import Label from 'apps/web/src/components/Label'; +import { getUserNamePicture } from 'apps/web/src/utils/usernames'; +import { ChangeEvent, useCallback, useEffect, useId, useState } from 'react'; +import { + ALLOWED_IMAGE_TYPE, + MAX_IMAGE_SIZE_IN_MB, +} from 'apps/web/pages/api/basenames/avatar/upload'; +import Image, { StaticImageData } from 'next/image'; +import ImageWithLoading from 'apps/web/src/components/ImageWithLoading'; +import cameraIcon from './cameraIcon.svg'; + +export type UsernameAvatarFieldProps = { + onChange: (file: File | undefined) => void; + value: string; + disabled?: boolean; + username: string; +}; + +export function validateAvatarUpload(file: File) { + if (!ALLOWED_IMAGE_TYPE.includes(file.type)) { + return { + valid: false, + message: 'Only supported image are PNG, SVG, JPEG & WebP', + }; + } + const bytes = file.size; + const bytesToMegaBytes = bytes / (1024 * 1024); + + if (bytesToMegaBytes > MAX_IMAGE_SIZE_IN_MB) { + return { + valid: false, + message: 'Max image size is 1Mb', + }; + } + + // TODO: Validate a square-ish image, with a width/height ratio of minimum 0.8 + return { + valid: true, + message: '', + }; +} + +export default function UsernameAvatarField({ + onChange, + value, + disabled = false, + username, +}: UsernameAvatarFieldProps) { + const [error, setError] = useState(); + const [avatarFile, setAvatarFile] = useState(); + const onChangeAvatar = useCallback((event: ChangeEvent) => { + const files = event.target.files; + if (!files) return; + + const singleFile = files[0]; + if (!singleFile) return; + + setAvatarFile(singleFile); + }, []); + + useEffect(() => { + if (!avatarFile) return; + const validationResult = validateAvatarUpload(avatarFile); + + if (!validationResult.valid) { + onChange(undefined); + setError(validationResult.message); + return; + } else { + onChange(avatarFile); + return setError(''); + } + }, [avatarFile, onChange]); + + const usernameAvatarFieldId = useId(); + + const defaultSelectedProfilePicture = getUserNamePicture(username); + const currentAvatar = value; // TODO: parse IPFS + + const avatarSrc = + avatarFile && !error + ? URL.createObjectURL(avatarFile) + : currentAvatar || defaultSelectedProfilePicture; + + return ( +
    + + {error && {error}} +
    + ); +} diff --git a/apps/web/src/components/Basenames/UsernameDescriptionField/index.tsx b/apps/web/src/components/Basenames/UsernameDescriptionField/index.tsx new file mode 100644 index 0000000..9cf0aee --- /dev/null +++ b/apps/web/src/components/Basenames/UsernameDescriptionField/index.tsx @@ -0,0 +1,60 @@ +import Fieldset from 'apps/web/src/components/Fieldset'; +import Hint from 'apps/web/src/components/Hint'; +import Label from 'apps/web/src/components/Label'; +import TextArea from 'apps/web/src/components/TextArea'; +import { + textRecordsKeysPlaceholderForDisplay, + USERNAME_DESCRIPTION_MAX_LENGTH, + UsernameTextRecordKeys, +} from 'apps/web/src/utils/usernames'; +import { ChangeEvent, ReactNode, useCallback, useId } from 'react'; + +export type UsernameDescriptionFieldProps = { + labelChildren?: ReactNode; + onChange: (key: UsernameTextRecordKeys, value: string) => void; + value: string; + disabled?: boolean; +}; + +export default function UsernameDescriptionField({ + labelChildren = 'Description', + onChange, + value, + disabled = false, +}: UsernameDescriptionFieldProps) { + const descriptionLength = value.length; + + const onChangeDescription = useCallback( + (event: ChangeEvent) => { + const description = event.target.value; + if (description.length > USERNAME_DESCRIPTION_MAX_LENGTH) { + event.preventDefault(); + } else { + // setDescription(value); + if (onChange) onChange(UsernameTextRecordKeys.Description, description); + } + }, + [onChange], + ); + + const usernameDescriptionFieldId = useId(); + const descriptionCharactersRemaining = USERNAME_DESCRIPTION_MAX_LENGTH - descriptionLength; + + return ( +
    + {labelChildren && } +