mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-15 15:48:56 -05:00
add initial demo app
This commit is contained in:
parent
5143ab4ce4
commit
33eea440b3
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
30
package.json
Normal file
30
package.json
Normal 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
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/splash.png
Normal file
BIN
public/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
19
src/app/.well-known/frames.json/route.ts
Normal file
19
src/app/.well-known/frames.json/route.ts
Normal 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
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
21
src/app/globals.css
Normal 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
19
src/app/layout.tsx
Normal 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
9
src/app/page.tsx
Normal 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
19
src/components/App.tsx
Normal 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
122
src/components/Demo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/WagmiProvider.tsx
Normal file
22
src/components/WagmiProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/ui/Button.tsx
Normal file
14
src/components/ui/Button.tsx
Normal 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
90
src/lib/connector.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
4
src/lib/truncateAddress.ts
Normal file
4
src/lib/truncateAddress.ts
Normal 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
18
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user