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:
Click on Connect
and the next page you should see is the one below:
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
.
The image below shows the transaction approval page for tipping an author:
View the transaction on the testnet explorer.
Delete Quote
Only the author of a quote can delete a selected 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.