Step 5: Create a purchase action
Understand purchase logic
The final step is to create the PurchaseAction
which contains all the buy logic.
This is a large step in terms of chunk of code size, but if you break it down piece by piece, it's fairly easy to understand.
There are two steps involved in a crypto transaction using any decentralized exchange:
- The first time that you initiate a purchase, you must approve the ERC-20 payment token to use for this transaction. This action grants permission to the
TreumExchange
contract to transfer up to a certain amount of your token (called anallowance
) from your wallet. - When the token is approved, you proceed to the
PurchaseForm
, where you use the approved token to fill an order. In this stage, you validate the form data if the user has enough token balance, then submit the order.
First, copy the /abis
and /hooks
folders from the source code to your project.
/src
...
...
|---- /abis: contains the ABI (Application Binary Interface) of deployed smart contracts that is understood by the Ethereum virtual machine.
|---- /hooks: for sharing wallet and order state across app components.
In the root directory, run:
yarn typechain --target ethers-v5 --out-dir src/contracts 'src/abis/*.abi'
This generates the TypeScript-safe typings compatible with the ethers.js
library for the smart contracts used in /src/abis
. These files are autogenerated under the src/contracts
folder.
/src
...
...
|---- /abis: contains the ABI (Application Binary Interface) of deployed smart contracts that is understood by the Ethereum virtual machine.
|---- /contracts: contains autogenerated typings from smart contracts.
|---- /hooks: for sharing wallet and order state across app components.
The hooks needed are:
useApproveTokenForOrder
- The umbrella function in which you submitorder
andnetworkId
and ask forapprovalState
.useApprovalCallback
- Called insideuseApproveTokenForOrder
and takes theorder token
,amount
,spender
as parameters, and returns theapproveState
and async functions toapprove
andremoveApproval
.useTokenAllowance
- Checks the currentallowance
and approval status. Ifallowance
amount is larger thantransfer
amount, there's no need to approve.useCurrency
- Contains functions to translateorder amount
andtoken address
into ERC-20 currencies.useExchangeContract
- Loads theTremExchange
contract. Returns function tofillOrder
and the exchange contract address.useTokenBalance
- Checks thebalance
of the payment token in the wallet. Prevents the user from submitting an order if there's insufficient balance.useTokenContract
- Loads thetoken contract
with the giventoken address
.
With some additional contractHelpers
, begin to construct the PurchaseAction
:
import { isAddress } from "ethers/lib/utils";
export const checkDeployment = async (
provider: any,
contractAddress: string,
): Promise<boolean> => {
if (!isAddress(contractAddress)) return false;
const bytecode = await provider.getCode(contractAddress);
return bytecode !== "0x";
};
import { Alert, Button, Modal, Typography } from "@mui/material";
import { useState } from "react";
import { useWallet } from "./WalletContext";
import PurchaseWizard from "./PurchaseWizard";
import { Item } from "../utils/types";
import { networkName } from "../config";
export interface PurchaseActionsProps {
item: Item;
}
const PurchaseAction: React.FC<PurchaseActionsProps> = ({ item }) => {
const { address, isRightNetwork } = useWallet();
const [cryptoBuyOpen, setCryptoBuyOpen] = useState<boolean>(false);
const { token_contract, listing } = item;
return (
<>
<Button
onClick={() => setCryptoBuyOpen(true)}
disabled={!isRightNetwork || !address}
color="secondary"
variant="outlined"
sx={{ borderRadius: 5, px: 18, float: "right" }}
>
<Typography variant="button" sx={ fontSize: "1rem" }>
Buy now
</Typography>
</Button>
{!isRightNetwork && token_contract && address && (
<Alert severity="warning">
Switch your network to ${networkName} to purchase.
</Alert>
)}
{!address && (
<Alert severity="warning">
You must connect your wallet to purchase.
</Alert>
)}
<Modal
open={cryptoBuyOpen}
onClose={() => setCryptoBuyOpen(false)}
sx={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PurchaseWizard
onDone={() => setCryptoBuyOpen(false)}
tokenContract={token_contract}
listing={listing}
/>
</Modal>
</>
);
};
export default PurchaseAction;
Create a two-step action
In this PurchaseAction
, build a Buy Now button that opens up a modal to PurchaseWizard
. When the user is in the wrong network or isn't connected to a wallet, the button is disabled and alerts are given.
import { Box, Step, StepLabel, Stepper } from "@mui/material";
import React from "react";
import { useState } from "react";
import { Listing, TokenContract } from "../utils/types";
import ApproveToken from "./ApproveToken";
import PurchaseForm from "./PurchaseForm";
export interface PurchaseWizardProps {
listing: Listing;
tokenContract: TokenContract;
onDone: any;
}
const steps = ["Approve", "Select quantity"];
const PurchaseWizard = React.forwardRef(
(props: PurchaseWizardProps, ref: any) => {
const [activeStep, setActiveStep] = useState<number>(0);
const { listing, tokenContract, onDone } = props;
const order = listing.data.order;
return (
<Box
ref={ref}
sx={{
p: 5,
backgroundColor: "#ffffff",
borderRadius: 5,
margin: "auto",
}}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps = {};
const labelProps = {};
return (
<Step key={label} {...stepProps}>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
pt: 4,
}}>
{activeStep === 0 && (
<ApproveToken
order={order}
tokenContract={tokenContract}
onDone={() => setActiveStep(1)}
/>
)}
{activeStep === 1 && (
<PurchaseForm onDone={onDone} listing={listing} />
)}
</Box>
</Box>
);
},
);
export default PurchaseWizard;
Here, use a Stepper
to guide the two-step transaction process.
On step 1, ApproveToken
.
import { Button } from "@mui/material";
import { useEffect } from "react";
import { ApprovalState } from "../hooks/useApproveCallback";
import { useApproveTokenForOrder } from "../hooks/useApproveTokenForOrder";
import { Order, TokenContract } from "../utils/types";
export interface ApproveTokenProps {
onDone: any;
order: Order;
tokenContract: TokenContract;
}
const ApproveToken: React.FC<ApproveTokenProps> = ({
onDone,
order,
tokenContract,
}) => {
const { approvalState, approve } = useApproveTokenForOrder(
order,
tokenContract.network_id,
{
refetchInterval: 8000,
},
);
const hasPendingTokenApproval = approvalState === ApprovalState.PENDING;
useEffect(() => {
if (approvalState === ApprovalState.APPROVED) {
onDone();
}
}, [approvalState, onDone]);
return (
<>
{approvalState !== ApprovalState.APPROVED && (
<Button onClick={approve} disabled={hasPendingTokenApproval}>
Approve Token
</Button>
)}
</>
);
};
export default ApproveToken;
On step 2, submit the PurchaseForm
:
import { Listing } from "../utils/types";
import { useWallet } from "./WalletContext";
import * as yup from "yup";
import useExchangeContract from "../hooks/useExchangeContract";
import { useCurrency } from "../hooks/useCurrency";
import { useTokenBalance } from "../hooks/useTokenBalance";
import { useCallback, useState } from "react";
import { BigNumber, BigNumberish, ethers } from "ethers";
import { TRANSACTION_SUCCESS } from "../utils/constants";
import { Alert, Box, Button, Typography } from "@mui/material";
import { Field, Form, Formik } from "formik";
import { formatPrice } from "../utils/market";
export interface PurchaseFormProps {
onDone?: any;
listing: Listing;
}
const validationSchema = yup.object().shape({
quantity: yup
.number()
.min(1, "Quantity must be larger than 0")
.required(`Please enter the quantity to purchase`),
});
export function isBalanceEnough(
balance?: BigNumberish,
requiredAmount?: BigNumberish
) {
if (!balance || !requiredAmount) {
return false;
} else {
return BigNumber.from(balance).gte(requiredAmount);
}
}
const PurchaseForm: React.FC<PurchaseFormProps> = ({
onDone,
listing,
}: PurchaseFormProps) => {
const { notify, network, address } = useWallet();
const { fillOrder } = useExchangeContract();
const { order, signature } = listing.data;
const currency = useCurrency(network!, order.takerToken.token);
const balance = useTokenBalance(currency!, address);
const [purchased, setPurchased] = useState<boolean>(false);
const handleBuy = useCallback(
async ({ quantity }) => {
try {
await fillOrder(
quantity,
order,
signature,
currency?.address === ethers.constants.AddressZero,
(tx: any) => {
setPurchased(true);
return TRANSACTION_SUCCESS;
}
);
} catch (error: any) {
console.error(error);
notify.notification({
eventCode: "error",
type: "error",
message: error.message,
autoDismiss: 5000,
});
} finally {
if (onDone) onDone();
}
},
[order, signature, fillOrder, notify, currency, onDone]
);
return (
<Formik
initialValues={
quantity: 1,
}
onSubmit={handleBuy}
validationSchema={validationSchema}
validateOnChange
validateOnBlur
>
{({ values, errors, touched, isValidating, isSubmitting }) => {
console.log("errors", errors);
console.log("isvalidating", isValidating);
console.log("touched", touched);
console.log("currency", currency);
return (
<Form>
<Typography variant="body1" align="center">
<Box width="100%">
Unit Price: {currency ? formatPrice(order, currency) : ""}
</Box>
<Box width="100%" mt={2}>
Select quantity (max {listing.data.quantity_remaining})
</Box>
<Field
type="number"
name="quantity"
placeholder="1"
disabled={isSubmitting}
validate={(quantity: number) => {
if (quantity < 1) {
return "Quantity must be higher than 0";
}
if (!balance) {
return "Checking token balance";
}
const hasEnough = isBalanceEnough(
balance!,
BigNumber.from(order.takerToken.amount).mul(quantity)
);
if (!hasEnough) {
return `You do not have enough ${currency!.symbol}`;
}
if (quantity > listing.data.quantity_remaining) {
return "You can only buy within the max quantity";
}
}}
/>
{touched.quantity && errors.quantity && (
<Alert severity="error">{errors.quantity}</Alert>
)}
{values.quantity > 0 && (
<Box width="100%" mt={2}>
Total Price:{" "}
{currency && values.quantity
? formatPrice(order, currency, values.quantity)
: ""}
</Box>
)}
<Box>
{purchased && <Button disabled={true}>Sold</Button>}
{!purchased && (
<Button
disabled={isSubmitting || !!errors.quantity}
type="submit"
sx={{ px: 3, borderRadius: 5 }}
variant="outlined"
>
Buy now
</Button>
)}
</Box>
</Typography>
</Form>
);
}}
</Formik>
);
};
export default PurchaseForm;
In this form, you validate if the quantity entered was too high, too low, or above balance. The buy button is disabled in these scenarios.
Finally, wire up this PurchaseAction
to the ItemPage
.
...
import PurchaseAction from "../components/PurchaseAction";
...
...
{isSuccess && (
<Page>
<Box sx={{ my: 4 }}>
{item.listing && <PurchaseAction item={item} />}
<Typography variant="h2" align="center" gutterBottom>
<ItemDetails item={item} />
</Typography>
</Box>
</Page>
)}
The buy button now displays and is disabled when the wallet isn't connected.
Select it to proceed to the approval and order process.
Follow through and complete the transaction. The item is marked SOLD instead of showing the price.
You've created your first NFT drop!
The entire source code of this project is available to download here.