add initial demo app

This commit is contained in:
horsefacts 2024-11-23 23:19:40 -05:00 committed by lucas-neynar
parent 5143ab4ce4
commit 33eea440b3
No known key found for this signature in database
22 changed files with 5723 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# frames-v2-demo
A Farcaster Frames v2 demo app.

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "frames-v2-demo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@farcaster/frame-sdk": "^0.0.2",
"@tanstack/react-query": "^5.61.0",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"viem": "2.x",
"wagmi": "^2.13.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,19 @@
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_URL;
const config = {
config: {
version: "0.0.0",
name: "Frames v2 Demo",
icon: `${appUrl}/icon.png`,
splashImage: `${appUrl}/splash.png`,
splashBackgroundColor: "#f7f7f7",
homeUrl: appUrl,
fid: 0,
key: "",
signature: "",
},
};
return Response.json(config);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

21
src/app/globals.css Normal file
View File

@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: 'Inter', Helvetica, Arial, sans-serif;
}

19
src/app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Farcaster Frames v2 Demo",
description: "A Farcaster Frames v2 demo app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

9
src/app/page.tsx Normal file
View File

@ -0,0 +1,9 @@
import { App } from "~/components/App";
export default function Home() {
return (
<main className="min-h-screen flex flex-col p-4">
<App />
</main>
);
}

19
src/components/App.tsx Normal file
View File

@ -0,0 +1,19 @@
"use client";
import dynamic from "next/dynamic";
const Demo = dynamic(() => import("./Demo"), {
ssr: false,
});
const WagmiConfig = dynamic(() => import("./WagmiProvider"), {
ssr: false,
});
export function App() {
return (
<WagmiConfig>
<Demo />
</WagmiConfig>
);
}

122
src/components/Demo.tsx Normal file
View File

@ -0,0 +1,122 @@
import { useEffect, useCallback, useState } from "react";
import sdk, { type FrameContext } from "@farcaster/frame-sdk";
import { useAccount, useSendTransaction } from "wagmi";
import { Button } from "~/components/ui/Button";
import { truncateAddress } from "~/lib/truncateAddress";
export default function Demo() {
const [isSDKLoaded, setIsSDKLoaded] = useState(false);
const [context, setContext] = useState<FrameContext>();
const [isContextOpen, setIsContextOpen] = useState(false);
const { address, isConnected } = useAccount();
const { sendTransaction } = useSendTransaction();
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 close = useCallback(() => {
sdk.actions.close();
}, []);
const sendTx = useCallback(() => {
sendTransaction({
to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878",
data: "0x9846cd9efc000023c0",
});
}, [sendTransaction]);
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">Frames v2 Demo</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.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.close
</pre>
</div>
<Button onClick={close}>Close Frame</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>
)}
{isConnected && (
<>
<div className="mb-4">
<Button onClick={sendTx} disabled={!isConnected}>
Send Transaction
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { createConfig, http, WagmiProvider } from "wagmi";
import { base } from "wagmi/chains";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { frameConnector } from "~/lib/connector";
export const config = createConfig({
chains: [base],
transports: {
[base.id]: http(),
},
connectors: [frameConnector()],
});
const queryClient = new QueryClient();
export default function WagmiConfig({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}

View File

@ -0,0 +1,14 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
export function Button({ children, className = "", ...props }: ButtonProps) {
return (
<button
className={`w-full max-w-xs mx-auto block bg-[#7C65C1] text-white py-3 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-[#7C65C1] hover:bg-[#6952A3] ${className}`}
{...props}
>
{children}
</button>
);
}

90
src/lib/connector.ts Normal file
View File

@ -0,0 +1,90 @@
import sdk from "@farcaster/frame-sdk";
import { SwitchChainError, fromHex, getAddress, numberToHex } from "viem";
import { ChainNotConfiguredError, createConnector } from "wagmi";
frameConnector.type = "frameConnector" as const;
export function frameConnector() {
let connected = true;
return createConnector<typeof sdk.wallet.ethProvider>((config) => ({
id: "farcaster",
name: "Farcaster Wallet",
type: frameConnector.type,
async setup() {
this.connect({ chainId: config.chains[0].id });
},
async connect({ chainId } = {}) {
const provider = await this.getProvider();
const accounts = await provider.request({
method: "eth_requestAccounts",
});
let currentChainId = await this.getChainId();
if (chainId && currentChainId !== chainId) {
const chain = await this.switchChain!({ chainId });
currentChainId = chain.id;
}
connected = true;
return {
accounts: accounts.map((x) => getAddress(x)),
chainId: currentChainId,
};
},
async disconnect() {
connected = false;
},
async getAccounts() {
if (!connected) throw new Error("Not connected");
const provider = await this.getProvider();
const accounts = await provider.request({ method: "eth_accounts" });
return accounts.map((x) => getAddress(x));
},
async getChainId() {
const provider = await this.getProvider();
const hexChainId = await provider.request({ method: "eth_chainId" });
return fromHex(hexChainId, "number");
},
async isAuthorized() {
if (!connected) {
return false;
}
const accounts = await this.getAccounts();
return !!accounts.length;
},
async switchChain({ chainId }) {
const provider = await this.getProvider();
const chain = config.chains.find((x) => x.id === chainId);
if (!chain) throw new SwitchChainError(new ChainNotConfiguredError());
await provider.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: numberToHex(chainId) }],
});
return chain;
},
onAccountsChanged(accounts) {
if (accounts.length === 0) this.onDisconnect();
else
config.emitter.emit("change", {
accounts: accounts.map((x) => getAddress(x)),
});
},
onChainChanged(chain) {
const chainId = Number(chain);
config.emitter.emit("change", { chainId });
},
async onDisconnect() {
config.emitter.emit("disconnect");
connected = false;
},
async getProvider() {
return sdk.wallet.ethProvider;
},
}));
}

View File

@ -0,0 +1,4 @@
export const truncateAddress = (address: string) => {
if (!address) return "";
return `${address.slice(0, 14)}...${address.slice(-12)}`;
};

18
tailwind.config.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

5248
yarn.lock Normal file

File diff suppressed because it is too large Load Diff