2025-03-26 11:42:49 -07:00

580 lines
16 KiB
TypeScript

"use client";
import { useEffect, useCallback, useState, useMemo } from "react";
import { signIn, signOut, getCsrfToken } from "next-auth/react";
import sdk, {
FrameNotificationDetails,
type FrameContext,
} from "@farcaster/frame-sdk";
import {
useAccount,
useSendTransaction,
useSignMessage,
useSignTypedData,
useWaitForTransactionReceipt,
useDisconnect,
useConnect,
useSwitchChain,
useChainId,
} from "wagmi";
import { config } from "~/components/providers/WagmiProvider";
import { Button } from "~/components/ui/Button";
import { truncateAddress } from "~/lib/truncateAddress";
import { base, optimism } from "wagmi/chains";
import { BaseError, UserRejectedRequestError } from "viem";
import { useSession } from "next-auth/react"
import { SignInResult } from "@farcaster/frame-core/dist/actions/signIn";
export default function Demo(
{ title }: { title?: string } = { title: "Frames v2 Demo" }
) {
const [isSDKLoaded, setIsSDKLoaded] = useState(false);
const [context, setContext] = useState<FrameContext>();
const [isContextOpen, setIsContextOpen] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
const [addFrameResult, setAddFrameResult] = useState("");
const [notificationDetails, setNotificationDetails] =
useState<FrameNotificationDetails | null>(null);
const [sendNotificationResult, setSendNotificationResult] = useState("");
useEffect(() => {
setNotificationDetails(context?.client.notificationDetails ?? null);
}, [context]);
const { address, isConnected } = useAccount();
const chainId = useChainId();
const {
sendTransaction,
error: sendTxError,
isError: isSendTxError,
isPending: isSendTxPending,
} = useSendTransaction();
const { isLoading: isConfirming, isSuccess: isConfirmed } =
useWaitForTransactionReceipt({
hash: txHash as `0x${string}`,
});
const {
signTypedData,
error: signTypedError,
isError: isSignTypedError,
isPending: isSignTypedPending,
} = useSignTypedData();
const { disconnect } = useDisconnect();
const { connect } = useConnect();
const {
switchChain,
error: switchChainError,
isError: isSwitchChainError,
isPending: isSwitchChainPending,
} = useSwitchChain();
const handleSwitchChain = useCallback(() => {
switchChain({ chainId: chainId === base.id ? optimism.id : base.id });
}, [switchChain, chainId]);
useEffect(() => {
const load = async () => {
setContext(await sdk.context);
sdk.actions.ready({});
};
if (sdk && !isSDKLoaded) {
setIsSDKLoaded(true);
load();
}
}, [isSDKLoaded]);
const openUrl = useCallback(() => {
sdk.actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
}, []);
const openWarpcastUrl = useCallback(() => {
sdk.actions.openUrl("https://warpcast.com/~/compose");
}, []);
const close = useCallback(() => {
sdk.actions.close();
}, []);
const addFrame = useCallback(async () => {
try {
setNotificationDetails(null);
const result = await sdk.actions.addFrame();
if (result.added) {
if (result.notificationDetails) {
setNotificationDetails(result.notificationDetails);
}
setAddFrameResult(
result.notificationDetails
? `Added, got notificaton token ${result.notificationDetails.token} and url ${result.notificationDetails.url}`
: "Added, got no notification details"
);
} else {
setAddFrameResult(`Not added: ${result.reason}`);
}
} catch (error) {
setAddFrameResult(`Error: ${error}`);
}
}, []);
const sendNotification = useCallback(async () => {
setSendNotificationResult("");
if (!notificationDetails || !context) {
return;
}
try {
const response = await fetch("/api/send-notification", {
method: "POST",
mode: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fid: context.user.fid,
notificationDetails,
}),
});
if (response.status === 200) {
setSendNotificationResult("Success");
return;
} else if (response.status === 429) {
setSendNotificationResult("Rate limited");
return;
}
const data = await response.text();
setSendNotificationResult(`Error: ${data}`);
} catch (error) {
setSendNotificationResult(`Error: ${error}`);
}
}, [context, notificationDetails]);
const sendTx = useCallback(() => {
sendTransaction(
{
// call yoink() on Yoink contract
to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878",
data: "0x9846cd9efc000023c0",
},
{
onSuccess: (hash) => {
setTxHash(hash);
},
}
);
}, [sendTransaction]);
const signTyped = useCallback(() => {
signTypedData({
domain: {
name: "Frames v2 Demo",
version: "1",
chainId,
},
types: {
Message: [{ name: "content", type: "string" }],
},
message: {
content: "Hello from Frames v2!",
},
primaryType: "Message",
});
}, [chainId, signTypedData]);
const toggleContext = useCallback(() => {
setIsContextOpen((prev) => !prev);
}, []);
if (!isSDKLoaded) {
return <div>Loading...</div>;
}
return (
<div className="w-[300px] mx-auto py-4 px-2">
<h1 className="text-2xl font-bold text-center mb-4">{title}</h1>
<div className="mb-4">
<h2 className="font-2xl font-bold">Context</h2>
<button
onClick={toggleContext}
className="flex items-center gap-2 transition-colors"
>
<span
className={`transform transition-transform ${
isContextOpen ? "rotate-90" : ""
}`}
>
</span>
Tap to expand
</button>
{isContextOpen && (
<div className="p-4 mt-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
{JSON.stringify(context, null, 2)}
</pre>
</div>
)}
</div>
<div>
<h2 className="font-2xl font-bold">Actions</h2>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.signIn
</pre>
</div>
<SignIn />
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.openUrl
</pre>
</div>
<Button onClick={openUrl}>Open Link</Button>
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.openUrl
</pre>
</div>
<Button onClick={openWarpcastUrl}>Open Warpcast Link</Button>
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.close
</pre>
</div>
<Button onClick={close}>Close Frame</Button>
</div>
</div>
<div>
<h2 className="font-2xl font-bold">Add to client & notifications</h2>
<div className="mt-2 mb-4 text-sm">
Client fid {context?.client.clientFid},
{context?.client.added
? " frame added to client,"
: " frame not added to client,"}
{notificationDetails
? " notifications enabled"
: " notifications disabled"}
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.addFrame
</pre>
</div>
{addFrameResult && (
<div className="mb-2 text-sm">
Add frame result: {addFrameResult}
</div>
)}
<Button onClick={addFrame} disabled={context?.client.added}>
Add frame to client
</Button>
</div>
{sendNotificationResult && (
<div className="mb-2 text-sm">
Send notification result: {sendNotificationResult}
</div>
)}
<div className="mb-4">
<Button onClick={sendNotification} disabled={!notificationDetails}>
Send notification
</Button>
</div>
</div>
<div>
<h2 className="font-2xl font-bold">Wallet</h2>
{address && (
<div className="my-2 text-xs">
Address: <pre className="inline">{truncateAddress(address)}</pre>
</div>
)}
{chainId && (
<div className="my-2 text-xs">
Chain ID: <pre className="inline">{chainId}</pre>
</div>
)}
<div className="mb-4">
<Button
onClick={() =>
isConnected
? disconnect()
: connect({ connector: config.connectors[0] })
}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
</div>
<div className="mb-4">
<SignMessage />
</div>
{isConnected && (
<>
<div className="mb-4">
<SendEth />
</div>
<div className="mb-4">
<Button
onClick={sendTx}
disabled={!isConnected || isSendTxPending}
isLoading={isSendTxPending}
>
Send Transaction (contract)
</Button>
{isSendTxError && renderError(sendTxError)}
{txHash && (
<div className="mt-2 text-xs">
<div>Hash: {truncateAddress(txHash)}</div>
<div>
Status:{" "}
{isConfirming
? "Confirming..."
: isConfirmed
? "Confirmed!"
: "Pending"}
</div>
</div>
)}
</div>
<div className="mb-4">
<Button
onClick={signTyped}
disabled={!isConnected || isSignTypedPending}
isLoading={isSignTypedPending}
>
Sign Typed Data
</Button>
{isSignTypedError && renderError(signTypedError)}
</div>
<div className="mb-4">
<Button
onClick={handleSwitchChain}
disabled={isSwitchChainPending}
isLoading={isSwitchChainPending}
>
Switch to {chainId === base.id ? "Optimism" : "Base"}
</Button>
{isSwitchChainError && renderError(switchChainError)}
</div>
</>
)}
</div>
</div>
);
}
function SignMessage() {
const { isConnected } = useAccount();
const { connectAsync } = useConnect();
const {
signMessage,
data: signature,
error: signError,
isError: isSignError,
isPending: isSignPending,
} = useSignMessage();
const handleSignMessage = useCallback(async () => {
if (!isConnected) {
await connectAsync({
chainId: base.id,
connector: config.connectors[0],
});
}
signMessage({ message: "Hello from Frames v2!" });
}, [connectAsync, isConnected, signMessage]);
return (
<>
<Button
onClick={handleSignMessage}
disabled={isSignPending}
isLoading={isSignPending}
>
Sign Message
</Button>
{isSignError && renderError(signError)}
{signature && (
<div className="mt-2 text-xs">
<div>Signature: {signature}</div>
</div>
)}
</>
);
}
function SendEth() {
const { isConnected, chainId } = useAccount();
const {
sendTransaction,
data,
error: sendTxError,
isError: isSendTxError,
isPending: isSendTxPending,
} = useSendTransaction();
const { isLoading: isConfirming, isSuccess: isConfirmed } =
useWaitForTransactionReceipt({
hash: data,
});
const toAddr = useMemo(() => {
// Protocol guild address
return chainId === base.id
? "0x32e3C7fD24e175701A35c224f2238d18439C7dBC"
: "0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830";
}, [chainId]);
const handleSend = useCallback(() => {
sendTransaction({
to: toAddr,
value: 1n,
});
}, [toAddr, sendTransaction]);
return (
<>
<Button
onClick={handleSend}
disabled={!isConnected || isSendTxPending}
isLoading={isSendTxPending}
>
Send Transaction (eth)
</Button>
{isSendTxError && renderError(sendTxError)}
{data && (
<div className="mt-2 text-xs">
<div>Hash: {truncateAddress(data)}</div>
<div>
Status:{" "}
{isConfirming
? "Confirming..."
: isConfirmed
? "Confirmed!"
: "Pending"}
</div>
</div>
)}
</>
);
}
function SignIn() {
const [signingIn, setSigningIn] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const [signInResult, setSignInResult] = useState<SignInResult>();
const { data: session, status } = useSession()
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error("Unable to generate nonce");
return nonce;
}, []);
const handleSignIn = useCallback(async () => {
try {
setSigningIn(true);
const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce });
setSignInResult(result);
await signIn("credentials", {
message: result.message,
signature: result.signature,
redirect: false,
});
} finally {
setSigningIn(false);
}
}, [getNonce]);
const handleSignOut = useCallback(async () => {
try {
setSigningOut(true);
await signOut({ redirect: false })
setSignInResult(undefined);
} finally {
setSigningOut(false);
}
}, []);
return (
<>
{status !== "authenticated" &&
<Button
onClick={handleSignIn}
disabled={signingIn}
>
Sign In with Farcaster
</Button>
}
{status === "authenticated" &&
<Button
onClick={handleSignOut}
disabled={signingOut}
>
Sign out
</Button>
}
{session &&
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 rounded-lg font-mono">
<div className="font-semibold text-gray-500 mb-1">Session</div>
<div className="whitespace-pre">{JSON.stringify(session, null, 2)}</div>
</div>
}
{signInResult && !signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 rounded-lg font-mono">
<div className="font-semibold text-gray-500 mb-1">SIWF Result</div>
<div className="whitespace-pre">{JSON.stringify(signInResult, null, 2)}</div>
</div>
)}
</>
);
}
const renderError = (error: Error | null) => {
if (!error) return null;
if (error instanceof BaseError) {
const isUserRejection = error.walk(
(e) => e instanceof UserRejectedRequestError
);
if (isUserRejection) {
return <div className="text-red-500 text-xs mt-1">Rejected by user.</div>;
}
}
return <div className="text-red-500 text-xs mt-1">{error.message}</div>;
};