diff --git a/.codeflow.yml b/.codeflow.yml index f281de5be9..24ae941379 100644 --- a/.codeflow.yml +++ b/.codeflow.yml @@ -14,11 +14,13 @@ build: - BaldurECR: name: docs path: ./apps/base-docs/Dockerfile + architecture: amd64 - BaldurECR: name: bridge path: ./apps/bridge/Dockerfile + architecture: amd64 multi_arch: true operate: slack_channels: - - "#base-codeflow-notifications" + - '#base-codeflow-notifications' diff --git a/apps/base-docs/assets/images/resend-email-campaigns/ock-dashboard.png b/apps/base-docs/assets/images/resend-email-campaigns/ock-dashboard.png new file mode 100644 index 0000000000..549e442d22 Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/ock-dashboard.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/ock-use-template.png b/apps/base-docs/assets/images/resend-email-campaigns/ock-use-template.png new file mode 100644 index 0000000000..38a662c359 Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/ock-use-template.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/resend-1.gif b/apps/base-docs/assets/images/resend-email-campaigns/resend-1.gif new file mode 100644 index 0000000000..e916d329ad Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/resend-1.gif differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/resend-api-keys.png b/apps/base-docs/assets/images/resend-email-campaigns/resend-api-keys.png new file mode 100644 index 0000000000..a2243ae07f Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/resend-api-keys.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/resend-contact-added.png b/apps/base-docs/assets/images/resend-email-campaigns/resend-contact-added.png new file mode 100644 index 0000000000..280e051a62 Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/resend-contact-added.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/resend-mailing-list-prompt.png b/apps/base-docs/assets/images/resend-email-campaigns/resend-mailing-list-prompt.png new file mode 100644 index 0000000000..5f6680a390 Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/resend-mailing-list-prompt.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/resend-user-subscribed.png b/apps/base-docs/assets/images/resend-email-campaigns/resend-user-subscribed.png new file mode 100644 index 0000000000..719a4c137b Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/resend-user-subscribed.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/site-load.png b/apps/base-docs/assets/images/resend-email-campaigns/site-load.png new file mode 100644 index 0000000000..96d9e5ec3c Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/site-load.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/vercel-import-project.png b/apps/base-docs/assets/images/resend-email-campaigns/vercel-import-project.png new file mode 100644 index 0000000000..f535d5ca56 Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/vercel-import-project.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/verel-login.png b/apps/base-docs/assets/images/resend-email-campaigns/verel-login.png new file mode 100644 index 0000000000..a4af98413b Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/verel-login.png differ diff --git a/apps/base-docs/assets/images/resend-email-campaigns/wc-project-page.png b/apps/base-docs/assets/images/resend-email-campaigns/wc-project-page.png new file mode 100644 index 0000000000..38c9180a6b Binary files /dev/null and b/apps/base-docs/assets/images/resend-email-campaigns/wc-project-page.png differ diff --git a/apps/base-docs/base-learn/docs/frontend-setup/building-an-onchain-app.md b/apps/base-docs/base-learn/docs/frontend-setup/building-an-onchain-app.md index c2210330cb..4ce0e75bfe 100644 --- a/apps/base-docs/base-learn/docs/frontend-setup/building-an-onchain-app.md +++ b/apps/base-docs/base-learn/docs/frontend-setup/building-an-onchain-app.md @@ -51,11 +51,9 @@ The [quick start] guide for RainbowKit also contains step-by-step instructions f Start by installing the dependencies: -:::bash - +```bash npm install @rainbow-me/rainbowkit wagmi viem@2.x @tanstack/react-query - -::: +``` :::info Onchain libraries and packages tend to require very current versions of Node. If you're not already using it, you may want to install [nvm]. @@ -65,11 +63,11 @@ Onchain libraries and packages tend to require very current versions of Node. If In Next.js with the app router, the root of your app is found in `app/layout.tsx`, if you followed the recommended setup options. As you want the blockchain provider context to be available for the entire app, you'll add it here. -You'll need to set up your providers in a second file, so that you can add `"use client":` to the top. Doing so forces this code to be run client side, which is necessary since your server won't have access to your users' wallet information. +You'll need to set up your providers in a second file, so that you can add `'use client';` to the top. Doing so forces this code to be run client side, which is necessary since your server won't have access to your users' wallet information. :::caution -You must configure these wrappers in a separate file. It will not work if you try to add them and `"use client":` directly in `layout.tsx`! +You must configure these wrappers in a separate file. It will not work if you try to add them and `'use client';` directly in `layout.tsx`! ::: @@ -77,7 +75,7 @@ Add a new file in the `app` folder called `providers.tsx`. ### Imports -As discussed above, add `"use client":` to the top of the file. +As discussed above, add `'use client';` to the top of the file. Continue with the imports: diff --git a/apps/base-docs/base-learn/docs/frontend-setup/wallet-connectors.md b/apps/base-docs/base-learn/docs/frontend-setup/wallet-connectors.md index 2d39fe7396..3858bdbb12 100644 --- a/apps/base-docs/base-learn/docs/frontend-setup/wallet-connectors.md +++ b/apps/base-docs/base-learn/docs/frontend-setup/wallet-connectors.md @@ -63,7 +63,7 @@ The [Building an Onchain App] tutorial will show you how to do this! ### Coinbase Smart Wallet -If you have the Coinbase Wallet extension, you might be wondering were the smart wallet can be found. By default, the smart wallet will only be invoked if you click the `Coinbase Wallet` button to log in **and** you **don't** have the browser extension. To test, open a private window with extensions disabled and try to log in. +If you have the Coinbase Wallet extension, you might be wondering where the smart wallet can be found. By default, the smart wallet will only be invoked if you click the `Coinbase Wallet` button to log in **and** you **don't** have the browser extension. To test, open a private window with extensions disabled and try to log in. Selecting `Rainbow`, `MetaMask`, or `WalletConnect` will display a QR code so that the user can log in with their phone. Picking `Coinbase Wallet` will instead invoke the smart wallet login. diff --git a/apps/base-docs/docs/tokens/token-list.md b/apps/base-docs/docs/tokens/token-list.md index 66a306a5cd..e713dc1b21 100644 --- a/apps/base-docs/docs/tokens/token-list.md +++ b/apps/base-docs/docs/tokens/token-list.md @@ -14,13 +14,14 @@ keywords: Optimism Superchain, token deployment, add token to Base, + Superchain, ] hide_table_of_contents: true --- # The Base Token List -This page is intended for token issuers who already have an ERC-20 contract deployed on Ethereum and would like to submit their token for bridging between Ethereum and Base. Base uses the [Optimism Superchain token list](https://github.com/ethereum-optimism/ethereum-optimism.github.io) as a reference for tokens that have been deployed on Base. +This page is intended for token issuers who already have an ERC-20 contract deployed on Ethereum and would like to submit their token for bridging between Ethereum and Base. Base uses the [Superchain token list](https://github.com/ethereum-optimism/ethereum-optimism.github.io) as a reference for tokens that have been deployed on Base. **_Disclaimer: Base does not endorse any of the tokens that are listed in the Github repository and has conducted only preliminary checks, which include automated checks listed_** [**_here_**](https://github.com/ethereum-optimism/ethereum-optimism.github.io)**_._** @@ -36,7 +37,7 @@ Select your preferred bridging framework and use it to deploy an ERC-20 for your ### Step 2: Submit details for your token -Follow the instructions in the [GitHub repository](https://github.com/ethereum-optimism/ethereum-optimism.github.io) and submit a PR containing the required details for your token. You must specify in your token's data.json file a section for ‘base-sepolia' and/or ‘base’. The change you need to submit is particularly simple if your token has already been added to the Optimism token list. For example, [this PR](https://github.com/ethereum-optimism/ethereum-optimism.github.io/commit/27ab9b2d3388f7feba3a152e0a0748c73d732a68) shows the change required for cbETH, which was already on Optimism's token list and relies on the Base standard bridge. +Follow the instructions in the [GitHub repository](https://github.com/ethereum-optimism/ethereum-optimism.github.io) and submit a PR containing the required details for your token. You must specify in your token's data.json file a section for ‘base-sepolia' and/or ‘base’. The change you need to submit is particularly simple if your token has already been added to the Superchain token list. For example, [this PR](https://github.com/ethereum-optimism/ethereum-optimism.github.io/commit/27ab9b2d3388f7feba3a152e0a0748c73d732a68) shows the change required for cbETH, which was already on Optimism's token list and relies on the Base standard bridge. ### Step 3: Await final approval diff --git a/apps/base-docs/docs/tools/basenames-faq.md b/apps/base-docs/docs/tools/basenames-faq.md index 4cb513a9f6..8b0d74a30a 100644 --- a/apps/base-docs/docs/tools/basenames-faq.md +++ b/apps/base-docs/docs/tools/basenames-faq.md @@ -11,7 +11,7 @@ displayed_sidebar: null ### 1. What are Basenames? -Basenames are a core onchain building block that enable builders to establish their identity on Base by registering human-readable names for their wallet address(es). They are fully onchain, built on the same technology powering ENS names and deployed on Base. These human-readable names can be used when connecting to onchain apps, and sending and receiving on Base and any other EVM chain. +[Basenames](https://base.org/names) are a core onchain building block that enable builders to establish their identity on Base by registering human-readable names for their wallet address(es). They are fully onchain, built on the same technology powering ENS names and deployed on Base. These human-readable names can be used when connecting to onchain apps, and sending and receiving on Base and any other EVM chain. Get your Basename at [base.org/names](https://base.org/names). ### 2. What are the Basename registration fees? diff --git a/apps/base-docs/src/css/navbar.css b/apps/base-docs/src/css/navbar.css index b825e7e6e6..f5886c09f0 100644 --- a/apps/base-docs/src/css/navbar.css +++ b/apps/base-docs/src/css/navbar.css @@ -40,7 +40,8 @@ } .navbar__item, -.navbar__link { +.navbar__link, +.dropdown__link { padding: 0; font-family: CoinbaseMono; font-size: var(--title3-font-size); @@ -48,7 +49,8 @@ font-weight: var(--title3-font-weight); } -.navbar__link:hover { +.navbar__link:hover, +.dropdown__link:hover { color: inherit; text-decoration: underline; } @@ -76,14 +78,10 @@ background-color: rgb(var(--gray100)); } - .dropdown__link { color: rgb(var(--gray0)) !important; - font-size: var(--title3-font-size); - line-height: var(--title3-line-height); - font-weight: var(--title3-font-weight); - border-radius: 0px; } + .dropdown__link:hover { text-decoration: underline; } @@ -113,3 +111,15 @@ .navbar__link svg { display: none; } + +@media (max-width: 996px) { + .navbar-sidebar__items .navbar__item, + .navbar-sidebar__items .dropdown__link { + display: block !important; + color: var(--foreground-muted); + margin-top: 0.25rem; + font-size: var(--body-font-size); + line-height: var(--body-line-height); + font-weight: var(--body-font-weight); + } +} diff --git a/apps/base-docs/src/theme/Navbar/ColorModeToggle/index.js b/apps/base-docs/src/theme/Navbar/ColorModeToggle/index.js index a506863c68..f3e7217863 100644 --- a/apps/base-docs/src/theme/Navbar/ColorModeToggle/index.js +++ b/apps/base-docs/src/theme/Navbar/ColorModeToggle/index.js @@ -1,22 +1,42 @@ -import React from 'react'; -import {useColorMode, useThemeConfig} from '@docusaurus/theme-common'; +import React, { useCallback } from 'react'; +import { useColorMode, useThemeConfig } from '@docusaurus/theme-common'; import ColorModeToggle from '@theme/ColorModeToggle'; import styles from './styles.module.css'; -export default function NavbarColorModeToggle({className}) { +import logEvent, { + ActionType, + ComponentType, + AnalyticsEventImportance, +} from 'base-ui/utils/logEvent'; + +export default function NavbarColorModeToggle({ className }) { const navbarStyle = useThemeConfig().navbar.style; const disabled = useThemeConfig().colorMode.disableSwitch; - const {colorMode, setColorMode} = useColorMode(); + const { colorMode, setColorMode } = useColorMode(); + + const toggleSwitch = useCallback(() => { + const newColorMode = colorMode === 'dark' ? 'light' : 'dark'; + + logEvent( + `colormode_toggle_from_${colorMode}_to_${newColorMode}`, + { + action: ActionType.click, + componentType: ComponentType.icon, + context: 'base_docs_navbar', + }, + AnalyticsEventImportance.low, + ); + setColorMode(newColorMode); + }, [colorMode, setColorMode]); + if (disabled) { return null; } return ( ); } diff --git a/apps/base-docs/src/theme/Navbar/index.js b/apps/base-docs/src/theme/Navbar/index.js index b5f88d1b63..b2469f16bc 100644 --- a/apps/base-docs/src/theme/Navbar/index.js +++ b/apps/base-docs/src/theme/Navbar/index.js @@ -2,19 +2,11 @@ import React from 'react'; import NavbarLayout from '@theme/Navbar/Layout'; import NavbarContent from '@theme/Navbar/Content'; -import Banner from '../../components/Banner/Banner'; export default function Navbar() { return ( - <> - - - - - + + + ); } diff --git a/apps/base-docs/tutorials/docs/0_intro-to-providers.md b/apps/base-docs/tutorials/docs/0_intro-to-providers.md index 7ecc438eec..8a1ff4bddc 100644 --- a/apps/base-docs/tutorials/docs/0_intro-to-providers.md +++ b/apps/base-docs/tutorials/docs/0_intro-to-providers.md @@ -335,7 +335,7 @@ In this tutorial, you've learned how Providers supply blockchain connection as a [Subgraph]: https://thegraph.com/docs/en/developing/creating-a-subgraph/ [data for Base Sepolia here]: https://github.com/wagmi-dev/viem/blob/main/src/chains/definitions/baseSepolia.ts [Base]: https://docs.base.org/network-information -[Optimism]: https://community.optimism.io/docs/useful-tools/networks/ +[Optimism]: https://docs.optimism.io/chain/networks [EIP-1193]: https://eips.ethereum.org/EIPS/eip-1193 [QuickNode]: https://www.quicknode.com/ [Alchemy Costs]: https://docs.alchemy.com/reference/compute-unit-costs diff --git a/apps/base-docs/tutorials/docs/2_email-campaign-with-resend.md b/apps/base-docs/tutorials/docs/2_email-campaign-with-resend.md new file mode 100644 index 0000000000..47d0866ec3 --- /dev/null +++ b/apps/base-docs/tutorials/docs/2_email-campaign-with-resend.md @@ -0,0 +1,463 @@ +--- +title: Create Email Marketing Campaigns Onchain using Coinbase Smart Wallet + Resend +slug: /onchain-email-campaigns-using-resend +description: 'A tutorial that teaches how to create a mailing list and email customers using Resend' +author: hughescoin +keywords: ['build on base', 'viem', 'wagmi', 'frontend', 'onchain app development'] +tags: ['account abstraction'] +difficulty: beginner +displayed_sidebar: null +--- + +# Create email campaigns for smart wallets using Resend + +In today’s digital landscape, onchain interactions are becoming increasingly common, but email remains a powerful tool for personal and business communication. When a user logs into your application, capturing their onchain information is just the first step. To build lasting relationships and keep your users engaged, you need to connect with them where they’re most likely to respond—through their inbox. + +This tutorial will guide you through the process of seamlessly prompting users to join your company's mailing list after they sign up with your app. By the end, you'll be equipped to launch effective email campaigns that bridge the gap between onchain activity and offchain communication. + +## Prerequisites + +1. [Coinbase Developer Platform (CDP) Account](https://www.coinbase.com/cloud) + +You’ll need to set up an account on the Coinbase Developer Platform (CDP). The CDP provides various tools and services for blockchain development, including access to API endpoints and other resources that will be instrumental in your project. Once you’ve created your account, you’ll be ready to move forward with integrating these services into your application. + +2. [WalletConnect Project ID](https://cloud.walletconnect.com/) + +You’ll need to set up a cloud account with [WalletConnect], a protocol that enables secure wallet connections across different platforms. + +3. [Resend Account](https://resend.com) + +You’ll need to set up an account with [Resend], a service that allows you to send email campaigns programmatically. If you don’t already have an account, visit their website and sign up. Once your account is created, you’ll be able to generate API keys, which are essential for integrating Resend with your application. + +After creating your Resend account, navigate to the API keys page within your Resend dashboard. Here, you’ll generate a new API key that will be used to authenticate your requests when sending emails from your application. Make sure to store this key securely, as it will be required in later steps. + +![image-resend-api-page](../../assets/images/resend-email-campaigns/resend-api-keys.png) + +Once your WalletConnect account is set up, log in to obtain an API key. This key allows your application to interact with WalletConnect’s services. Navigate to the API keys section in your WalletConnect dashboard, generate a new key, and store it securely. + +With your WalletConnect API key in hand, it’s time to create a project within the WalletConnect Cloud. + +![image-wc-project-id](../../assets/images/resend-email-campaigns/wc-project-page.png) + +This project will house your integration settings and project-specific credentials. Go to the projects section of your WalletConnect dashboard and create a new project. After creating your WalletConnect project, you’ll be provided with a unique Project ID. Copy this Project ID and keep it handy, as you’ll need it for the upcoming integration steps. + +![cdp-home-onchainkit-api](../../assets/images/resend-email-campaigns/ock-dashboard.png) + +You will now set up your development environment. + +:::tip Integrating Resend to an existing project? + +If you’re planning to integrate Resend into an existing project, feel free to skip ahead to the backend section where we’ll create custom API routes for interacting with Resend. + +::: + +To begin, you’ll need to fork the [OnchainKit App template] from GitHub by clicking the green `Use this template` button. This template provides a solid foundation for building onchain applications and will be used as the base for our demo. + +![image-create-template](../../assets/images/resend-email-campaigns/ock-use-template.png) + +Once you’ve forked the repository, it’s time to clone it to your local machine. Open your terminal and run the following command, replacing the repository URL with the appropriate one if different: + +```bash +git clone git@github.com:/onchain-app-template.git resend-demo +``` + +This command clones the repository into a directory named `resend-demo`. + +After cloning the repository, navigate into the project directory using the following command: + +```bash +cd resend-demo +``` + +This will switch your terminal’s context to the project’s root directory, where you can begin working on the code. + +The OnchainKit template uses [Bun] as the package manager. If you don’t have Bun installed, you can install it by running the following command: + +```bash +# Install bun in case you don't have it +bun curl -fsSL | bash +``` + +:::tip Is Bun Installed? + +After installation, you may need to restart your terminal or run source ~/.bashrc (or ~/.zshrc) to ensure Bun is recognized as a command. + +::: + +Next, install the Resend package to handle email campaign functionality within your app: + +```bash +bun install resend +``` + +This command adds Resend as a dependency to your project, making it available for use in your application. + +In this templates, environment variables are stored in a `.env.example` file. You’ll need to rename this file to `.env` to ensure the environment variables are properly loaded. Run the following command: + +```bash +mv .env.local.example .env +``` + +With the `.env` file in place, open it in your preferred text editor and update it with your API keys and project IDs. These keys are essential for connecting to Resend, WalletConnect, and the Coinbase Developer Platform. + +Here’s an example of how your `.env` file should look: + +```bash +NEXT_CDP_API_KEY="YOUR_COINBASE_API_KEY" +NEXT_PUBLIC_WC_PROJECT_ID="YOUR_WALLET_CONNECT_PROJECT_ID" +RESEND_API_KEY="YOUR_RESEND_API_KEY" +RESEND_AUDIENCE_ID="YOUR_RESEND_AUDIENCE_ID" +``` + +:::note + +Make sure to replace the placeholder values (YOUR_COINBASE_API_KEY, etc.) with your actual keys. + +::: + +## Deploy template to Vercel + +To send emails from your application using Resend, you’ll need to deploy your project to a live environment. Vercel is a popular platform for deploying web applications, and it’s ideal for this purpose. By deploying your cloned repo on Vercel, you’ll obtain a live domain where your app can interact with Resend. + +If you don’t already have a Vercel account, head over to [Vercel’s website] and sign up. You can sign up using your GitHub account, which will make importing your project easier. + +Once logged in, go to the Projects section of your Vercel dashboard. Click on the `Add New` button to start the process of deploying a new project. + +In the next step, Vercel will prompt you to import a Git repository. Click on Import Git Repository and search for the OnchainKit app that you forked earlier. Select the repository to proceed. + +![image-of-vercel-project-add](../../assets/images/resend-email-campaigns/vercel-import-project.png) + +This step connects your GitHub (or other Git provider) account with Vercel, allowing Vercel to pull the code from your repository. + +:::note Private Repos + +If your project is private, you’ll see an option to Configure GitHub App. Click this button to give Vercel the necessary permissions to access your private repository. Follow the prompts to complete the authorization process. + +::: + +After Vercel has access to your repository, you’ll be guided through the final deployment steps. Vercel will automatically detect the settings for your project, but you may want to double-check that everything is correct, such as the project name and deployment settings. + +Environment Variables: Before completing the deployment, ensure that your environment variables (from the `.env` file) are correctly set up in Vercel. Vercel provides an interface to input these variables during the deployment process. Follow Vercel's guide for [adding environment variables]. + +After the build is complete, Vercel will provide you with a deployment URL. This URL is your live domain, where your application will be hosted. You can visit this URL to see your deployed site in action. + +![image-deployed-vercel](../../assets/images/resend-email-campaigns/site-load.png) + +## Set up logic and functionality + +Let's start by removing a few imports to clean our `page.tsx` file up. + +Start by removing `TransactionWrapper` and `WalletWrapper` imports and the code within the second `
` html element. + +Your `src/app/page.tsx` file should look like this: + +```typescript +'use client'; +import Footer from 'src/components/Footer'; +import { ONCHAINKIT_LINK } from 'src/links'; +import OnchainkitSvg from 'src/svg/OnchainkitSvg'; +import { useAccount } from 'wagmi'; +import LoginButton from '../components/LoginButton'; +import SignupButton from '../components/SignupButton'; + +export default function Page() { + const { address } = useAccount(); + + return ( +
+
+
+ + + +
+ + {!address && } +
+
+
+
+
+
+ ); +} +``` + +Use react hooks and create the following state variables + +``` +import { useState, useEffect } from 'react'; +``` + +`src/app/page.tsx:` + +```typescript title="src/app/page.tsx" +const [showForm, setShowForm] = useState(false); +const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '' }); +const [isSubscribed, setIsSubscribed] = useState(false); +const [isMember, setIsMember] = useState(false); +``` + +Add useEffect hook: Add this useEffect hook to update form visibility and member status based on wallet connection: + +```typescript title="src/app/page.tsx" +useEffect(() => { + if (address) { + setShowForm(true); + } else { + setShowForm(false); + setIsMember(false); + } +}, [address]); +``` + +Add form submission handler: Add the handleSubscribe function to handle form submissions: + +```typescript title="src/app/page.tsx" +const handleSubscribe = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const { firstName, email } = formData; + + // Send email using API + const emailResponse = await fetch('/api/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firstName, email }), + }); + const emailData = await emailResponse.json(); + if (!emailResponse.ok) throw new Error(emailData.error || 'Failed to send email'); + + // Create contact using API + const contactResponse = await fetch('/api/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firstName, email }), + }); + const contactData = await contactResponse.json(); + if (!contactResponse.ok) throw new Error(contactData.error || 'Failed to create contact'); + + console.log('Subscription successful:', { emailData, contactData }); + setIsSubscribed(true); + setIsMember(true); + + // Close the form after 3 seconds + setTimeout(() => { + setShowForm(false); + setIsSubscribed(false); + }, 3000); + } catch (error) { + console.error('Error subscribing:', error); + alert(error instanceof Error ? error.message : 'An unknown error occurred'); + } +}; +``` + +Add input change handler: Add the `handleChange` function to handle input changes: + +```typescript title="src/app/page.tsx" +const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); +}; +``` + +Add outside click handler: Add the handleOutsideClick function to close the form when clicking outside: + +```typescript title="src/app/page.tsx" +const handleOutsideClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + setShowForm(false); + } +}; +``` + +Create a component that will serve as the email template: + +`src/components/EmailTemplate.tsx: ` + +```typescript title="src/components/EmailTemplate.tsx" +import * as React from 'react'; + +interface EmailTemplateProps { + firstName: string; +} +export const EmailTemplate: React.FC> = ({ firstName }) => ( +
+

Welcome, {firstName}!

+
+); +``` + +In `src/app/page.tsx` add the following section to display wether the user is a member or not: + +```html +
+
+
+

+ {isMember ? "You're now a member." : "Welcome to Smart Wallet"} +

+

+ {isMember ? "Thank you for joining!" : "Demo app to showcase the Smart Wallet with Resend"} +

+
+
+
+``` + +In the same file (`src/app/page.tsx`) add the following logic to display the form after the last `section/>` element: + +```html +{ showForm && ( +
+
+ {isSubscribed ? ( +

Subscribed!

+ ) : ( +
+

Join our mailing list

+ {/* Form inputs */} +
+ + + +
+ +
+ )} +
+
+) } +``` + +Now, let's set up our API routes for creating contacts and sending emails. In your project's `app` directory, create a new folder called `api`. Inside this `api` folder, create two more folders: `create` and `send`. + +In the `create` folder, we'll create a file named `route.ts`. This route will handle creating a new contact using the Resend API. In the `send` folder, create another `route.ts` file. This route will be responsible for sending an email to the user, also using the Resend API. + +These two routes will work together to add a new subscriber to your list and send them a welcome email. + +`src/app/api/send/route.ts: ` + +```typescript title="src/app/api/send/route.ts" +import { EmailTemplate } from '../../../components/EmailTemplate'; +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function POST() { + try { + const { data, error } = await resend.emails.send({ + from: 'Acme ', + to: ['delivered@resend.dev'], + subject: 'Hello world', + react: EmailTemplate({ firstName: 'John' }), + }); + + if (error) { + return Response.json({ error }, { status: 500 }); + } + + return Response.json(data); + } catch (error) { + return Response.json({ error }, { status: 500 }); + } +} +``` + +`src/app/api/send/route.ts: ` + +```typescript title="src/app/api/send/route.ts" +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function POST(request: Request) { + try { + const { firstName, email } = await request.json(); + + const { data, error } = await resend.contacts.create({ + email, + firstName, + audienceId: process.env.RESEND_AUDIENCE_ID || '', + }); + + if (error) { + console.error('Contact creation error:', error); + return Response.json({ error: 'Failed to create contact' }, { status: 500 }); + } + + return Response.json({ success: true, data }); + } catch (error) { + console.error('Unexpected error:', error); + return Response.json({ error: 'An unexpected error occurred' }, { status: 500 }); + } +} +``` + +Now that everything is set up, it’s time to test your integration to ensure everything is working as expected. + +Start your development server by running the following command: + +```bash +bun run dev +``` + +Open your application in a web browser by navigating to `http://localhost:3000` or the port specified in your `package.json`. You can sign up or log in using your smart wallet. This will trigger the wallet connection process. + +After connecting your wallet, you should see a prompt to join the mailing list. Enter your name and email address in the form provided. + +![resend-](../../assets/images/resend-email-campaigns/resend-mailing-list-prompt.png) + +Once you’ve submitted the form, navigate to your [Resend Audience] dashboard. You should see a new contact with the name and email information you provided while testing on the development server. + +![resend-](../../assets/images/resend-email-campaigns/resend-user-subscribed.png) + +## Conclusion + +Congratulations! You've set up a seamless process to capture user emails after signing in with a Smart Wallet. You can better engage with your users more effectively and build stronger, lasting relationships. Keep exploring the potential of onchain apps and continue enhancing your user experience! + +--- + +[Basenames]: https://www.base.org/names/ +[OnchainKit]: https://onchainkit.xyz/ +[Wallet Connect]: https://cloud.walletconnect.com/sign-in +[OnchainKit App template]: https://github.com/coinbase/onchain-app-template +[Bun]: https://bun.sh/package-manager +[adding environment variables]: https://vercel.com/docs/projects/environment-variables +[Resend Audience]: https://resend.com/audiences/ diff --git a/apps/web/app/(base-org)/ecosystem/page.tsx b/apps/web/app/(base-org)/ecosystem/page.tsx index 867531cf5f..fa9016e6a0 100644 --- a/apps/web/app/(base-org)/ecosystem/page.tsx +++ b/apps/web/app/(base-org)/ecosystem/page.tsx @@ -2,7 +2,6 @@ import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button'; import EcosystemHeroLogos from 'apps/web/public/images/ecosystem-hero-logos-new.png'; import { Divider } from 'apps/web/src/components/Divider/Divider'; import type { Metadata } from 'next'; - import ImageAdaptive from 'apps/web/src/components/ImageAdaptive'; import Content from 'apps/web/src/components/Ecosystem/Content'; @@ -44,16 +43,12 @@ async function EcosystemHero() { ); } -export type EcosystemProps = { - searchParams: { tag?: string; search?: string; showCount: number }; -}; - -export default async function Ecosystem(page: EcosystemProps) { +export default async function Ecosystem() { return (
- +
); } diff --git a/apps/web/app/(base-org)/onchainsummer/page.tsx b/apps/web/app/(base-org)/onchainsummer/page.tsx deleted file mode 100644 index 56129dae8e..0000000000 --- a/apps/web/app/(base-org)/onchainsummer/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import AboutBlock from 'apps/web/src/components/OnchainSummer/AboutBlock'; -import CommunityEventsBlock from 'apps/web/src/components/OnchainSummer/CommunityEventsBlock'; -import EventsBlock from 'apps/web/src/components/OnchainSummer/EventsBlock'; -import Hero from 'apps/web/src/components/OnchainSummer/Hero'; -import ResourcesBlock from 'apps/web/src/components/OnchainSummer/ResourcesBlock'; -import RewardsBlock from 'apps/web/src/components/OnchainSummer/RewardsBlock'; -import SponsorsBlock from 'apps/web/src/components/OnchainSummer/SponsorsBlock'; -import ToolsBlock from 'apps/web/src/components/OnchainSummer/ToolsBlock'; -import type { Metadata } from 'next'; - -export const metadata: Metadata = { - metadataBase: new URL('https://base.org'), - title: `Onchain Summer | Buildathon`, - description: - 'Onchain Summer is back to unleash onchain creativity and invite everyone to build all summer long. Build, create, and get rewarded. June – August 2024.', - openGraph: { - title: `Onchain Summer | Buildathon`, - url: `/onchainsummer`, - description: - 'Onchain Summer is back to unleash onchain creativity and invite everyone to build all summer long. Build, create, and get rewarded. June – August 2024.', - }, -}; - -export default async function OnchainSummer() { - return ( -
-
- - - - - - - - -
-
- ); -} diff --git a/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts new file mode 100644 index 0000000000..68b4955057 --- /dev/null +++ b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts @@ -0,0 +1,63 @@ +import { pinata } from 'apps/web/src/utils/pinata'; +import { isDevelopment } from 'libs/base-ui/constants'; +import { NextResponse, NextRequest } from 'next/server'; + +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 async function POST(request: NextRequest) { + try { + // Rerrer validation + const requestUrl = new URL(request.url); + + // Username must be provided + const username = requestUrl.searchParams.get('username'); + if (!username) return NextResponse.json({ error: 'Invalid request' }, { status: 500 }); + + // Must have a referer + const referer = request.headers.get('referer'); + if (!referer) return NextResponse.json({ error: 'Invalid request' }, { status: 500 }); + + // referer can only be us + // TODO: Won't work on vercel previews + const refererUrl = new URL(referer); + const allowedReferersHost = isDevelopment ? 'localhost:3000' : 'www.base.org'; + if (allowedReferersHost !== refererUrl.host) { + return NextResponse.json({ error: 'Invalid request' }, { status: 500 }); + } + + const data = await request.formData(); + const file: File | null = data.get('file') as unknown as File; + + // Validation: file is present in request + if (!file) return NextResponse.json({ error: 'No file uploaded' }, { status: 500 }); + + // Validation: file is an image + if (!ALLOWED_IMAGE_TYPE.includes(file.type)) + return NextResponse.json({ error: 'Invalid file type' }, { status: 500 }); + + // Validation: file is less than 1mb + const bytes = file.size; + const bytesToMegaBytes = bytes / (1024 * 1024); + if (bytesToMegaBytes > MAX_IMAGE_SIZE_IN_MB) + return NextResponse.json({ error: 'File is too large' }, { status: 500 }); + + // Upload + const uploadData = await pinata.upload.file(file, { + groupId: '765ab5e4-0bc3-47bb-9d6a-35b308291009', + metadata: { + name: username, + }, + }); + return NextResponse.json(uploadData, { status: 200 }); + } catch (e) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/(basenames)/name/[username]/opengraph-image.tsx b/apps/web/app/(basenames)/name/[username]/opengraph-image.tsx index 92be26b175..bcd0ef107a 100644 --- a/apps/web/app/(basenames)/name/[username]/opengraph-image.tsx +++ b/apps/web/app/(basenames)/name/[username]/opengraph-image.tsx @@ -2,18 +2,16 @@ import { UsernameProfileProps } from 'apps/web/app/(basenames)/name/[username]/p import ImageRaw from 'apps/web/src/components/ImageRaw'; import { ImageResponse } from 'next/og'; import coverImageBackground from 'apps/web/app/(basenames)/name/[username]/coverImageBackground.png'; -import { namehash } from 'viem'; import { getBasenamePublicClient } from 'apps/web/src/hooks/useBasenameChain'; import { isDevelopment } from 'apps/web/src/constants'; -import L2ResolverAbi from 'apps/web/src/abis/L2Resolver'; import { formatBaseEthDomain, getBasenameImage, USERNAME_DOMAINS, - UsernameTextRecordKeys, } from 'apps/web/src/utils/usernames'; import { base, baseSepolia } from 'viem/chains'; import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames'; +import { CLOUDFARE_IPFS_PROXY } from 'apps/web/src/utils/urls'; export const runtime = 'edge'; const size = { @@ -31,20 +29,25 @@ export async function generateImageMetadata({ params }: UsernameProfileProps) { username = formatBaseEthDomain(username, base.id); } + // Remove funky char which breaks OG image path + const alphanumericRegex = /[^a-zA-Z0-9]/g; + const sanitizedId = username.replace(alphanumericRegex, ''); + return [ { alt: `Basenames | ${username}`, contentType: 'image/png', size, - id: username, + id: sanitizedId, }, ]; } -type ImageRouteProps = { id: string }; +type ImageRouteProps = { id: string; params: { username: string } }; export default async function OpenGraphImage(props: ImageRouteProps) { - let username = props.id; + // Decode emoji + let username = decodeURIComponent(props.params.username); if ( username && @@ -64,13 +67,13 @@ export default async function OpenGraphImage(props: ImageRouteProps) { // NOTE: Do we want to fail if the name doesn't exists? try { - const nameHash = namehash(username); const client = getBasenamePublicClient(base.id); - const avatar = await client.readContract({ - abi: L2ResolverAbi, - address: USERNAME_L2_RESOLVER_ADDRESSES[base.id], - args: [nameHash, UsernameTextRecordKeys.Avatar], - functionName: 'text', + const avatar = await client.getEnsAvatar({ + name: username, + universalResolverAddress: USERNAME_L2_RESOLVER_ADDRESSES[base.id], + assetGatewayUrls: { + ipfs: CLOUDFARE_IPFS_PROXY, + }, }); // Satori Doesn't support webp diff --git a/apps/web/next.config.js b/apps/web/next.config.js index deff8013c8..236818eb68 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -118,6 +118,7 @@ const contentSecurityPolicy = { 'https://translate.googleapis.com', // Let user translate our website 'https://sdk-api.neynar.com/', // Neymar API 'https://unpkg.com/@lottiefiles/dotlottie-web@0.31.1/dist/dotlottie-player.wasm', // lottie player + `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`, ], 'frame-ancestors': ["'self'", baseXYZDomains], 'form-action': ["'self'", baseXYZDomains], @@ -132,6 +133,7 @@ const contentSecurityPolicy = { 'https://ipfs.io', // ipfs ens avatar resolution 'https://cloudflare-ipfs.com', // ipfs Cloudfare ens avatar resolution 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // basename avatar upload to vercel blob + `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`, ], }; @@ -227,6 +229,11 @@ module.exports = extendBaseConfig( destination: '/onchainsummer', permanent: true, }, + { + source: '/onchainsummer', + destination: '/getstarted', + permanent: true, + }, { source: '/onchainfont', // just so the build doesn't fail in CI diff --git a/apps/web/package.json b/apps/web/package.json index 23414568e3..45e610a696 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.2", - "@rainbow-me/rainbowkit": "^2.1.3", + "@rainbow-me/rainbowkit": "^2.1.5", "@tanstack/react-query": "^5", "@types/jsonwebtoken": "^9.0.6", "@vercel/blob": "^0.23.4", @@ -47,6 +47,7 @@ "permissionless": "^0.1.41", "pg": "^8.12.0", "qrcode.react": "^3.1.0", + "pinata": "^0.4.0", "react": "^18.2.0", "react-blockies": "^1.4.1", "react-copy-to-clipboard": "^5.1.0", diff --git a/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx index efcc63ee5c..8119f167c0 100644 --- a/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx +++ b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx @@ -1,14 +1,13 @@ import satori from 'satori'; import { NextRequest } from 'next/server'; -import { getBasenameImage, UsernameTextRecordKeys } from 'apps/web/src/utils/usernames'; +import { getBasenameImage } from 'apps/web/src/utils/usernames'; import twemoji from 'twemoji'; import { base } from 'viem/chains'; -import { namehash } from 'viem'; import { getBasenamePublicClient } from 'apps/web/src/hooks/useBasenameChain'; -import L2ResolverAbi from 'apps/web/src/abis/L2Resolver'; import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames'; import { isDevelopment } from 'apps/web/src/constants'; import ImageRaw from 'apps/web/src/components/ImageRaw'; +import { CLOUDFARE_IPFS_PROXY } from 'apps/web/src/utils/urls'; const emojiCache: Record> = {}; export async function loadEmoji(emojiString: string) { @@ -44,13 +43,13 @@ export default async function handler(request: NextRequest) { // NOTE: Do we want to fail if the name doesn't exists? try { - const nameHash = namehash(username); const client = getBasenamePublicClient(chainId); - const avatar = await client.readContract({ - abi: L2ResolverAbi, - address: USERNAME_L2_RESOLVER_ADDRESSES[chainId], - args: [nameHash, UsernameTextRecordKeys.Avatar], - functionName: 'text', + const avatar = await client.getEnsAvatar({ + name: username, + universalResolverAddress: USERNAME_L2_RESOLVER_ADDRESSES[chainId], + assetGatewayUrls: { + ipfs: CLOUDFARE_IPFS_PROXY, + }, }); // Satori Doesn't support webp diff --git a/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts b/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts index 8cde2e4974..22b1fb58dd 100644 --- a/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts +++ b/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts @@ -1,6 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'apps/web/node_modules/next/dist/shared/lib/utils'; import { createPublicClient, http } from 'viem'; -import { base } from 'viem/chains'; import { REGISTER_CONTRACT_ABI, REGISTER_CONTRACT_ADDRESSES, @@ -9,6 +8,7 @@ import { import { weiToEth } from 'apps/web/src/utils/weiToEth'; import { formatWei } from 'apps/web/src/utils/formatWei'; import { logger } from 'apps/web/src/utils/logger'; +import { CHAIN } from 'apps/web/pages/api/basenames/frame/constants'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { name, years } = req.query; @@ -30,7 +30,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) async function getBasenameRegistrationPrice(name: string, years: number): Promise { const client = createPublicClient({ - chain: base, + chain: CHAIN, transport: http(), }); try { @@ -40,7 +40,7 @@ async function getBasenameRegistrationPrice(name: string, years: number): Promis } const price = await client.readContract({ - address: REGISTER_CONTRACT_ADDRESSES[base.id], + address: REGISTER_CONTRACT_ADDRESSES[CHAIN.id], abi: REGISTER_CONTRACT_ABI, functionName: 'registerPrice', args: [normalizedName, secondsInYears(years)], diff --git a/apps/web/pages/api/basenames/[name]/isNameAvailable.ts b/apps/web/pages/api/basenames/[name]/isNameAvailable.ts index 401d760865..7da37e45e5 100644 --- a/apps/web/pages/api/basenames/[name]/isNameAvailable.ts +++ b/apps/web/pages/api/basenames/[name]/isNameAvailable.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from 'apps/web/node_modules/next/dist/shared/lib/utils'; -import { base } from 'viem/chains'; import { getBasenameAvailable } from 'apps/web/src/utils/usernames'; +import { CHAIN } from 'apps/web/pages/api/basenames/frame/constants'; export type IsNameAvailableResponse = { nameIsAvailable: boolean; @@ -9,7 +9,7 @@ export type IsNameAvailableResponse = { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { name } = req.query; try { - const isNameAvailableResponse = await getBasenameAvailable(String(name), base); + const isNameAvailableResponse = await getBasenameAvailable(String(name), CHAIN); const responseData: IsNameAvailableResponse = { nameIsAvailable: isNameAvailableResponse, }; diff --git a/apps/web/pages/api/basenames/avatar/upload.ts b/apps/web/pages/api/basenames/avatar/upload.ts deleted file mode 100644 index 46d45072ef..0000000000 --- a/apps/web/pages/api/basenames/avatar/upload.ts +++ /dev/null @@ -1,51 +0,0 @@ -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(500).json({ error: (error as Error).message }); - } -} diff --git a/apps/web/pages/api/basenames/frame/04_txSuccess.ts b/apps/web/pages/api/basenames/frame/04_txSubmitted.ts similarity index 61% rename from apps/web/pages/api/basenames/frame/04_txSuccess.ts rename to apps/web/pages/api/basenames/frame/04_txSubmitted.ts index e2c60813fa..ff6d00b490 100644 --- a/apps/web/pages/api/basenames/frame/04_txSuccess.ts +++ b/apps/web/pages/api/basenames/frame/04_txSubmitted.ts @@ -1,7 +1,12 @@ import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; import { FrameRequest, getFrameMessage } from '@coinbase/onchainkit/frame'; -import { txSuccessFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; +import { getTransactionStatus } from 'apps/web/src/utils/frames/basenames'; +import { + txSucceededFrame, + txRevertedFrame, +} from 'apps/web/pages/api/basenames/frame/frameResponses'; import { NEYNAR_API_KEY } from 'apps/web/pages/api/basenames/frame/constants'; +import { CHAIN } from 'apps/web/pages/api/basenames/frame/constants'; import type { TxFrameStateType } from 'apps/web/pages/api/basenames/frame/tx'; if (!NEYNAR_API_KEY) { @@ -14,6 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const body = req.body as FrameRequest; + const transactionId: string | undefined = body?.untrustedData?.transactionId; let message; let isValid; let name; @@ -38,7 +44,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) throw new Error('No message state received'); } name = messageState.targetName; - return res.status(200).setHeader('Content-Type', 'text/html').send(txSuccessFrame(name)); + + if (!transactionId) { + throw new Error('transactionId is not valid'); + } + const txStatus = await getTransactionStatus(CHAIN, transactionId); + if (txStatus !== 'success') { + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send(txRevertedFrame(txStatus as string, transactionId)); + } + + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send(txSucceededFrame(name, transactionId)); } catch (e) { return res.status(500).json({ error: e }); } diff --git a/apps/web/pages/api/basenames/frame/assets/tx-failed.png b/apps/web/pages/api/basenames/frame/assets/tx-failed.png new file mode 100644 index 0000000000..92d6124a14 Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/tx-failed.png differ diff --git a/apps/web/pages/api/basenames/frame/assets/tx-submitted.png b/apps/web/pages/api/basenames/frame/assets/tx-submitted.png deleted file mode 100644 index 1ec7923e42..0000000000 Binary files a/apps/web/pages/api/basenames/frame/assets/tx-submitted.png and /dev/null differ diff --git a/apps/web/pages/api/basenames/frame/assets/tx-succeeded.png b/apps/web/pages/api/basenames/frame/assets/tx-succeeded.png new file mode 100644 index 0000000000..d963a7f12c Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/tx-succeeded.png differ diff --git a/apps/web/pages/api/basenames/frame/constants.ts b/apps/web/pages/api/basenames/frame/constants.ts index 9c148b28df..285472d229 100644 --- a/apps/web/pages/api/basenames/frame/constants.ts +++ b/apps/web/pages/api/basenames/frame/constants.ts @@ -1,4 +1,7 @@ import { isDevelopment } from 'apps/web/src/constants'; +import { base } from 'viem/chains'; export const DOMAIN = isDevelopment ? `http://localhost:3000` : 'https://www.base.org'; export const NEYNAR_API_KEY = process.env.NEXT_PUBLIC_NEYNAR_API_KEY; + +export const CHAIN = base; diff --git a/apps/web/pages/api/basenames/frame/frameResponses.ts b/apps/web/pages/api/basenames/frame/frameResponses.ts index 104e001680..cc62728fb5 100644 --- a/apps/web/pages/api/basenames/frame/frameResponses.ts +++ b/apps/web/pages/api/basenames/frame/frameResponses.ts @@ -2,7 +2,8 @@ import { getFrameMetadata, getFrameHtmlResponse } from '@coinbase/onchainkit/fra import { FrameMetadataResponse } from '@coinbase/onchainkit/frame/types'; import initialImage from 'apps/web/pages/api/basenames/frame/assets/initial-image.png'; import searchImage from 'apps/web/pages/api/basenames/frame/assets/search-image.png'; -import txSubmittedImage from 'apps/web/pages/api/basenames/frame/assets/tx-submitted.png'; +import txSucceededImage from 'apps/web/pages/api/basenames/frame/assets/tx-succeeded.png'; +import txFailedImage from 'apps/web/pages/api/basenames/frame/assets/tx-failed.png'; import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; export const initialFrame: FrameMetadataResponse = getFrameMetadata({ @@ -99,7 +100,7 @@ export const confirmationFrame = ( image: { src: `${DOMAIN}/api/basenames/frame/assets/registrationFrameImage.png?name=${formattedTargetName}&years=${targetYears}&priceInEth=${registrationPriceInEth}`, }, - postUrl: `${DOMAIN}/api/basenames/frame/04_txSuccess`, + postUrl: `${DOMAIN}/api/basenames/frame/04_txSubmitted`, state: { targetName, formattedTargetName, @@ -109,7 +110,7 @@ export const confirmationFrame = ( }, }); -export const txSuccessFrame = (name: string) => +export const txSucceededFrame = (name: string, transactionId: string) => getFrameHtmlResponse({ buttons: [ { @@ -117,8 +118,27 @@ export const txSuccessFrame = (name: string) => label: `Go to your profile`, target: `${DOMAIN}/name/${name}`, }, + { + action: 'link', + label: `View on block explorer`, + target: `https://basescan.org/tx/${transactionId}`, + }, + ], + image: { + src: `${DOMAIN}/${txSucceededImage.src}`, + }, + }); + +export const txRevertedFrame = (name: string, transactionId: string) => + getFrameHtmlResponse({ + buttons: [ + { + action: 'link', + label: `View on block explorer`, + target: `https://basescan.org/tx/${transactionId}`, + }, ], image: { - src: `${DOMAIN}/${txSubmittedImage.src}`, + src: `${DOMAIN}/${txFailedImage.src}`, }, }); diff --git a/apps/web/pages/api/basenames/frame/tx.ts b/apps/web/pages/api/basenames/frame/tx.ts index c8c6608bb4..6d92322efa 100644 --- a/apps/web/pages/api/basenames/frame/tx.ts +++ b/apps/web/pages/api/basenames/frame/tx.ts @@ -5,7 +5,6 @@ import { FrameTransactionResponse, } from '@coinbase/onchainkit/frame'; import { encodeFunctionData, namehash } from 'viem'; -import { base } from 'viem/chains'; import L2ResolverAbi from 'apps/web/src/abis/L2Resolver'; import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI'; import { formatBaseEthDomain } from 'apps/web/src/utils/usernames'; @@ -13,7 +12,7 @@ import { USERNAME_L2_RESOLVER_ADDRESSES, USERNAME_REGISTRAR_CONTROLLER_ADDRESSES, } from 'apps/web/src/addresses/usernames'; -import { NEYNAR_API_KEY } from 'apps/web/pages/api/basenames/frame/constants'; +import { CHAIN, NEYNAR_API_KEY } from 'apps/web/pages/api/basenames/frame/constants'; export type TxFrameStateType = { targetName: string; @@ -23,8 +22,8 @@ export type TxFrameStateType = { registrationPriceInEth: string; }; -const RESOLVER_ADDRESS = USERNAME_L2_RESOLVER_ADDRESSES[base.id]; -const REGISTRAR_CONTROLLER_ADDRESS = USERNAME_REGISTRAR_CONTROLLER_ADDRESSES[base.id]; +const RESOLVER_ADDRESS = USERNAME_L2_RESOLVER_ADDRESSES[CHAIN.id]; +const REGISTRAR_CONTROLLER_ADDRESS = USERNAME_REGISTRAR_CONTROLLER_ADDRESSES[CHAIN.id]; if (!NEYNAR_API_KEY) { throw new Error('missing NEYNAR_API_KEY'); @@ -77,13 +76,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const addressData = encodeFunctionData({ abi: L2ResolverAbi, functionName: 'setAddr', - args: [namehash(formatBaseEthDomain(name, base.id)), claimingAddress], + args: [namehash(formatBaseEthDomain(name, CHAIN.id)), claimingAddress], }); const nameData = encodeFunctionData({ abi: L2ResolverAbi, functionName: 'setName', - args: [namehash(formatBaseEthDomain(name, base.id)), formatBaseEthDomain(name, base.id)], + args: [namehash(formatBaseEthDomain(name, CHAIN.id)), formatBaseEthDomain(name, CHAIN.id)], }); const registerRequest = { @@ -103,7 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const txData: FrameTransactionResponse = { - chainId: `eip155:${base.id}`, + chainId: `eip155:${CHAIN.id}`, method: 'eth_sendTransaction', params: { abi: [ diff --git a/apps/web/pages/api/basenames/metadata/[tokenId].ts b/apps/web/pages/api/basenames/metadata/[tokenId].ts index c385a6c16e..c2fb0cef25 100644 --- a/apps/web/pages/api/basenames/metadata/[tokenId].ts +++ b/apps/web/pages/api/basenames/metadata/[tokenId].ts @@ -1,10 +1,15 @@ +import { BaseName } from '@coinbase/onchainkit/identity'; import { premintMapping } from 'apps/web/pages/api/basenames/metadata/premintsMapping'; import L2Resolver from 'apps/web/src/abis/L2Resolver'; import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames'; import { isDevelopment } from 'apps/web/src/constants'; import { getBasenamePublicClient } from 'apps/web/src/hooks/useBasenameChain'; import { logger } from 'apps/web/src/utils/logger'; -import { formatBaseEthDomain, USERNAME_DOMAINS } from 'apps/web/src/utils/usernames'; +import { + formatBaseEthDomain, + getBasenameNameExpires, + 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'; @@ -37,6 +42,7 @@ export default async function GET(request: Request) { ); let basenameFormatted = undefined; + let nameExpires = undefined; try { const client = getBasenamePublicClient(chainId); basenameFormatted = await client.readContract({ @@ -45,6 +51,7 @@ export default async function GET(request: Request) { args: [namehashNode], functionName: 'name', }); + nameExpires = await getBasenameNameExpires(basenameFormatted as BaseName); } catch (error) { logger.error('Error getting token metadata', error); } @@ -73,6 +80,8 @@ export default async function GET(request: Request) { // A human-readable description of the item. Markdown is supported. name: basenameFormatted, + nameExpires: Number(nameExpires), + // TODO: attributes? }; diff --git a/apps/web/pages/api/decorators.ts b/apps/web/pages/api/decorators.ts index 8bad938c36..7ed74de107 100644 --- a/apps/web/pages/api/decorators.ts +++ b/apps/web/pages/api/decorators.ts @@ -1,20 +1,8 @@ import { logger } from 'apps/web/src/utils/logger'; -import { measureExecutionTime } from 'apps/web/src/utils/metrics'; import { NextApiRequest, NextApiResponse } from 'next'; type NextApiHandler = (req: NextApiRequest, res: NextApiResponse) => void | Promise; -export const apiLatencyMetricsNamespace = 'baseorg.api.latency'; -export function withExecutionTime( - handler: NextApiHandler, - metricName: string, - tags: string[] = [], -): NextApiHandler { - return async (req: NextApiRequest, res: NextApiResponse) => { - await measureExecutionTime(metricName, async () => handler(req, res), tags); - }; -} - const defaultTimeout = process.env.DEFAULT_API_TIMEOUT ?? 5000; export function withTimeout( handler: NextApiHandler, diff --git a/apps/web/pages/api/proofs/cb1/index.ts b/apps/web/pages/api/proofs/cb1/index.ts index 5cfd084cfb..0e3bcb8af3 100644 --- a/apps/web/pages/api/proofs/cb1/index.ts +++ b/apps/web/pages/api/proofs/cb1/index.ts @@ -4,7 +4,6 @@ import { logger } from 'apps/web/src/utils/logger'; import { DiscountType, ProofsException, proofValidation } from 'apps/web/src/utils/proofs'; import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance'; import type { NextApiRequest, NextApiResponse } from 'next'; -import tracer from 'apps/web/tracer/tracer'; /** * This endpoint checks if the provided address has access to the cb1 attestation. @@ -35,8 +34,6 @@ import tracer from 'apps/web/tracer/tracer'; * } */ async function handler(req: NextApiRequest, res: NextApiResponse) { - // leave in for testing for now - tracer.dogstatsd.increment('proofs.cb1.endpoint.hit'); if (req.method !== 'GET') { return res.status(405).json({ error: 'method not allowed' }); } diff --git a/apps/web/pages/api/proofs/coinbase/index.ts b/apps/web/pages/api/proofs/coinbase/index.ts index 067cfd57dc..ec6374d0a0 100644 --- a/apps/web/pages/api/proofs/coinbase/index.ts +++ b/apps/web/pages/api/proofs/coinbase/index.ts @@ -10,7 +10,6 @@ import { import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance'; import type { NextApiRequest, NextApiResponse } from 'next'; import { Address } from 'viem'; -import tracer from 'apps/web/tracer/tracer'; // Coinbase verified account *and* CB1 structure export type CoinbaseProofResponse = { @@ -41,8 +40,6 @@ export type CoinbaseProofResponse = { * @returns */ async function handler(req: NextApiRequest, res: NextApiResponse) { - // leave in for testing for now - tracer.dogstatsd.increment('proofs.coinbase.endpoint.hit'); if (req.method !== 'GET') { return res.status(405).json({ error: 'method not allowed' }); } diff --git a/apps/web/public/images/partners/anglez.png b/apps/web/public/images/partners/anglez.png new file mode 100644 index 0000000000..44ac72d628 Binary files /dev/null and b/apps/web/public/images/partners/anglez.png differ diff --git a/apps/web/public/images/partners/conduit.png b/apps/web/public/images/partners/conduit.png new file mode 100644 index 0000000000..d7131f433d Binary files /dev/null and b/apps/web/public/images/partners/conduit.png differ diff --git a/apps/web/public/images/partners/fit-club.png b/apps/web/public/images/partners/fit-club.png new file mode 100644 index 0000000000..84e5339943 Binary files /dev/null and b/apps/web/public/images/partners/fit-club.png differ diff --git a/apps/web/public/images/partners/hug.png b/apps/web/public/images/partners/hug.png new file mode 100644 index 0000000000..cf062dec2d Binary files /dev/null and b/apps/web/public/images/partners/hug.png differ diff --git a/apps/web/public/images/partners/nftarot.png b/apps/web/public/images/partners/nftarot.png new file mode 100644 index 0000000000..71bfa57833 Binary files /dev/null and b/apps/web/public/images/partners/nftarot.png differ diff --git a/apps/web/public/images/partners/omnihub.png b/apps/web/public/images/partners/omnihub.png new file mode 100644 index 0000000000..5d62a06ce9 Binary files /dev/null and b/apps/web/public/images/partners/omnihub.png differ diff --git a/apps/web/public/images/partners/safary.png b/apps/web/public/images/partners/safary.png new file mode 100644 index 0000000000..99298525f4 Binary files /dev/null and b/apps/web/public/images/partners/safary.png differ diff --git a/apps/web/public/images/partners/shadow.png b/apps/web/public/images/partners/shadow.png new file mode 100644 index 0000000000..2dec0c3f2a Binary files /dev/null and b/apps/web/public/images/partners/shadow.png differ diff --git a/apps/web/public/images/partners/talent-protocol.png b/apps/web/public/images/partners/talent-protocol.png new file mode 100644 index 0000000000..a6791cafe3 Binary files /dev/null and b/apps/web/public/images/partners/talent-protocol.png differ diff --git a/apps/web/public/images/partners/thirdwave.png b/apps/web/public/images/partners/thirdwave.png new file mode 100644 index 0000000000..feb02798d7 Binary files /dev/null and b/apps/web/public/images/partners/thirdwave.png differ diff --git a/apps/web/public/images/partners/umbra.png b/apps/web/public/images/partners/umbra.png new file mode 100644 index 0000000000..fd32b675b7 Binary files /dev/null and b/apps/web/public/images/partners/umbra.png differ diff --git a/apps/web/public/images/partners/vela.png b/apps/web/public/images/partners/vela.png new file mode 100644 index 0000000000..b30dcc2ebe Binary files /dev/null and b/apps/web/public/images/partners/vela.png differ diff --git a/apps/web/src/components/Basenames/RegistrationFlow.tsx b/apps/web/src/components/Basenames/RegistrationFlow.tsx index 0ac5a13e8b..6954b9fd03 100644 --- a/apps/web/src/components/Basenames/RegistrationFlow.tsx +++ b/apps/web/src/components/Basenames/RegistrationFlow.tsx @@ -2,7 +2,6 @@ import dynamic from 'next/dynamic'; import { useLocalStorage } from 'usehooks-ts'; 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 { @@ -24,7 +23,6 @@ import { 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 { useCallback, useEffect, useMemo } from 'react'; import { useAccount, useSwitchChain } from 'wagmi'; @@ -44,7 +42,6 @@ export const claimQueryKey = 'claim'; export function RegistrationFlow() { const { chain } = useAccount(); - const { logEventWithContext } = useAnalytics(); const searchParams = useSearchParams(); const [, setIsModalOpen] = useLocalStorage('BasenamesLaunchModalVisible', true); const [, setIsBannerVisible] = useLocalStorage('basenamesLaunchBannerVisible', true); @@ -110,10 +107,6 @@ export function RegistrationFlow() { setRegistrationStep(RegistrationSteps.Search); }, [setRegistrationStep]); - useEffect(() => { - logEventWithContext('initial_render', ActionType.render); - }, [logEventWithContext]); - useEffect(() => { const claimQuery = searchParams?.get(claimQueryKey); if (claimQuery) { diff --git a/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx b/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx index 4c19d50dbb..ab12bef9eb 100644 --- a/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx +++ b/apps/web/src/components/Basenames/RegistrationSearchInput/index.tsx @@ -241,11 +241,17 @@ export default function RegistrationSearchInput({ }, [resetBackground, setSearchInputFocused]); useEffect(() => { - if (!invalidWithMessage) return; - - // Log invalid - logEventWithContext('search_available_name_invalid', ActionType.error, { error: message }); - }, [invalidWithMessage, logEventWithContext, message, setSearchInputFocused]); + if (debouncedSearch.length > 2 && invalidWithMessage) { + // Log invalid + logEventWithContext('search_available_name_invalid', ActionType.error, { error: message }); + } + }, [ + debouncedSearch.length, + invalidWithMessage, + logEventWithContext, + message, + setSearchInputFocused, + ]); const selectName = useCallback(() => { handleSelectName(debouncedSearch); diff --git a/apps/web/src/components/Basenames/RegistrationValueProp/assets/globeWhite.webm b/apps/web/src/components/Basenames/RegistrationValueProp/assets/globeWhite.webm index 1d35884d38..04d50621f8 100644 Binary files a/apps/web/src/components/Basenames/RegistrationValueProp/assets/globeWhite.webm and b/apps/web/src/components/Basenames/RegistrationValueProp/assets/globeWhite.webm differ diff --git a/apps/web/src/components/Basenames/UsernameProfile/index.tsx b/apps/web/src/components/Basenames/UsernameProfile/index.tsx index 4d275eab6a..b96ac34cad 100644 --- a/apps/web/src/components/Basenames/UsernameProfile/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfile/index.tsx @@ -2,19 +2,13 @@ import UsernameProfileContent from 'apps/web/src/components/Basenames/UsernameProfileContent'; import UsernameProfileSidebar from 'apps/web/src/components/Basenames/UsernameProfileSidebar'; -import { useAnalytics } from 'apps/web/contexts/Analytics'; -import { ActionType } from 'libs/base-ui/utils/logEvent'; import UsernameProfileSettings from 'apps/web/src/components/Basenames/UsernameProfileSettings'; import { useUsernameProfile } from 'apps/web/src/components/Basenames/UsernameProfileContext'; import UsernameProfileSettingsProvider from 'apps/web/src/components/Basenames/UsernameProfileSettingsContext'; export default function UsernameProfile() { - const { logEventWithContext } = useAnalytics(); - const { showProfileSettings } = useUsernameProfile(); - logEventWithContext('page_loaded', ActionType.render); - if (showProfileSettings) return ( diff --git a/apps/web/src/components/Basenames/UsernameProfileSectionBadges/hooks/useBaseGrant.ts b/apps/web/src/components/Basenames/UsernameProfileSectionBadges/hooks/useBaseGrant.ts new file mode 100644 index 0000000000..f1d89bcebf --- /dev/null +++ b/apps/web/src/components/Basenames/UsernameProfileSectionBadges/hooks/useBaseGrant.ts @@ -0,0 +1,18 @@ +import { useReadContract } from 'wagmi'; +import BuildathonSBT from 'apps/web/src/abis/BuildathonSBT'; + +const BASE_GRANT_NFT_ADDRESS = '0x1926a8090d558066ed26b6217e43d30493dc938e'; + +export default function useBaseGrant(address?: `0x${string}`): boolean { + const { data: balanceOf } = useReadContract({ + address: BASE_GRANT_NFT_ADDRESS, + abi: BuildathonSBT, + functionName: 'balanceOf', + args: [address ?? '0x'], + query: { + enabled: !!address, + }, + }); + + return balanceOf ? balanceOf > 0 : false; +} diff --git a/apps/web/src/components/Basenames/UsernameProfileSectionBadges/index.tsx b/apps/web/src/components/Basenames/UsernameProfileSectionBadges/index.tsx index 0ac7ef000b..6cdd78247e 100644 --- a/apps/web/src/components/Basenames/UsernameProfileSectionBadges/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileSectionBadges/index.tsx @@ -10,6 +10,7 @@ import { useCoinbaseVerification } from './hooks/useCoinbaseVerifications'; import { useTalentProtocol } from './hooks/useTalentProtocol'; import useBuildathonParticipant from './hooks/useBuildathon'; import { useMemo } from 'react'; +import useBaseGrant from 'apps/web/src/components/Basenames/UsernameProfileSectionBadges/hooks/useBaseGrant'; function BadgesLoop({ badges, @@ -70,6 +71,7 @@ function BuilderSection() { const { badges, empty } = useBaseGuild(profileAddress); const talentScore = useTalentProtocol(profileAddress); const { isParticipant, isWinner } = useBuildathonParticipant(profileAddress); + const isBaseGrantee = useBaseGrant(profileAddress); const combinedBadges = useMemo( () => ({ @@ -77,8 +79,9 @@ function BuilderSection() { TALENT_SCORE: talentScore, BUILDATHON_PARTICIPANT: isParticipant, BUILDATHON_WINNER: isWinner, + BASE_GRANTEE: isBaseGrantee, }), - [badges, talentScore, isParticipant, isWinner], + [badges, talentScore, isParticipant, isWinner, isBaseGrantee], ); const combinedEmpty = empty && !talentScore; diff --git a/apps/web/src/components/Basenames/UsernameProfileSettingsAvatar/index.tsx b/apps/web/src/components/Basenames/UsernameProfileSettingsAvatar/index.tsx index b9d245e89c..b119fc38dc 100644 --- a/apps/web/src/components/Basenames/UsernameProfileSettingsAvatar/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileSettingsAvatar/index.tsx @@ -2,7 +2,6 @@ import { ActionType } from 'libs/base-ui/utils/logEvent'; import { useCallback, useEffect, useState } from 'react'; -import { upload } from '@vercel/blob/client'; import { useAnalytics } from 'apps/web/contexts/Analytics'; import { useErrors } from 'apps/web/contexts/Errors'; import UsernameAvatarField from 'apps/web/src/components/Basenames/UsernameAvatarField'; @@ -11,6 +10,7 @@ import { Button, ButtonSizes, ButtonVariants } from 'apps/web/src/components/But import useWriteBaseEnsTextRecords from 'apps/web/src/hooks/useWriteBaseEnsTextRecords'; import { UsernameTextRecordKeys } from 'apps/web/src/utils/usernames'; import { Icon } from 'apps/web/src/components/Icon/Icon'; +import { PinResponse } from 'pinata'; export default function UsernameProfileSettingsAvatar() { const { profileUsername, profileAddress, currentWalletIsProfileEditor } = useUsernameProfile(); @@ -37,30 +37,37 @@ export default function UsernameProfileSettingsAvatar() { }, }); - const uploadAvatar = useCallback( + const uploadFile = useCallback( async (file: File | undefined) => { if (!file) return Promise.resolve(); - if (!currentWalletIsProfileEditor) return false; - - setAvatarIsLoading(true); - - logEventWithContext('avatar_upload_initiated', ActionType.change); - const timestamp = Date.now(); - const newBlob = await upload( - `basenames/avatar/${profileUsername}/${timestamp}/${file.name}`, - file, - { - access: 'public', - handleUploadUrl: `/api/basenames/avatar/upload?username=${profileUsername}`, - }, - ); - setAvatarIsLoading(false); - updateTextRecords(UsernameTextRecordKeys.Avatar, newBlob.url); - - return newBlob; + try { + setAvatarIsLoading(true); + const data = new FormData(); + data.set('file', file); + const uploadRequest = await fetch( + `/api/basenames/avatar/ipfsUpload?username=${profileUsername}`, + { + method: 'POST', + body: data, + }, + ); + + if (uploadRequest.ok) { + const uploadData = (await uploadRequest.json()) as PinResponse; + updateTextRecords(UsernameTextRecordKeys.Avatar, `ipfs://${uploadData.IpfsHash}`); + setAvatarIsLoading(false); + return uploadData; + } else { + alert(uploadRequest.statusText); + logError(uploadRequest, 'Failed to upload Avatar'); + setAvatarIsLoading(false); + } + } catch (e) { + alert('Trouble uploading file'); + } }, - [currentWalletIsProfileEditor, logEventWithContext, profileUsername, updateTextRecords], + [logError, profileUsername, updateTextRecords], ); const saveAvatar = useCallback(() => { @@ -79,7 +86,7 @@ export default function UsernameProfileSettingsAvatar() { if (!currentWalletIsProfileEditor) return false; if (avatarFile) { - uploadAvatar(avatarFile) + uploadFile(avatarFile) .then((result) => { // set the uploaded result as the url if (result) { @@ -98,7 +105,7 @@ export default function UsernameProfileSettingsAvatar() { [ currentWalletIsProfileEditor, avatarFile, - uploadAvatar, + uploadFile, logEventWithContext, logError, saveAvatar, diff --git a/apps/web/src/components/Basenames/UsernameProfileSettingsContext/index.tsx b/apps/web/src/components/Basenames/UsernameProfileSettingsContext/index.tsx index 40d8a8a486..9c89ab8c32 100644 --- a/apps/web/src/components/Basenames/UsernameProfileSettingsContext/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileSettingsContext/index.tsx @@ -17,20 +17,14 @@ import { export enum SettingsTabs { ManageProfile = 'manage-profile', Ownership = 'ownership', - Subdomain = 'subdomain', } export const settingTabsForDisplay = { [SettingsTabs.ManageProfile]: 'Manage Profile', [SettingsTabs.Ownership]: 'Ownership', - [SettingsTabs.Subdomain]: 'Subdomain', }; -export const allSettingsTabs = [ - SettingsTabs.ManageProfile, - SettingsTabs.Ownership, - SettingsTabs.Subdomain, -]; +export const allSettingsTabs = [SettingsTabs.ManageProfile, SettingsTabs.Ownership]; // Other features are not yet supported export const settingsTabsEnabled = [SettingsTabs.ManageProfile, SettingsTabs.Ownership]; diff --git a/apps/web/src/components/Ecosystem/Card.tsx b/apps/web/src/components/Ecosystem/Card.tsx index eeced2f7da..a49a46a1d3 100644 --- a/apps/web/src/components/Ecosystem/Card.tsx +++ b/apps/web/src/components/Ecosystem/Card.tsx @@ -1,3 +1,4 @@ +'use client'; import ImageWithLoading from 'apps/web/src/components/ImageWithLoading'; type Props = { @@ -12,7 +13,7 @@ function getNiceDomainDisplayFromUrl(url: string) { return url.replace('https://', '').replace('http://', '').replace('www.', '').split('/')[0]; } -export async function Card({ name, url, description, imageUrl, tags }: Props) { +export function Card({ name, url, description, imageUrl, tags }: Props) { return ( { const isTagged = selectedTag === 'all' || app.tags.includes(selectedTag); @@ -62,13 +58,16 @@ export default function Content({ searchParams }: EcosystemProps) {
{tags.map((tag) => ( - + ))}
- - - +
); diff --git a/apps/web/src/components/Ecosystem/List.tsx b/apps/web/src/components/Ecosystem/List.tsx index f64cbfd1fe..1a83bd5008 100644 --- a/apps/web/src/components/Ecosystem/List.tsx +++ b/apps/web/src/components/Ecosystem/List.tsx @@ -1,30 +1,35 @@ +'use client'; import ErrorImg from 'apps/web/public/images/error.png'; import { Button } from '../Button/Button'; import { Card } from './Card'; import { EcosystemApp } from 'apps/web/src/components/Ecosystem/Content'; -import Link from 'next/link'; -import { Url } from 'next/dist/shared/lib/router/router'; import ImageAdaptive from 'apps/web/src/components/ImageAdaptive'; +import { Dispatch, SetStateAction, useCallback } from 'react'; -export async function List({ +export function List({ selectedTag, searchText, apps, showCount, + setShowCount, }: { selectedTag: string; searchText: string; apps: EcosystemApp[]; showCount: number; + setShowCount: Dispatch>; }) { const canShowMore = showCount < apps.length; const showEmptyState = apps.length === 0; const truncatedApps = apps.slice(0, showCount); - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop - const tagHref: Url = { - pathname: '/ecosystem', - query: { tag: selectedTag, search: searchText, showCount: showCount + 16 }, - }; + + const onClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setShowCount(showCount + 16); + }, + [setShowCount, showCount], + ); return ( <> @@ -45,9 +50,7 @@ export async function List({ )} {canShowMore && (
- - - +
)} diff --git a/apps/web/src/components/Ecosystem/SearchBar.tsx b/apps/web/src/components/Ecosystem/SearchBar.tsx index e810d242fb..f45bc4e457 100644 --- a/apps/web/src/components/Ecosystem/SearchBar.tsx +++ b/apps/web/src/components/Ecosystem/SearchBar.tsx @@ -1,6 +1,5 @@ 'use client'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useRef } from 'react'; function SearchIcon() { return ( @@ -38,45 +37,28 @@ function XIcon() { ); } -const DEBOUNCE_LENGTH_MS = 300; - -export function SearchBar({ value }: { value: string }) { - const [text, setText] = useState(value); +export function SearchBar({ + search, + setSearch, +}: { + search: string; + setSearch: Dispatch>; +}) { const debounced = useRef(); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const tag = searchParams?.get('tag'); - - const updateRoute = useCallback( - (search: string) => { - const params = new URLSearchParams(searchParams?.toString()); - if (tag) params.set('tag', tag); - if (search) params.set('search', search); - if (!search) params.delete('search'); - router.push(pathname + '?' + params.toString(), { scroll: false }); - }, - [pathname, router, searchParams, tag], - ); const onChange = useCallback( (e: React.ChangeEvent) => { clearTimeout(debounced.current); - const val = e.target.value; - setText(val); - updateRoute(val); - - debounced.current = window.setTimeout(() => {}, DEBOUNCE_LENGTH_MS); + const value = e.target.value; + setSearch(value); }, - [updateRoute], + [setSearch], ); const clearInput = useCallback(() => { - setText(''); - - updateRoute(''); - }, [updateRoute]); + setSearch(''); + }, [setSearch]); return (
@@ -85,13 +67,13 @@ export function SearchBar({ value }: { value: string }) { - {text && ( + {search && ( diff --git a/apps/web/src/components/Ecosystem/TagChip.tsx b/apps/web/src/components/Ecosystem/TagChip.tsx index 9be10d9bce..da2a2b0c60 100644 --- a/apps/web/src/components/Ecosystem/TagChip.tsx +++ b/apps/web/src/components/Ecosystem/TagChip.tsx @@ -1,26 +1,31 @@ -import { Url } from 'next/dist/shared/lib/router/router'; -import Link from 'next/link'; +'use client'; + +import { Dispatch, SetStateAction, useCallback } from 'react'; type Props = { tag: string; isSelected: boolean; + setSelectedTag: Dispatch>; }; -export async function TagChip({ tag, isSelected }: Props) { - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop - const tagHref: Url = { - pathname: '/ecosystem', - query: { tag }, - }; +export function TagChip({ tag, isSelected, setSelectedTag }: Props) { + const onClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setSelectedTag(tag); + }, + [setSelectedTag, tag], + ); + return ( - -
- {tag} -
- + ); } diff --git a/apps/web/src/components/Features/Features.tsx b/apps/web/src/components/Features/Features.tsx index e1d0a0fec7..60ac290820 100644 --- a/apps/web/src/components/Features/Features.tsx +++ b/apps/web/src/components/Features/Features.tsx @@ -3,7 +3,7 @@ import { FeatureCard } from 'apps/web/src/components/Features/FeatureCard'; function OpenSourceDescription() { return (

- Base is built on Optimism’s open-source{' '} + Base is built on the Superchain's{' '} OP Stack diff --git a/apps/web/src/components/GetStarted/Hero.tsx b/apps/web/src/components/GetStarted/Hero.tsx index 455a45e3fd..0602207614 100644 --- a/apps/web/src/components/GetStarted/Hero.tsx +++ b/apps/web/src/components/GetStarted/Hero.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; import { StaticImport } from 'next/dist/shared/lib/get-img-props'; import gtcBackground from './images/gtc-background.svg'; -import ocsTitle from './images/onchain-summer.svg'; +import getStartedHeroImage from './images/gs_hero_img.webp'; const heroContainerClasses = ` w-full @@ -37,7 +37,7 @@ export default async function Hero() {

- onchain summer + onchain summer
diff --git a/apps/web/src/components/GetStarted/images/gs_hero_img.webp b/apps/web/src/components/GetStarted/images/gs_hero_img.webp new file mode 100644 index 0000000000..b7f00425ac Binary files /dev/null and b/apps/web/src/components/GetStarted/images/gs_hero_img.webp differ diff --git a/apps/web/src/components/GetStarted/images/onchain-summer.svg b/apps/web/src/components/GetStarted/images/onchain-summer.svg deleted file mode 100644 index 1eec2d3f8d..0000000000 --- a/apps/web/src/components/GetStarted/images/onchain-summer.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/web/src/components/Layout/Footer/Footer.tsx b/apps/web/src/components/Layout/Footer/Footer.tsx index c5926fbdbd..0b376257cb 100644 --- a/apps/web/src/components/Layout/Footer/Footer.tsx +++ b/apps/web/src/components/Layout/Footer/Footer.tsx @@ -28,13 +28,10 @@ export function Footer() {

- - Build on Base - - : Get in touch with our teams about your project. + + Builder Resource Kit + + : Get help to build and grow your project on Base.

diff --git a/apps/web/src/components/Layout/Nav/Nav.tsx b/apps/web/src/components/Layout/Nav/Nav.tsx index b68cb18cde..d345d6e7c0 100644 --- a/apps/web/src/components/Layout/Nav/Nav.tsx +++ b/apps/web/src/components/Layout/Nav/Nav.tsx @@ -1,7 +1,6 @@ 'use client'; import Link from 'next/link'; -import dynamic from 'next/dynamic'; import { usePathname } from 'next/dist/client/components/navigation'; import AnalyticsProvider from 'apps/web/contexts/Analytics'; import DesktopNav from './DesktopNav'; @@ -10,10 +9,6 @@ import logoBlack from './logoBlack.svg'; import logoWhite from './logoWhite.svg'; import Image, { StaticImageData } from 'next/image'; -const DynamicBanner = dynamic(async () => import('base-ui/components/Layout/Nav/Banner'), { - ssr: false, -}); - const BLACK_NAV_PATHS = [ '/', '/jobs/apply', @@ -29,11 +24,6 @@ export default function Nav() { const logo = color === 'black' ? (logoBlack as StaticImageData) : (logoWhite as StaticImageData); return ( -