Now we know a users balance, lets work on the Mint NFT functionality of the site.
Create src/components/Mint.tsx with the following content
import MintControl from '@/components/MintControl';
import { useGetTokenCount } from '@/hooks/useGetTokenCount';
import { COLLECTION_ID, GAS_TOKENS, shortAddress } from '@/lib/utils';
import { useAuth, useFutureverseSigner } from '@futureverse/auth-react';
import { useCallback, useMemo, useState } from 'react';
import { useTrnApi } from '@futureverse/transact-react';
import {
type ExtrinsicPayload,
type RootTransactionBuilder,
TransactionBuilder,
} from '@futureverse/transact';
import { getAddress, isAddress } from 'viem';
import Modal from '@/components/Modal';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useShouldShowEoa } from '@/hooks/useShouldShowEoa';
import { Label } from '@/components/ui/label';
import { useQueryClient } from '@tanstack/react-query';
export type GasProps = {
gasString: string;
gasFee: string;
tokenDecimals: number;
};
export default function Mint() {
const queryClient = useQueryClient();
const shouldShowEoa = useShouldShowEoa();
const { userSession } = useAuth();
const signer = useFutureverseSigner();
const { trnApi } = useTrnApi();
const [gas, setGas] = useState<GasProps>({
gasString: '',
gasFee: '',
tokenDecimals: 0,
});
const [payload, setPayload] = useState<null | ExtrinsicPayload>(null);
const [walletToPayGas, setWalletToPayGas] = useState<'fpass' | 'eoa'>(
'fpass'
);
const [gasTokenId, setGasTokenId] = useState<number>(2);
const [toSign, setToSign] = useState<string>('');
const [currentBuilder, setCurrentBuilder] =
useState<RootTransactionBuilder | null>(null);
const { data: fpassTokenCount, isFetching: fpassCountFetching } =
useGetTokenCount(userSession?.futurepass ?? '', COLLECTION_ID);
const { data: eoaTokenCount, isFetching: eoaCountFetching } =
useGetTokenCount(userSession?.eoa ?? '', COLLECTION_ID);
const isFetching = fpassCountFetching || eoaCountFetching;
const totalTokenCount = (fpassTokenCount ?? 0) + (eoaTokenCount ?? 0);
const [mintQty, setMintQty] = useState<number>(1);
const [showModal, setShowModal] = useState<boolean>(false);
const handleSetMintQty = (value: number[]) => {
if (value && Array.isArray(value) && value[0] !== undefined) {
setMintQty(value[0]);
}
};
const onSuccessfulMint = useCallback(async () => {
void queryClient.invalidateQueries({ queryKey: ['assets'] });
void queryClient.invalidateQueries({ queryKey: ['tokens'] });
void queryClient.invalidateQueries({ queryKey: ['balance'] });
}, [queryClient]);
const mintBuilder = useCallback(async () => {
if (!trnApi || !signer || !userSession) {
console.log('Missing trnApi, signer or userSession');
return;
}
const addressToSend = getAddress(userSession.futurepass);
if (isAddress(addressToSend) && parseInt(addressToSend) === 0) {
throw new Error('Invalid futurepass address');
// return;
}
const getExtrinsic = async (builder: RootTransactionBuilder) => {
const gasEstimate = await builder?.getGasFees();
if (gasEstimate) {
setGas(gasEstimate);
}
const payloads = await builder?.getPayloads();
if (!payloads) {
return;
}
setPayload(payloads);
const { ethPayload } = payloads;
setToSign(ethPayload.toString());
};
const nft = TransactionBuilder.nft(
trnApi,
signer,
userSession.eoa,
COLLECTION_ID
).mint({
walletAddress: addressToSend,
quantity: mintQty,
});
if (walletToPayGas === 'fpass') {
if (gasTokenId === 2) {
await nft.addFuturePass(userSession.futurepass);
}
if (gasTokenId !== 2) {
await nft.addFuturePassAndFeeProxy({
futurePass: userSession.futurepass,
assetId: gasTokenId,
slippage: 5,
});
}
}
if (walletToPayGas === 'eoa') {
if (gasTokenId !== 2) {
await nft.addFeeProxy({
assetId: gasTokenId,
slippage: 5,
});
}
}
await getExtrinsic(nft);
setCurrentBuilder(nft);
}, [trnApi, signer, userSession, mintQty, walletToPayGas, gasTokenId]);
useMemo(() => {
setShowModal(!!toSign);
}, [toSign]);
return (
<main className="flex min-h-[calc(100dvh-6rem-1rem)] flex-col items-center justify-center text-white">
<div className="container grid grid-cols-1 lg:grid-cols-9 gap-8 px-4 items-center ">
<div className="grid lg:col-span-5 gap-4">
<div className="w-full">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-8xl font-bold text-left mb-4">
How many would you like to mint?
</h1>
<div className="text-3xl text-orange-500">
Mint limited to 10 per person
</div>
</div>
<div className="text-base text-orange-500">
You currently own {totalTokenCount} tokens
</div>
</div>
<div className="w-full lg:col-span-4 flex justify-end items-center">
<div className="bg-slate-700 p-4 w-full rounded-lg">
<MintControl
mintQty={mintQty}
handleSetMintQty={handleSetMintQty}
totalTokenCount={totalTokenCount}
isFetching={isFetching}
>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 w-full bg-slate-500 bg-opacity-20 p-4 rounded-lg">
{shouldShowEoa && userSession && (
<div className="md:col-span-3">
<Label className="text-xs font-bold tracking-wider uppercase">
Account To Pay Gas
</Label>
<Select
onValueChange={value =>
setWalletToPayGas(value as 'fpass' | 'eoa')
}
value={walletToPayGas}
>
<SelectTrigger className="text:base xl:text-xl leading-6 p-4 h-12 mt-1">
<SelectValue
placeholder="Account To Pay Gas"
className="text:base xl:text-lg font-bold"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="eoa">
{shortAddress(userSession?.eoa)}
</SelectItem>
<SelectItem value="fpass">
{shortAddress(userSession?.futurepass)}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div
className={`${!shouldShowEoa ? 'md:col-span-5' : 'md:col-span-2'}`}
>
<Label className="text-xs font-bold tracking-wider uppercase">
Gas Token
</Label>
<Select
onValueChange={value => setGasTokenId(parseInt(value))}
value={gasTokenId.toString()}
>
<SelectTrigger className="text:base xl:text-xl leading-6 p-4 h-12 mt-1">
<SelectValue
placeholder="Select Gas Token"
className="text:base xl:text-lg font-bold"
/>
</SelectTrigger>
<SelectContent>
{Object.keys(GAS_TOKENS).map(key => (
<SelectItem
key={key}
value={GAS_TOKENS[
key as keyof typeof GAS_TOKENS
].toString()}
>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
onClick={async () => await mintBuilder()}
className="text-base lg:text-lg xl:text-xl bg-accent-foreground py-2 px-3 xl:py-4 xl:px-6 text-orange-600 hover:bg-orange-600 hover:text-white transition-colors duration-300 rounded-md md:col-span-5 w-full"
>
Mint {mintQty} Token{mintQty > 1 ? 's' : ''} Now!
</button>
</div>
</MintControl>
</div>
</div>
{showModal && (
<Modal
setShow={() => setShowModal(false)}
gas={gas}
toSign={toSign}
payload={payload}
gasTokenId={gasTokenId}
walletAddress={
(walletToPayGas === 'fpass'
? userSession?.futurepass
: userSession?.eoa) ?? ''
}
extrinsicBuilder={currentBuilder}
// signedCallback={signedCallback}
resultCallback={onSuccessfulMint}
/>
)}
</div>
</main>
);
}Create src/components/MintControl.tsx with the following content
import { Skeleton } from './ui/skeleton';
import { MAX_MINT_QTY } from '@/lib/utils';
import { type PropsWithChildren } from 'react';
import { Slider } from './ui/slider';
type MintControlProps = PropsWithChildren<{
mintQty: number;
handleSetMintQty: (value: number[]) => void;
totalTokenCount: number;
isFetching: boolean;
}>;
export default function MintControl({
children,
mintQty,
handleSetMintQty,
totalTokenCount,
isFetching,
}: MintControlProps) {
return (
<div className="w-full flex flex-col justify-center items-center gap-8">
{isFetching && <Skeleton className="text-4xl p-4">Loading...</Skeleton>}
{!isFetching && (
<>
{totalTokenCount < MAX_MINT_QTY ? (
<>
<div className="w-full flex flex-col justify-center items-center">
<div className="text-sm uppercase">Mint Qty</div>
<input
type="number"
className="appearance-none bg-transparent text-center w-full m-3 rounded-lg text-8xl font-black"
value={mintQty.toString()}
min={1}
max={MAX_MINT_QTY - totalTokenCount}
onChange={e => handleSetMintQty([parseInt(e.target.value)])}
/>
</div>
<Slider
defaultValue={[1]}
max={MAX_MINT_QTY - totalTokenCount}
min={1}
step={1}
value={[mintQty]}
onValueChange={handleSetMintQty}
minStepsBetweenThumbs={1}
/>
{children}
</>
) : (
<div className="text-4xl text-orange-500 text-center">
Sorry, you have reached the maximum mint quantity of allowed
</div>
)}
</>
)}
</div>
);
}Create src/components/Modal.tsx with the following content
import { useCallback, useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import type {
ExtrinsicPayload,
ExtrinsicResult,
RootTransactionBuilder,
} from '@futureverse/transact';
import { TransactionPayload } from '@futureverse/transact-react';
import { Button } from './ui/button';
import Spinner from './Spinner';
import useGetUserBalance from '@/hooks/useGetUserBalance';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from './ui/accordion';
type ModalProps = {
setShow: (show: boolean) => void;
extrinsicBuilder: RootTransactionBuilder | null;
toSign: string;
signedCallback?: () => void;
resultCallback?: (result: ExtrinsicResult) => void;
gas: {
gasFee: string;
gasString: string;
tokenDecimals: number;
};
payload?: ExtrinsicPayload | null;
walletAddress: string;
gasTokenId: number;
};
export default function Modal({
setShow,
extrinsicBuilder,
toSign,
signedCallback,
resultCallback,
gas,
payload,
walletAddress,
gasTokenId,
}: ModalProps) {
const [signed, setSigned] = useState(false);
const [, setSent] = useState(false);
const [result, setResult] = useState<ExtrinsicResult>();
const [error, setError] = useState<string>();
const { data: gasTokenUserBalance, isLoading: gasTokenUserBalanceLoading } =
useGetUserBalance({
walletAddress: walletAddress ?? '',
assetId: gasTokenId,
});
const onSign = useCallback(() => {
setSigned(true);
if (signedCallback) {
signedCallback();
}
}, [setSigned, signedCallback]);
const onSend = useCallback(() => {
setSent(true);
}, [setSent]);
const signExtrinsic = useCallback(async () => {
if (toSign && extrinsicBuilder) {
try {
const result = await extrinsicBuilder.signAndSend({ onSign, onSend });
setResult(result as unknown as ExtrinsicResult);
if (resultCallback) {
resultCallback(result);
}
} catch (e: unknown) {
console.error(e);
if (typeof e === 'string') {
setError(e);
}
if (typeof e === 'object' && e instanceof Error) {
setError(e?.message);
}
}
}
}, [
extrinsicBuilder,
onSend,
onSign,
resultCallback,
setError,
setResult,
toSign,
]);
const balanceLowerThanGasFee = useMemo(() => {
if (gasTokenUserBalanceLoading) {
return false;
}
return BigInt(gasTokenUserBalance?.rawBalance ?? '') <= BigInt(gas.gasFee);
}, [gasTokenUserBalanceLoading, gasTokenUserBalance, gas.gasFee]);
return (
<Dialog open={true} defaultOpen={true} onOpenChange={() => setShow(false)}>
<DialogContent
className="max-w-[600px] p-6 w-[90vw] border-white"
onInteractOutside={e => {
e.preventDefault();
}}
>
{!signed && !result && !error && (
<DialogHeader>
<DialogTitle className="text-md -mb-2">
Confirm & Sign Transaction
</DialogTitle>
</DialogHeader>
)}
{result && !error && (
<DialogHeader>
<DialogTitle className="text-md -mb-2">
Tokens Minted Successfully
</DialogTitle>
</DialogHeader>
)}
<div className="flex flex-col gap-4">
{!result && !error && !signed && (
<>
{toSign && (
<div className="w-full">
<div className="w-full text-sm mb-2 font-bold uppercase text-orange-600 tracking-wider">
Transaction Encoded As
</div>
<div className="w-full bg-foreground text-background font-mono text-xs p-2 rounded-lg break-all">
{toSign}
</div>
</div>
)}
{payload && (
<>
<div>
<div className="w-full max-h-[50vh]">
<Accordion type="single" collapsible>
<AccordionItem
value="tx-data"
className="border-none hover:no-underline"
>
<AccordionTrigger className="justify-start text-start hover:no-underline p-0">
<div className="w-full text-sm mb-2 font-bold uppercase text-orange-600 tracking-wider">
Expand Transaction Details
</div>
</AccordionTrigger>
<AccordionContent>
<TransactionPayload
payload={payload.trnPayload}
config={{
backgroundColor: 'hsl(var(--foreground))',
textColor: 'hsl(var(--background))',
highlightColor: 'hsl(var(--accent))',
borderColor: 'hsl(var(--primary-foreground))',
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
<div className="w-full grid grid-cols-1 justify-center justify-items-center">
<div className="w-full text-sm font-bold uppercase text-orange-600 tracking-wider">
Gas Fee
</div>
<div
className={`w-full text-3xl ${
balanceLowerThanGasFee ? 'text-red-700 underline' : ''
}`}
>
{gas.gasString}
</div>
</div>
{!signed && (
<Button
className={`text-lg bg-accent-foreground p-6 text-orange-600 hover:bg-orange-600 hover:text-white transition-colors duration-300 rounded-md col-span-2 w-full ${balanceLowerThanGasFee ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={signExtrinsic}
disabled={
gasTokenUserBalanceLoading ||
!gasTokenUserBalance ||
balanceLowerThanGasFee
}
>
{balanceLowerThanGasFee
? 'Top Up Funds To Cover Gas'
: 'Sign Transaction'}
</Button>
)}
</>
)}
</>
)}
{signed && !result && !error && (
<div className="grid grid-cols-1 p-8 justify-center justify-items-center w-full">
<Spinner className="w-36 h-36" />
</div>
)}
{result && (
<>
<div className="grid gap-4 mt-2">
<Button asChild className="bg-orange-500">
<a
href={`https://porcini.rootscan.io/extrinsic/${result.extrinsicId}`}
target="_blank"
rel="noreferrer noopener"
>
View Transaction
</a>
</Button>
</div>
</>
)}
{error && (
<div className="grid grid-cols-1 p-2">
<div className="bg-red-700 p-2 rounded-sm">
<div className="text-sm font-bold mb-1">
There has been an error...
</div>
<div className="text-xs">{error}</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}Open src/components/Navigation.tsx and replace content with
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Link } from 'react-router-dom';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from './ui/navigation-menu';
import Wallet from './Wallet';
import { type Dispatch, type SetStateAction, useState } from 'react';
export function Navigation() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="relative">
<InnerNavigation
classes="hidden lg:flex flex-row"
closeHandler={setIsMenuOpen}
/>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-white hover:text-orange-500 duration-300 transition-colors flex lg:hidden border-orange-500 border-[1px] rounded-md p-2 uppercase text-xs tracking-wider"
>
Menu
</button>
{isMenuOpen && (
<div className="absolute -right-4 -bottom-6 translate-y-full bg-slate-500 bg-opacity-90 rounded-md p-2 pl-1.5 items-end">
<InnerNavigation
classes="flex lg:hidden flex-col text-end items-end"
closeHandler={setIsMenuOpen}
/>
</div>
)}
</div>
);
}
const InnerNavigation = ({
classes = '',
closeHandler,
}: {
classes?: string;
closeHandler: Dispatch<SetStateAction<boolean>>;
}) => {
return (
<NavigationMenu>
<NavigationMenuList className={classes}>
<NavigationMenuItem>
<NavigationMenuLink
asChild
onClick={() => closeHandler && closeHandler(false)}
className={`${navigationMenuTriggerStyle()} bg-transparent hover:bg-transparent text-white hover:text-orange-500 duration-300 transition-colors text-lg`}
>
<Link to="/mint">Mint NFT</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{/* <NavigationMenuItem>
<NavigationMenuLink
onClick={() => closeHandler && closeHandler(false)}
asChild
className={`${navigationMenuTriggerStyle()} bg-transparent hover:bg-transparent text-white hover:text-orange-500 duration-300 transition-colors text-lg`}
>
<Link to="/accessories">Mint Accessories</Link>
</NavigationMenuLink>
</NavigationMenuItem> */}
{/* <NavigationMenuItem>
<NavigationMenuLink
asChild
onClick={() => closeHandler && closeHandler(false)}
className={`${navigationMenuTriggerStyle()} bg-transparent hover:bg-transparent text-white hover:text-orange-500 duration-300 transition-colors text-lg`}
>
<Link to="/my-collection">My Collection</Link>
</NavigationMenuLink>
</NavigationMenuItem> */}
<NavigationMenuItem>
<Wallet />
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
};Remove the following comments from src/App.tsx
//import Mint from '@/components/Mint';and
{
/* <Route
path="/mint"
element={
<ProtectedRoute>
<Mint />
</ProtectedRoute>
}
/> */
}so it looks like
<Route
path="/mint"
element={
<ProtectedRoute>
<Mint />
</ProtectedRoute>
}
/>Open src/components/Home.tsx and replace the content with the following
import { useAuth } from '@futureverse/auth-react';
import { Button } from '@/components/ui/button';
import { LoginButton } from '@/components/LoginButton';
import { useAccount } from 'wagmi';
import { Link } from 'react-router-dom';
export default function HomePage() {
const { userSession } = useAuth();
const { isConnected } = useAccount();
return (
<main className="flex min-h-[calc(100dvh-6rem-1rem)] flex-col items-center justify-center text-white">
<div className="container grid grid-cols-6 gap-y-3 ">
<div className="grid gap-8 col-span-full md:col-span-4 pb-52 lg:pb-0 z-20 lg:z-0">
<h1 className="text-4xl lg:text-6xl xl:text-8xl font-bold text-left">
Mint Your Swappables! 🚀
</h1>
<div className="flex justify-start">
{!userSession || !isConnected ? (
<LoginButton buttonText="Login to mint your NFT now!" />
) : (
<Button
asChild
size="lg"
className={`text-lg xl:text-xl bg-accent-foreground py-6 px-8 text-orange-600 hover:bg-orange-600 hover:text-white transition-colors duration-300 rounded-md`}
>
<Link to="/mint">Mint your NFT now!</Link>
</Button>
)}
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 w-full md:w-1/2 h-auto z-10">
<img
src="/RichieRich.webp"
width={1200}
height={1200}
alt="Futureverse Workshop: Paris"
className="h-auto w-auto"
/>
</div>
</main>
);
}