About the tutorial
In my previous tutorial, we learnt how to create an RSVP dApp. We created the smart contract and the subgraph to index our smart contract. In this tutorial, we will create a front-end that implements our previous work. If you haven’t read the former tutorial, please take a look at it here before you start with this
Prerequisites
- Celo Composer: While setting up Celo-Composer, we selected React as our front-end framework so we will be writing in React specifically Next JS.
- Web3Storage: Web3storage provides services like data storage designed to natively support protocols such as IPFS. We will be storing files here. You need to create an account on Web3storage and generate an api token. Their docs can be found here
Starter Guide
For a starter guide, you can check out the codebase here
Demo
You can find the demo of this tutorial here
Getting Started
Install the following dependencies to begin
npm i @headlessui/react
npm i react-icons/fa
npm i @apollo/client
npm i web3.storage
Create a utility folder that contains several helper files such as the file that lets us connect to our contract, timestamp formatter and our contract abi.
Next, let’s create our components. In the react-app/component folder, you can have the following extra components
- Alert
- Dashboard
- DashboardNav
- EventCard
- Navbar
- NavMenu
Our Alert.tsx will handle how alerts will be displayed. Here is our code
import { useState, Fragment } from "react";
import { Transition } from "@headlessui/react";
import { FaTimesCircle, FaExclamation, FaSmile } from "react-icons/fa";
export default function Alert({
alertType,
alertBody,
triggerAlert,
color,
}: {
alertType: any;
alertBody: any;
triggerAlert: any;
color: any;
}) {
const [showAlert, setShowAlert] = useState(triggerAlert);
return (
<Transition
show={showAlert}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:-translate-x-2/4"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="alert z-50 max-w-lg p-3 w-full shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden"
style={{ backgroundColor: color }}
>
<div className="flex">
<div className="flex-shrink-0">
{alertType === "success" ? (
<FaSmile className="h-5 w-5" />
) : (
<FaExclamation className="h-5 w-5" />
)}
</div>
<div className="ml-2">
<p className="text-sm font-medium text-gray-900">{alertBody}</p>
</div>
<div className="ml-auto pl-3">
<div className="mx-3.5 -my-1.5">
<button
type="button"
className="inline-flex rounded-md p-1.5 text-gray-900 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-50 focus:ring-gray-600"
>
<span className="sr-only">Dismiss</span>
<FaTimesCircle
className="h-5 w-5"
aria-hidden="true"
onClick={() => {
setShowAlert(!showAlert);
}}
/>
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}
Our Dashboard.tsx is our dashboard component. The code is as follows
import Head from "next/head";
import { useRouter } from "next/router";
import joinClassNames from "../utils/joinClassNames";
import DashboardNav from "./DashboardNav";
interface DashboardProps {
page: "events" | "rsvps";
isUpcoming: boolean;
children: React.ReactNode;
}
export default function Dashboard({ page, isUpcoming, children }: DashboardProps) {
const router = useRouter();
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
e.preventDefault();
const name = e.target.value;
const href = tabs.find((tab) => tab.name === name)?.href;
if (href) router.push(href);
};
let tabs = [
{
name: "Upcoming",
href: `/my-${page}/upcoming`,
current: isUpcoming,
},
{
name: "Past",
href: `/my-${page}/past`,
current: !isUpcoming,
},
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Head>
<title>My Dashboard | web3rsvp</title>
<meta name="description" content="Manage your events and RSVPs" />
</Head>
<div className="flex flex-wrap py-8">
<DashboardNav page={page} />
<div className="sm:w-10/12 sm:pl-8">
<h1 className="text-2xl tracking-tight font-extrabold text-gray-900 sm:text-3xl md:text-4xl mb-4">
{page === "events" ? "My Events" : "My RSVPs"}
</h1>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a tab
</label>
<select
id="tabs"
name="tabs"
className="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
defaultValue={tabs.find((tab) => tab.current)?.name}
onChange={handleChange}
>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>
))}
</select>
</div>
<div className="hidden sm:block">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={joinClassNames(
tab.current
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
)}
aria-current={tab.current ? "page" : undefined}
>
{tab.name}
</a>
))}
</nav>
</div>
</div>
<section className="py-8">{children}</section>
</div>
</div>
</div>
);
}
Our DashboardNav.tsx is our dashboard navigation component where an address will be able to find the events they created/ the events they rsvp’d to. Here is the code
import joinClassNames from "../utils/joinClassNames";
interface NavigationItem {
name: string;
href: string;
current: boolean;
}
interface Props {
page: string;
}
export default function DashboardNav({ page }: Props) {
let navigation: NavigationItem[] = [
{
name: "My Events",
href: `/my-events/upcoming`,
current: page == "events",
},
{
name: "My RSVPs",
href: `/my-rsvps/upcoming`,
current: page == "rsvps",
},
];
return (
<nav className="space-y-1 w-60 mb-8 sm:w-2/12" aria-label="Sidebar">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={joinClassNames(
item.current
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
"flex items-center px-3 py-2 text-sm font-medium rounded-md"
)}
aria-current={item.current ? "page" : undefined}
>
<span className="truncate">{item.name}</span>
</a>
))}
</nav>
);
}
Our EventsCard.tsx is the component for the created events that will be displayed in our homepage.
import Link from "next/link";
import Image from "next/image";
import formatTimestamp from "../utils/formatTimestamp";
interface Props {
id: string;
name: string;
eventTimestamp: number;
imageURL?: string;
}
export default function EventCard({
id,
name,
eventTimestamp,
imageURL,
}: Props) {
return (
<div className="group relative clickable-card rounded-lg focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-offset-gray-100 focus-within:ring-indigo-500">
<Link href={`/event/${id}`} className="clickable-card__link">
<div className="block w-full aspect-w-10 aspect-h-7 rounded-lg bg-gray-100 overflow-hidden relative group-hover:opacity-75">
{imageURL && (
<Image src={imageURL} alt="event image" width={500} height={500} />
)}
</div>
</Link>
<p className="mt-2 block text-sm text-gray-500">
{formatTimestamp(eventTimestamp)}
</p>
<p className="block text-base font-medium text-gray-900">{name}</p>
</div>
);
}
Our NavBar.tsx is an extra header to help the navigation process easier. The code is as follows
import { useState, useEffect } from "react";
import Link from "next/link";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useAccount, useDisconnect } from "wagmi";
import Navmenu from "./Navmenu";
export default function Navbar() {
const { address } = useAccount();
const { disconnect } = useDisconnect();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
mounted && (
<header className="bg-white border-b-2 border-gray-100">
<nav
className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"
aria-label="Top"
>
<div className="w-full py-6 flex flex-wrap items-center justify-between border-b border-yellow-500 lg:border-none">
<div className="flex items-center"></div>
<div className="ml-10 space-x-4 flex items-center">
{address ? (
<Navmenu address={address} disconnect={() => disconnect()} />
) : (
<ConnectButton />
)}
</div>
</div>
</nav>
</header>
)
);
}
Our NavMenu just contains the menu items that will be shown in the NavBar
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import joinClassNames from "../utils/joinClassNames";
export default function Navmenu({
address,
disconnect,
}: {
address: any;
disconnect: any;
}) {
return (
<Menu as="div" className="relative z-10 inline-block text-left">
<div>
<Menu.Button className="inline-flex items-center px-2.5 py-2 rounded-md text-sm font-medium bg-indigo-100 text-indigo-800 w-32 cursor-pointer">
<span className="w-12 h-3 mr-1 bg-indigo-400 rounded-full"></span>
<p className="text-ellipsis overflow-hidden">{address}</p>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<a
href={`/my-rsvps/upcoming`}
className={joinClassNames(
address ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm"
)}
>
My RSVPs
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={`/my-events/upcoming`}
className={joinClassNames(
address ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm"
)}
>
My Events
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active
}) => (
<a
onClick={disconnect}
className={joinClassNames(
address ? "bg-gray-100 text-gray-900" : "text-gray-700",
"block px-4 py-2 text-sm cursor-pointer"
)}
>
Log Out
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}
We will be using apollo client to query our subgraph so create an apollo-client.js file in the root folder of react-app. Paste the following code
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: "https://api.thegraph.com/subgraphs/name/amoweolubusayo/rsvp",
cache: new InMemoryCache(),
});
export default client;
In the uri, replace with the query of your subgraph. You will find this in your dashboard.
Now go ahead to edit your _app.tsx
file by wrapping ApolloProvider
around the Layout.
Write your imports above
import { ApolloProvider } from "@apollo/client";
import client from "../apollo-client";
And do the wrapping here
function App({ Component, pageProps }: AppProps) {
return (
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains} coolMode={true}>
<ApolloProvider client={client}>
<Layout>
<Component {...pageProps} />
</Layout>
</ApolloProvider>
</RainbowKitProvider>
</WagmiConfig>
);
}
We will also create an api
folder that will hold couple of functions we will be calling from some of the pages we will call be creating shortly. You can create this inside of the pages folder. Inside of the api
folder, create a save-event-details.js file and write the following code
import { Web3Storage, File, getFilesFromPath } from "web3.storage";
const { resolve } = require("path");
export default async function handler(req, res) {
if (req.method === "POST") {
return await storeEventData(req, res);
} else {
return res
.status(405)
.json({ message: "Method not allowed", success: false });
}
}
async function storeEventData(req, res) {
const body = req.body;
try {
const files = await makeFileObjects(body);
const cid = await storeFiles(files);
return res.status(200).json({ success: true, cid: cid });
} catch (err) {
return res
.status(500)
.json({ error: "Error creating event", success: false });
}
}
async function storeFiles(files) {
const client = makeStorageClient();
const cid = await client.put(files);
return cid;
}
async function makeFileObjects(body) {
const buffer = Buffer.from(JSON.stringify(body));
const imageDirectory = resolve(process.cwd(), `public/images/${body.image}`);
const files = await getFilesFromPath(imageDirectory);
files.push(new File([buffer], "data.json"));
return files;
}
function makeStorageClient() {
return new Web3Storage({ token: process.env.WEB3STORAGE_TOKEN });
}
WEB3STORAGE_TOKEN is your web3storage api token that you would have created earlier
Inside the pages folder, find the index.tsx file and edit the code to have the following
import { useState } from "react";
import { gql, useQuery } from "@apollo/client";
import Landing from "../components/Landing";
import EventCard from "../components/EventCard";
const UPCOMING_EVENTS = gql`
query Events($currentTimestamp: String) {
events(where: { eventTimestamp_gt: $currentTimestamp }) {
id
name
eventTimestamp
imageURL
}
}
`;
export default function Home() {
const [currentTimestamp, setEventTimestamp] = useState(
new Date().getTime().toString()
);
const { loading, error, data } = useQuery(UPCOMING_EVENTS, {
variables: { currentTimestamp },
});
return (
<div>
<div className="h1">
<Landing>
<ul
role="list"
className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8"
>
{data &&
data.events.map((event: any) => (
<li key={event.id}>
<EventCard
id={event.id}
name={event.name}
eventTimestamp={event.eventTimestamp}
imageURL={event.imageURL}
/>
</li>
))}
</ul>
</Landing>
</div>
</div>
)
}