Frontend: Build a Fullstack DAPP  on NEAR using Rust, Near-API-Js,  React & Chakra-UI

Frontend: Build a Fullstack DAPP on NEAR using Rust, Near-API-Js, React & Chakra-UI

Introduction

This is the second part of building a fullstack decentralized application on the NEAR blockchain. To follow along, find the first part here.

In this section, we will create the frontend of our application and interact with the smart contract that has already been deployed. To see the folder structure, navigate to the /frontend directory.

Frontend Tech Stack

  • React

  • Chakra-UI

  • date-fns

  • react-time-go

Install Dependencies

To begin, copy and paste the following code into the root directory of your terminal to install the dependencies we need:

yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion react-time-go@7.1.1 javascript-time-ago date-fns

Replace the entire code in frontend/assets/css/global.css with this block of code below:

body{
  background-color: rgb(23, 60, 64) !important;
}

Next, we navigate into frontend/assets/js/near/utils, look for the function initContract you'll find the contract's view and change methods, add the functions we created in our smart contract to their respective methods using the code block below:

 ...

 window.contract = await new Contract(window.walletConnection.account(), nearConfig.contractName, {
    // View methods are read only. They don't modify the state, but usually return some value.
    viewMethods: ['fetch_quotes', 'total_quotes'],
    // Change methods can modify the state. But you don't receive the returned value when called.
    changeMethods: ['create_quote', 'tip_author', 'delete_quote'],
  })

....

Implement Contract Functions

In the same directory frontend/assets/js/near/utils, replace the entire file with the below code block:

import { connect, Contract, keyStores, WalletConnection } from 'near-api-js'
import getConfig from './config'
import { parseNearAmount } from "near-api-js/lib/utils/format";
const nearConfig = getConfig('testnet')

const GAS = 100000000000000; // gas fee

export async function initContract() {
  const near = await connect(Object.assign({ deps: { keyStore: new keyStores.BrowserLocalStorageKeyStore() } }, nearConfig))

  window.walletConnection = new WalletConnection(near)

  window.accountId = window.walletConnection.getAccountId()

  // Initializing our contract APIs by contract name and configuration
  window.contract = await new Contract(window.walletConnection.account(), nearConfig.contractName, {
    // View methods are read only. They don't modify the state, but usually return some value.
    viewMethods: ['fetch_quotes', 'total_quotes'],
    // Change methods can modify the state. But you don't receive the returned value when called.
    changeMethods: ['create_quote', 'tip_author', 'delete_quote'],
  })
}

export function logout() {
  window.walletConnection.signOut()
  window.location.replace(window.location.origin + window.location.pathname)
}

export function login() {
  window.walletConnection.requestSignIn(nearConfig.contractName)
}

export async function fetch_quotes(from_index, limit){
  let quotes = await window.contract.fetch_quotes({
    args:{from_index, limit}
  })
  return quotes
}

export async function total_quotes(){
  let quote_count = await window.contract.total_quotes()
  return quote_count
}


export async function create_quote(payload){
  let create_quote = await window.contract.create_quote(payload, GAS)
  return create_quote
}

export async function tip_author(id){
  let tip = await window.contract.tip_author(id, GAS, parseNearAmount(1 + "") )
  return tip
}

export async function delete_quote(id){
  let delete_quote = await window.contract.delete_quote(id, GAS)
  return delete_quote
}

Each function created in the above code block extends the functions defined in our deployed smart contract.

Configure Application

To include Chakra UI in your app, navigate to /frontend/index.js and wrap it around the App component. To add other required libraries, replace the entire code there with the code block below:

import React from "react";
import { createRoot } from "react-dom/client";
import { ChakraProvider } from "@chakra-ui/react";
import App from "./App";
import { initContract } from "./assets/js/near/utils";
import JavascriptTimeAgo from "javascript-time-ago";
import "./assets/css/global.css";


// The desired locales.
import en from "javascript-time-ago/locale/en";
import ru from "javascript-time-ago/locale/ru";

JavascriptTimeAgo.locale(en);
JavascriptTimeAgo.locale(ru);

const container = document.querySelector("#root");
const root = createRoot(container); // createRoot(container!) if you use TypeScript

window.nearInitPromise = initContract()
  .then(() => {
    <App />;
    root.render(
      <ChakraProvider>
        <App tab="home" />
      </ChakraProvider>
    );
  })
  .catch(console.error);

In the above code block, initContract is imported, frontend/assets/js/near/utils which is used to initialize your deployed smart contract.

Create a new folder utils in the frontend directory with a new file index.js, frontend/utils/index.js to handle dates from each quote.

import React from "react";
import ReactTimeAgo from "react-time-ago";
import { format } from "date-fns";

function LastSeen({ date }) {
  return <ReactTimeAgo date={date} locale="en-US" />;
}

export default function DateUtil(data) {
  const nano = data / 1000000;

  const oneday = new Date() - 60 * 60 * 24 * 1000;
  const oneData = new Date(oneday);

  if (new Date(nano).toISOString() < oneData.toISOString()) {
    return format(new Date(nano), "dd MMM yyyy");
  } else {
    return <LastSeen date={nano} />;
  }
}

The timestamp used by NEAR is in nanoseconds, we must convert it to a readable time format. To make the created_at field on a quote readable.

Create Components

Create a new folder frontend/components in the frontend directory. We will add other components needed.

Login

Create a new folder Login within the components and add a new file index.js to it, frontend/components/Login/index.js

import { Box, Button, Heading, Image } from "@chakra-ui/react";
import React from "react";

export default function Login({ login }) {
  return (
    <Box
      display="flex"
      flexDirection="column"
      justifyContent="center"
      alignItems="center"
      maxWidth="1440px"
      h="100vh"
      m="auto"
    >
      <Heading color="#fff" fontSize="72px">
        NEAR Quote
      </Heading>
      <Image
        src="https://www.pngall.com/wp-content/uploads/13/NFT-Art-PNG.png"
        w="auto"
        h="auto"
        my="8rem"
      />
      <Button onClick={login} color="#fff" bg="green" w="200px">
        Connect
      </Button>
    </Box>
  );
}

Nav

Create a new folder Nav within the components and add a new file index.js to it, frontend/components/Nav/index.js.

import { Button, Flex, Text } from "@chakra-ui/react";
import React from "react";

export default function Nav({ account, onClick }) {
  return (
    <Flex bg="black" w="full" p={6} justifyContent="center" alignItems="center">
      <Text flex={1} color="#fff" fontWeight="700" fontSize={{base: '20px', md:"30px"}} whiteSpace="nowrap">
        NEAR <Text display={{base: 'none', md: "inline-block"}}>Quote</Text>
      </Text>
      <Text color="#fff" fontWeight="600" mx={6}>
        {account}
      </Text>
      <Button className="link" bg="red" color="#fff" whiteSpace="nowrap" onClick={onClick}>
        Disconnect
      </Button>
    </Flex>
  );
}

Add Quote

Create a new folder AddQuote within the components and add a new file index.js to it, frontend/components/AddQuote/index.js.

import { Box, Button, FormControl, FormHelperText, Text, Textarea } from "@chakra-ui/react";
import React from "react";

export default function AddQuote({quote, totalQuotes, onChange, loading, createQuote }) {
  return (
    <>
      <Text fontSize="28px" fontWeight="600" mb={4} color="#fff">
        Total Quotes: {totalQuotes}
      </Text>
      <Box display="flex" flexDirection="column">
        <FormControl>
          <Textarea
            type="text"
            name="quote"
            value={quote}
            onChange={onChange}
            errorBorderColor="crimson"
            placeholder="Enter Quote"
            color="#fff"
            borderColor="#717b7c"
          />
          {quote.length < 6 && (
            <FormHelperText color="red">Enter more words</FormHelperText>
          )}
        </FormControl>
        <Button
          isDisabled={quote.length < 6 && true}
          isLoading={loading}
          onClick={createQuote}
          bg="green"
          color="#fff"
          my={4}
        >
          Add
        </Button>
      </Box>
    </>
  );
}

Card

Create a new folder Card within the components and add a new file index.js to it, frontend/components/Card/index.js.

import {
  Badge,
  Box,
  Button,
  Card,
  CardBody,
  CardFooter,
  Image,
  Stack,
  Text,
} from "@chakra-ui/react";
import React from "react";
import DateUtil from "../../utils";
import DeleteModal from "../Modal";

export default function CardContainer({
  author,
  item,
  loading,
  tipAuthor,
  deleteQuote,
  refresh,
}) {
  return (
    <Card
      direction={{ base: "column", sm: "row" }}
      overflow="hidden"
      variant="outline"
      my={6}
      border="none"
      bg="blackAlpha.600"
      boxShadow="xl"
    >
      <Image
        objectFit="cover"
        maxW={{ base: "100%", sm: "200px" }}
        src="https://images.unsplash.com/photo-1667489022797-ab608913feeb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHw5fHx8ZW58MHx8fHw%3D&auto=format&fit=crop&w=800&q=60"
        alt="Caffe Latte"
      />

      <Stack w="full">
        <CardBody>
          <Text fontSize="18px" fontWeight="500" py="1" color="#fff">
            {item.author}
          </Text>
          <Text fontSize="14px" fontWeight="500" color="grey">
            {DateUtil(item.created_at)}
          </Text>
          <Text
            fontSize="20px"
            fontWeight="500"
            textTransform="capitalize"
            py="2"
            color="#fff"
          >
            {item.text}
          </Text>
        </CardBody>

        <CardFooter
          display="flex"
          justifyContent="flex-end"
          alignItems="center"
          gap={4}
        >
          <Box flex={1}>
            <Badge variant="solid" colorScheme="green">
              {item.tip_count} Tips
            </Badge>
          </Box>

          {author === item.author ? (
            <DeleteModal
              text={item.text}
              deleteQuote={deleteQuote}
              isDisabled={loading === true ? true : false}
              isLoading={loading}
              refresh={refresh}
            />
          ) : (
            <Button onClick={tipAuthor} bg="blue" color="#fff">
              Tip 1NEAR
            </Button>
          )}
        </CardFooter>
      </Stack>
    </Card>
  );
}

Modal

Create a new folder Modal within the components and add a new file index.js to it, frontend/components/Modal/index.js.

import {
  Button,
  Modal,
  ModalBody,
  ModalCloseButton,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
  useDisclosure,
} from "@chakra-ui/react";
import React, { useEffect } from "react";

export default function DeleteModal({
  text,
  deleteQuote,
  isDisabled,
  isLoading,
  refresh,
}) {
  const { isOpen, onOpen, onClose } = useDisclosure();

  useEffect(() => {
    if (refresh) {
      onClose();
    }
  }, [refresh]);

  return (
    <>
      <Button bg="red" color="#fff" onClick={onOpen}>
        Delete
      </Button>

      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>Delete Quote</ModalHeader>
          <ModalCloseButton />
          <ModalBody>{text}</ModalBody>

          <ModalFooter>
            <Button
              isDisabled={isDisabled}
              isLoading={isLoading}
              bg="red"
              color="#fff"
              onClick={deleteQuote}
              mx={4}
            >
              Confirm
            </Button>
            <Button colorScheme="red" variant="outline" onClick={onClose}>
              Close
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
}

Notification

Create a new folder Notification within the components and add a new file index.js to it, frontend/components/Notification/index.js.

import { Box, Text } from '@chakra-ui/react'
import React from 'react'

export default function Notification({message}) {
  return (
    <Box position="relative" top="2rem" right={0}>
      <Text color="gold">✔ {message}</Text>
    </Box>
  )
}

Integrate Created Functions and Components

Update App.js by replacing the entire code with the code block below:

import "regenerator-runtime/runtime";
import React, { useEffect, useState } from "react";
import {
  login,
  logout,
  create_quote,
  delete_quote,
  fetch_quotes,
  tip_author,
  total_quotes,
} from "./assets/js/near/utils";
import {
  Box,
  Text,
} from "@chakra-ui/react";

import Nav from "./components/Nav";
import Notification from "./components/Notification";
import CardContainer from "./components/Card";
import AddQuote from "./components/AddQuote";
import Login from "./components/Login";

export default function App() {
  const [data, setData] = useState([]);
  const [totalQuotes, setTotalQuotes] = useState(0);
  const [quote, setQuote] = useState("");
  const [loading, setLoading] = useState(false);
  const [loadingDelete, setLoadingDelete] = useState(false);
  const [refresh, setRefresh] = useState(false);

  // after submitting the form, we want to show Notification
  const [showNotification, setShowNotification] = useState(false);

  useEffect(() => {
    fetch_quotes({ from_index: "0", limit: 20 }).then((res) => {
      setData(res);
    });
    total_quotes().then((res) => {
      setTotalQuotes(res);
    });
    setRefresh(false);
  }, [refresh]);

  const createQuote = () => {
    const payload = { author: window.accountId, text: quote };
    setLoading(true);
    create_quote(payload).then((res) => {
      console.log(res);
      setRefresh(true);
      setLoading(false);
      setQuote("");

      // show Notification
      setShowNotification(true);

      // remove Notification again after css animation completes
      // this allows it to be shown again the next time the form is submitted
      setTimeout(() => {
        setShowNotification(false);
      }, 3000);
    });
  };

  const deleteQuote = (id) => {
    setLoadingDelete(true);
    delete_quote({ id }).then((res) => {
      console.log(res);
      setLoadingDelete(false);
      setRefresh(true);
    });
  };

  const tipAuthor = (id) => {
    tip_author({ id }).then((res) => {
      console.log(res);
    });
  };

  return (
    <>
      {!window.walletConnection.isSignedIn() ? (
        <Login login={login} />
      ) : (
        <Box
          display="flex"
          flexDirection="column"
          justifyContent="flex-start"
          alignItems="center"
        >
          <Nav account={window.accountId} onClick={logout} />
          {showNotification && <Notification message="Successful" />}

          <Box
            width={{ base: "auto", md: "700px" }}
            mt="0rem"
            p={8}
            maxW="1440px"
            minH="100vh"
            m="auto"
          >
            <AddQuote
              quote={quote}
              totalQuotes={totalQuotes}
              onChange={(e) => setQuote(e.target.value)}
              loading={loading}
              createQuote={createQuote}
            />
            <Box my={8}>
              {data.length < 1 && (
                <Text py={4} textAlign="center" color="#fff">
                  No Quote found
                </Text>
              )}
              { data
                  ?.sort((a, b) => b.id.localeCompare(a.id))
                  .map((item, index) => (
                    <CardContainer
                      key={index}
                      author={window.accountId}
                      item={item}
                      loading={loadingDelete}
                      tipAuthor={() => tipAuthor(item.id)}
                      deleteQuote={() => deleteQuote(item.id)}
                      refresh={refresh}
                    />
                  ))}
            </Box>
          </Box>
        </Box>
      )}
    </>
  );
}

The functions required to interact with our smart contract were imported from frontend/assets/js/near/utils. We also imported and used the components that we had created. Run yarn start in your terminal, and your application should be running at http://localhost:1234 as shown in the image below:

Login

Click on Connect and the next page you should see is the one below:

Quote Page

Go ahead and interact with the application, Create a quote, Tip an author, and Delete a Quote.

Tip Author

Click on the Blue button to tip an author of a quote 1NEAR.

Tip Author

The image below shows the transaction approval page for tipping an author:

Tipping Transaction

View the transaction on the testnet explorer.

Delete Quote

Only the author of a quote can delete a selected quote.

Delete Quote

Conclusion

In this article, we covered how to build and connect a React application to the NEAR blockchain and integrate the functions of a deployed smart contract. View the live version and explore the codebase that was used to create this article.