Skip to content

Commit

Permalink
Add loading on checkout (#818)
Browse files Browse the repository at this point in the history
* handle loading toast on checkout

* chore: update deps

* chore: revert deps update

* fix:handle transaction cancellation
  • Loading branch information
KannuSingh authored Feb 12, 2025
1 parent 10b94fb commit d8cc3f7
Show file tree
Hide file tree
Showing 7 changed files with 393 additions and 87 deletions.
132 changes: 98 additions & 34 deletions advanced/dapps/chain-abstraction-demo/app/hooks/useGiftDonut.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,128 @@
"use client";
import { config } from "@/config";
import { tokenAddresses } from "@/consts/tokens";
import { Network, Token } from "@/data/EIP155Data";
import { toast } from "sonner";
import { erc20Abi, Hex } from "viem";
import { getAccount, getWalletClient } from "wagmi/actions";
import { erc20Abi, Hex, PublicClient } from "viem";
import { getAccount, getWalletClient, getPublicClient } from "wagmi/actions";
import { useState } from "react";
import { TransactionToast } from "@/components/TransactionToast";

type TransactionStatus = 'waiting-approval' | 'pending' | 'success' | 'error';

export default function useGiftDonut() {
const [isPending, setIsPending] = useState(false);

const updateToast = (
toastId: ReturnType<typeof toast>,
status: TransactionStatus,
{ elapsedTime, hash, networkName }: {
elapsedTime?: number;
hash?: string;
networkName?: string;
} = {}
) => {
toast(
<TransactionToast
status={status}
elapsedTime={elapsedTime}
hash={hash}
networkName={networkName}
/>,
{ id: toastId }
);
};

const validateTransaction = async (network: Network) => {
const client = await getWalletClient(config, { chainId: network.chainId });
const publicClient = getPublicClient(config);
if (!publicClient) throw new Error("Failed to get public client");

const account = getAccount(config);
const connectedChainId = account.chain?.id;

if (!connectedChainId) throw new Error("Chain undefined");
if (connectedChainId !== network.chainId) {
throw new Error("Please switch chain, connected chain does not match network");
}

return { client, publicClient };
};

const getTokenContract = (token: Token, chainId: number) => {
const tokenChainMapping = tokenAddresses[token.name];
if (!tokenChainMapping) throw new Error("Token not supported");

const contract = tokenChainMapping[chainId];
if (!contract) throw new Error("Can't send on specified chain");

return contract;
};

const giftDonutAsync = async (
to: Hex,
donutCount: number,
token: Token,
network: Network,
) => {
try {
const client = await getWalletClient(config, {
chainId: network.chainId,
});
const account = getAccount(config);
const connectedChainId = account.chain?.id;
if (!connectedChainId) {
throw new Error("Chain undefined");
}
setIsPending(true);
const startTime = Date.now();
const toastId = toast(<TransactionToast status="waiting-approval" />);
let updateInterval: ReturnType<typeof setInterval>;

if (connectedChainId !== network.chainId) {
throw new Error("Please switch chain, connected chain does not match network");
}
try {
// Validate chain and get clients
const { client, publicClient } = await validateTransaction(network);
const chainId = getAccount(config).chain?.id!;

const tokenName = token.name;
const tokenChainMapping = tokenAddresses[tokenName];
if (!tokenChainMapping) {
throw new Error("Token not supported");
}

const contract = tokenChainMapping[connectedChainId];
if (!contract) {
throw new Error("Cant send on specified chain");
}
// Get token contract
const contract = getTokenContract(token, chainId);
const tokenAmount = donutCount * 1 * 10 ** 6;
console.log({ to, tokenAmount, contract });

// Start tracking elapsed time
updateInterval = setInterval(() => {
updateToast(toastId, 'waiting-approval', {
elapsedTime: Math.floor((Date.now() - startTime) / 1000)
});
}, 1000);

// Send transaction
const tx = await client.writeContract({
abi: erc20Abi,
address: contract,
functionName: "transfer",
args: [to, BigInt(tokenAmount)],
});
toast.success(`Transaction sent with hash: ${tx}`);

// Update to pending status
updateToast(toastId, 'pending', { hash: tx, networkName: network.name });

// Wait for transaction
const receipt = await publicClient.waitForTransactionReceipt({ hash: tx });
clearInterval(updateInterval);

// Update final status
const finalElapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
const finalStatus = receipt.status === 'success' ? 'success' : 'error';

updateToast(toastId, finalStatus, {
elapsedTime: finalElapsedSeconds,
hash: tx,
networkName: network.name
});

return tx;
} catch (e) {

clearInterval(updateInterval!);
const finalElapsedSeconds = Math.floor((Date.now() - startTime) / 1000);

if (e instanceof Error) {
toast.error(e.message)
}
else {
toast.error("Error sending gift donut");
updateToast(toastId, 'error', { elapsedTime: finalElapsedSeconds });
}
console.error(e);
} finally {
setIsPending(false);
}
};
return { giftDonutAsync };
}

return { giftDonutAsync, isPending };
}
2 changes: 1 addition & 1 deletion advanced/dapps/chain-abstraction-demo/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function RootLayout({
<div className="flex justify-center min-h-screen">{children}</div>
</ThemeProvider>
</AppKitProvider>
<Toaster />
<Toaster expand={true} />
</body>
</html>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import { Loader2 } from 'lucide-react';

export const CheckWalletToast = () => {
return (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm font-medium">Check your wallet to approve transaction</p>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import { Loader2 } from 'lucide-react';

interface TransactionToastProps {
hash?: string;
networkName?: string;
elapsedTime?: number; // in seconds
status: 'waiting-approval' | 'pending' | 'success' | 'error';
}

export const TransactionToast = ({ hash, networkName, elapsedTime, status }: TransactionToastProps) => {
const formatTime = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};

const renderContent = () => {
switch (status) {
case 'waiting-approval':
return (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm font-medium">Check your wallet to approve transaction</p>
</div>
);
case 'pending':
return (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<div className="flex flex-col">
<p className="text-sm font-medium">Sending Gift Donut</p>
{hash && networkName && (
<a
href={`${networkName}/tx/${hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:text-blue-600"
>
View transaction
</a>
)}
{elapsedTime && (
<p className="text-xs text-secondary">Time elapsed: {formatTime(elapsedTime)}</p>
)}
</div>
</div>
);
case 'success':
return (
<div className="flex flex-col">
<p className="text-sm font-medium">Transaction completed!</p>
{elapsedTime && (
<p className="text-xs text-secondary">Completed in {formatTime(elapsedTime)}</p>
)}
</div>
);
case 'error':
return (
<div className="flex flex-col">
<p className="text-sm font-medium">Transaction failed</p>
{elapsedTime && (
<p className="text-xs text-secondary">Failed after {formatTime(elapsedTime)}</p>
)}
</div>
);
}
};

return <div className="w-full">{renderContent()}</div>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,31 @@ function GiftDonutForm({
const [recipientAddress, setRecipientAddress] = React.useState(
recipient || "",
);
const { giftDonutAsync } = useGiftDonut();
const { giftDonutAsync, isPending } = useGiftDonut();

const setRecipient = (address: string) => {
setRecipientAddress(address);
giftDonutModalManager.setRecipient(address);
};

const handleCheckout = () => {
try{
const handleCheckout = async () => {
try {
const to = recipientAddress as `0x${string}`;
const token = giftDonutModalManager.getToken();
const network = giftDonutModalManager.getNetwork();
if(!network) {
throw new Error("Network not selected");
}
onClose()
giftDonutAsync(to, donutCount, token, network);
}catch(e){
console.error(e)
if(e instanceof Error){
toast.error(e.message)

// Start the transaction before closing the modal
const giftPromise = giftDonutAsync(to, donutCount, token, network);
onClose(); // Close modal after initiating transaction
await giftPromise; // Wait for transaction to complete

} catch(e) {
console.error(e);
if(e instanceof Error) {
toast.error(e.message);
}
}
};
Expand Down
12 changes: 7 additions & 5 deletions advanced/dapps/chain-abstraction-demo/config/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cookieStorage, createStorage, http } from "@wagmi/core";
import { cookieStorage, createStorage, type Storage } from "@wagmi/core";
import { WagmiAdapter } from "@reown/appkit-adapter-wagmi";
import { arbitrum, optimism, base } from "@reown/appkit/networks";

Expand All @@ -10,10 +10,12 @@ if (!projectId) {

export const networks = [base, optimism, arbitrum];

const storage = createStorage({
storage: cookieStorage as Storage,
});

export const wagmiAdapter = new WagmiAdapter({
storage: createStorage({
storage: cookieStorage,
}),
storage,
ssr: true,
projectId,
networks,
Expand All @@ -26,4 +28,4 @@ export const metadata = {
icons: ["https://ca-demo.reown.com/donut.png"],
};

export const config = wagmiAdapter.wagmiConfig;
export const config = wagmiAdapter.wagmiConfig;
Loading

0 comments on commit d8cc3f7

Please sign in to comment.