├── vercel.json ├── public ├── icon.png └── splash.png ├── src ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ ├── [...nextauth] │ │ │ │ └── route.ts │ │ │ ├── nonce │ │ │ │ └── route.ts │ │ │ ├── signers │ │ │ │ └── route.ts │ │ │ ├── signer │ │ │ │ ├── route.ts │ │ │ │ └── signed_key │ │ │ │ │ └── route.ts │ │ │ ├── session-signers │ │ │ │ └── route.ts │ │ │ └── validate │ │ │ │ └── route.ts │ │ ├── opengraph-image │ │ │ └── route.tsx │ │ ├── users │ │ │ └── route.ts │ │ ├── best-friends │ │ │ └── route.ts │ │ ├── send-notification │ │ │ └── route.ts │ │ └── webhook │ │ │ └── route.ts │ ├── app.tsx │ ├── .well-known │ │ └── farcaster.json │ │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ ├── layout.NeynarAuth.tsx │ ├── providers.tsx │ ├── share │ │ └── [fid] │ │ │ └── page.tsx │ ├── providers.NeynarAuth.tsx │ └── globals.css ├── lib │ ├── truncateAddress.ts │ ├── localStorage.ts │ ├── devices.ts │ ├── kv.ts │ ├── notifs.ts │ ├── utils.ts │ ├── neynar.ts │ ├── errorUtils.tsx │ └── constants.ts ├── components │ ├── ui │ │ ├── tabs │ │ │ ├── index.ts │ │ │ ├── HomeTab.tsx │ │ │ ├── ContextTab.tsx │ │ │ ├── ActionsTab.tsx │ │ │ ├── ActionsTab.NeynarAuth.tsx │ │ │ └── WalletTab.tsx │ │ ├── wallet │ │ │ ├── index.ts │ │ │ ├── SignEvmMessage.tsx │ │ │ ├── SignSolanaMessage.tsx │ │ │ ├── SignIn.tsx │ │ │ ├── SendSolana.tsx │ │ │ └── SendEth.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── Button.tsx │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── NeynarAuthButton │ │ │ ├── ProfileButton.tsx │ │ │ ├── AuthDialog.tsx │ │ │ └── index.tsx │ │ └── Share.tsx │ ├── providers │ │ ├── SafeFarcasterSolanaProvider.tsx │ │ └── WagmiProvider.tsx │ └── App.tsx ├── hooks │ ├── useDetectClickOutside.ts │ ├── useNeynarUser.ts │ └── useQuickAuth.ts └── auth.ts ├── index.d.ts ├── next.config.ts ├── postcss.config.mjs ├── .env.example ├── components.json ├── .gitignore ├── .github └── workflows │ └── publish.yml ├── tsconfig.json ├── LICENSE ├── .eslintrc.json ├── scripts ├── cleanup.js └── dev.js ├── package.json ├── tailwind.config.ts ├── README.md └── bin └── index.js /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "next build", 3 | "framework": "nextjs" 4 | } -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neynarxyz/create-farcaster-mini-app/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neynarxyz/create-farcaster-mini-app/HEAD/public/splash.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neynarxyz/create-farcaster-mini-app/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Initialize a new Farcaster mini app project 3 | * @returns Promise 4 | */ 5 | export function init(): Promise; -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/truncateAddress.ts: -------------------------------------------------------------------------------- 1 | export const truncateAddress = (address: string) => { 2 | if (!address) return ""; 3 | return `${address.slice(0, 14)}...${address.slice(-12)}`; 4 | }; 5 | -------------------------------------------------------------------------------- /src/components/ui/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { HomeTab } from './HomeTab'; 2 | export { ActionsTab } from './ActionsTab'; 3 | export { ContextTab } from './ContextTab'; 4 | export { WalletTab } from './WalletTab'; -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth" 2 | import { authOptions } from "~/auth" 3 | 4 | const handler = NextAuth(authOptions) 5 | 6 | export { handler as GET, handler as POST } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Default values for local development 2 | # For production, update these URLs to your deployed domain 3 | KV_REST_API_TOKEN='' 4 | KV_REST_API_URL='' 5 | NEXT_PUBLIC_URL='http://localhost:3000' 6 | NEXTAUTH_URL='http://localhost:3000' 7 | -------------------------------------------------------------------------------- /src/components/ui/wallet/index.ts: -------------------------------------------------------------------------------- 1 | export { SignIn } from './SignIn'; 2 | export { SignEvmMessage } from './SignEvmMessage'; 3 | export { SendEth } from './SendEth'; 4 | export { SignSolanaMessage } from './SignSolanaMessage'; 5 | export { SendSolana } from './SendSolana'; -------------------------------------------------------------------------------- /src/app/app.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | import { APP_NAME } from "~/lib/constants"; 5 | 6 | // note: dynamic import is required for components that use the Frame SDK 7 | const AppComponent = dynamic(() => import("~/components/App"), { 8 | ssr: false, 9 | }); 10 | 11 | export default function App( 12 | { title }: { title?: string } = { title: APP_NAME } 13 | ) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/api/auth/nonce/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getNeynarClient } from '~/lib/neynar'; 3 | 4 | export async function GET() { 5 | try { 6 | const client = getNeynarClient(); 7 | const response = await client.fetchNonce(); 8 | return NextResponse.json(response); 9 | } catch (error) { 10 | console.error('Error fetching nonce:', error); 11 | return NextResponse.json( 12 | { error: 'Failed to fetch nonce' }, 13 | { status: 500 } 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/app/.well-known/farcaster.json/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getFarcasterDomainManifest } from '~/lib/utils'; 3 | 4 | export async function GET() { 5 | try { 6 | const config = await getFarcasterDomainManifest(); 7 | return NextResponse.json(config); 8 | } catch (error) { 9 | console.error('Error generating metadata:', error); 10 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; 11 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useDetectClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function useDetectClickOutside( 4 | ref: React.RefObject, 5 | callback: () => void 6 | ) { 7 | useEffect(() => { 8 | function handleClickOutside(event: MouseEvent) { 9 | if (ref.current && !ref.current.contains(event.target as Node)) { 10 | callback(); 11 | } 12 | } 13 | document.addEventListener('mousedown', handleClickOutside); 14 | return () => { 15 | document.removeEventListener('mousedown', handleClickOutside); 16 | }; 17 | }, [ref, callback]); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import '~/app/globals.css'; 4 | import { Providers } from '~/app/providers'; 5 | import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants'; 6 | 7 | export const metadata: Metadata = { 8 | title: APP_NAME, 9 | description: APP_DESCRIPTION, 10 | }; 11 | 12 | export default async function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | !.env.example 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import App from "./app"; 3 | import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants"; 4 | import { getMiniAppEmbedMetadata } from "~/lib/utils"; 5 | 6 | export const revalidate = 300; 7 | 8 | export async function generateMetadata(): Promise { 9 | return { 10 | title: APP_NAME, 11 | openGraph: { 12 | title: APP_NAME, 13 | description: APP_DESCRIPTION, 14 | images: [APP_OG_IMAGE_URL], 15 | }, 16 | other: { 17 | "fc:frame": JSON.stringify(getMiniAppEmbedMetadata()), 18 | }, 19 | }; 20 | } 21 | 22 | export default function Home() { 23 | return (); 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/localStorage.ts: -------------------------------------------------------------------------------- 1 | export function setItem(key: string, value: T) { 2 | try { 3 | localStorage.setItem(key, JSON.stringify(value)); 4 | } catch (error) { 5 | console.warn('Failed to save item:', error); 6 | } 7 | } 8 | 9 | export function getItem(key: string): T | null { 10 | try { 11 | const stored = localStorage.getItem(key); 12 | return stored ? JSON.parse(stored) : null; 13 | } catch (error) { 14 | console.warn('Failed to load item:', error); 15 | return null; 16 | } 17 | } 18 | 19 | export function removeItem(key: string) { 20 | try { 21 | localStorage.removeItem(key); 22 | } catch (error) { 23 | console.warn('Failed to remove item:', error); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 🚀 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - package.json 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '20' 22 | registry-url: 'https://registry.npmjs.org' 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Publish to npm 30 | run: npm publish --access public -------------------------------------------------------------------------------- /src/lib/devices.ts: -------------------------------------------------------------------------------- 1 | function isAndroid(): boolean { 2 | return ( 3 | typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) 4 | ); 5 | } 6 | 7 | function isSmallIOS(): boolean { 8 | return ( 9 | typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) 10 | ); 11 | } 12 | 13 | function isLargeIOS(): boolean { 14 | return ( 15 | typeof navigator !== 'undefined' && 16 | (/iPad/.test(navigator.userAgent) || 17 | (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) 18 | ); 19 | } 20 | 21 | function isIOS(): boolean { 22 | return isSmallIOS() || isLargeIOS(); 23 | } 24 | 25 | export function isMobile(): boolean { 26 | return isAndroid() || isIOS(); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/layout.NeynarAuth.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | 3 | import { getSession } from '~/auth'; 4 | import '~/app/globals.css'; 5 | import { Providers } from '~/app/providers'; 6 | import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants'; 7 | 8 | export const metadata: Metadata = { 9 | title: APP_NAME, 10 | description: APP_DESCRIPTION, 11 | }; 12 | 13 | export default async function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | const session = await getSession(); 19 | 20 | return ( 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/tabs/HomeTab.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** 4 | * HomeTab component displays the main landing content for the mini app. 5 | * 6 | * This is the default tab that users see when they first open the mini app. 7 | * It provides a simple welcome message and placeholder content that can be 8 | * customized for specific use cases. 9 | * 10 | * @example 11 | * ```tsx 12 | * 13 | * ``` 14 | */ 15 | export function HomeTab() { 16 | return ( 17 |
18 |
19 |

Put your content here!

20 |

Powered by Neynar 🪐

21 |
22 |
23 | ); 24 | } -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "~/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "~/*": ["./src/*"] 23 | } 24 | }, 25 | "ts-node": { 26 | "esm": true, 27 | "compilerOptions": { 28 | "module": "ES2020", 29 | "moduleResolution": "node" 30 | } 31 | }, 32 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "~/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import dynamic from 'next/dynamic'; 4 | import { MiniAppProvider } from '@neynar/react'; 5 | import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; 6 | import { ANALYTICS_ENABLED, RETURN_URL } from '~/lib/constants'; 7 | 8 | const WagmiProvider = dynamic( 9 | () => import('~/components/providers/WagmiProvider'), 10 | { 11 | ssr: false, 12 | } 13 | ); 14 | 15 | export function Providers({ 16 | children, 17 | }: { 18 | children: React.ReactNode; 19 | }) { 20 | const solanaEndpoint = 21 | process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; 22 | return ( 23 | 24 | 29 | 30 | {children} 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Neynar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/api/opengraph-image/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import { NextRequest } from "next/server"; 3 | import { getNeynarUser } from "~/lib/neynar"; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export async function GET(request: NextRequest) { 8 | const { searchParams } = new URL(request.url); 9 | const fid = searchParams.get('fid'); 10 | 11 | const user = fid ? await getNeynarUser(Number(fid)) : null; 12 | 13 | return new ImageResponse( 14 | ( 15 |
16 | {user?.pfp_url && ( 17 |
18 | Profile 19 |
20 | )} 21 |

{user?.display_name ? `Hello from ${user.display_name ?? user.username}!` : 'Hello!'}

22 |

Powered by Neynar 🪐

23 |
24 | ), 25 | { 26 | width: 1200, 27 | height: 800, 28 | } 29 | ); 30 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | // Disable img warnings since you're using them intentionally in specific contexts 5 | "@next/next/no-img-element": "off", 6 | 7 | // Allow @ts-ignore comments (though @ts-expect-error is preferred) 8 | "@typescript-eslint/ban-ts-comment": "off", 9 | 10 | // Allow explicit any types (sometimes necessary for dynamic imports and APIs) 11 | "@typescript-eslint/no-explicit-any": "off", 12 | 13 | // Allow unused variables that start with underscore 14 | "@typescript-eslint/no-unused-vars": [ 15 | "warn", 16 | { 17 | "argsIgnorePattern": "^_", 18 | "varsIgnorePattern": "^_", 19 | "caughtErrorsIgnorePattern": "^_" 20 | } 21 | ], 22 | 23 | // Make display name warnings instead of errors for dynamic components 24 | "react/display-name": "warn", 25 | 26 | // Allow module assignment for dynamic imports 27 | "@next/next/no-assign-module-variable": "warn", 28 | 29 | // Make exhaustive deps a warning instead of error for complex hooks 30 | "react-hooks/exhaustive-deps": "warn" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useNeynarUser.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export interface NeynarUser { 4 | fid: number; 5 | score: number; 6 | } 7 | 8 | export function useNeynarUser(context?: { user?: { fid?: number } }) { 9 | const [user, setUser] = useState(null); 10 | const [loading, setLoading] = useState(false); 11 | const [error, setError] = useState(null); 12 | 13 | useEffect(() => { 14 | if (!context?.user?.fid) { 15 | setUser(null); 16 | setError(null); 17 | return; 18 | } 19 | setLoading(true); 20 | setError(null); 21 | fetch(`/api/users?fids=${context.user.fid}`) 22 | .then((response) => { 23 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 24 | return response.json(); 25 | }) 26 | .then((data) => { 27 | if (data.users?.[0]) { 28 | setUser(data.users[0]); 29 | } else { 30 | setUser(null); 31 | } 32 | }) 33 | .catch((err) => setError(err.message)) 34 | .finally(() => setLoading(false)); 35 | }, [context?.user?.fid]); 36 | 37 | return { user, loading, error }; 38 | } -------------------------------------------------------------------------------- /src/app/share/[fid]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { redirect } from "next/navigation"; 3 | import { APP_URL, APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; 4 | import { getMiniAppEmbedMetadata } from "~/lib/utils"; 5 | export const revalidate = 300; 6 | 7 | // This is an example of how to generate a dynamically generated share page based on fid: 8 | // Sharing this route e.g. exmaple.com/share/123 will generate a share page for fid 123, 9 | // with the image dynamically generated by the opengraph-image API route. 10 | export async function generateMetadata({ 11 | params, 12 | }: { 13 | params: Promise<{ fid: string }>; 14 | }): Promise { 15 | const { fid } = await params; 16 | const imageUrl = `${APP_URL}/api/opengraph-image?fid=${fid}`; 17 | 18 | return { 19 | title: `${APP_NAME} - Share`, 20 | openGraph: { 21 | title: APP_NAME, 22 | description: APP_DESCRIPTION, 23 | images: [imageUrl], 24 | }, 25 | other: { 26 | "fc:frame": JSON.stringify(getMiniAppEmbedMetadata(imageUrl)), 27 | }, 28 | }; 29 | } 30 | 31 | export default function SharePage() { 32 | // redirect to home page 33 | redirect("/"); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/api/auth/signers/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getNeynarClient } from '~/lib/neynar'; 3 | 4 | const requiredParams = ['message', 'signature']; 5 | 6 | export async function GET(request: Request) { 7 | const { searchParams } = new URL(request.url); 8 | const params: Record = {}; 9 | for (const param of requiredParams) { 10 | params[param] = searchParams.get(param); 11 | if (!params[param]) { 12 | return NextResponse.json( 13 | { 14 | error: `${param} parameter is required`, 15 | }, 16 | { status: 400 } 17 | ); 18 | } 19 | } 20 | 21 | const message = params.message as string; 22 | const signature = params.signature as string; 23 | 24 | try { 25 | const client = getNeynarClient(); 26 | const data = await client.fetchSigners({ message, signature }); 27 | const signers = data.signers; 28 | return NextResponse.json({ 29 | signers, 30 | }); 31 | } catch (error) { 32 | console.error('Error fetching signers:', error); 33 | return NextResponse.json( 34 | { error: 'Failed to fetch signers' }, 35 | { status: 500 } 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/ui/tabs/ContextTab.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMiniApp } from "@neynar/react"; 4 | 5 | /** 6 | * ContextTab component displays the current mini app context in JSON format. 7 | * 8 | * This component provides a developer-friendly view of the Farcaster mini app context, 9 | * including user information, client details, and other contextual data. It's useful 10 | * for debugging and understanding what data is available to the mini app. 11 | * 12 | * The context includes: 13 | * - User information (FID, username, display name, profile picture) 14 | * - Client information (safe area insets, platform details) 15 | * - Mini app configuration and state 16 | * 17 | * @example 18 | * ```tsx 19 | * 20 | * ``` 21 | */ 22 | export function ContextTab() { 23 | const { context } = useMiniApp(); 24 | 25 | return ( 26 |
27 |

Context

28 |
29 |
30 |           {JSON.stringify(context, null, 2)}
31 |         
32 |
33 |
34 | ); 35 | } -------------------------------------------------------------------------------- /src/app/api/users/route.ts: -------------------------------------------------------------------------------- 1 | import { NeynarAPIClient } from '@neynar/nodejs-sdk'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export async function GET(request: Request) { 5 | const apiKey = process.env.NEYNAR_API_KEY; 6 | const { searchParams } = new URL(request.url); 7 | const fids = searchParams.get('fids'); 8 | 9 | if (!apiKey) { 10 | return NextResponse.json( 11 | { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, 12 | { status: 500 } 13 | ); 14 | } 15 | 16 | if (!fids) { 17 | return NextResponse.json( 18 | { error: 'FIDs parameter is required' }, 19 | { status: 400 } 20 | ); 21 | } 22 | 23 | try { 24 | const neynar = new NeynarAPIClient({ apiKey }); 25 | const fidsArray = fids.split(',').map(fid => parseInt(fid.trim())); 26 | 27 | const { users } = await neynar.fetchBulkUsers({ 28 | fids: fidsArray, 29 | }); 30 | 31 | return NextResponse.json({ users }); 32 | } catch (error) { 33 | console.error('Failed to fetch users:', error); 34 | return NextResponse.json( 35 | { error: 'Failed to fetch users. Please check your Neynar API key and try again.' }, 36 | { status: 500 } 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/api/auth/signer/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getNeynarClient } from '~/lib/neynar'; 3 | 4 | export async function POST() { 5 | try { 6 | const neynarClient = getNeynarClient(); 7 | const signer = await neynarClient.createSigner(); 8 | return NextResponse.json(signer); 9 | } catch (error) { 10 | console.error('Error fetching signer:', error); 11 | return NextResponse.json( 12 | { error: 'Failed to fetch signer' }, 13 | { status: 500 } 14 | ); 15 | } 16 | } 17 | 18 | export async function GET(request: Request) { 19 | const { searchParams } = new URL(request.url); 20 | const signerUuid = searchParams.get('signerUuid'); 21 | 22 | if (!signerUuid) { 23 | return NextResponse.json( 24 | { error: 'signerUuid is required' }, 25 | { status: 400 } 26 | ); 27 | } 28 | 29 | try { 30 | const neynarClient = getNeynarClient(); 31 | const signer = await neynarClient.lookupSigner({ 32 | signerUuid, 33 | }); 34 | return NextResponse.json(signer); 35 | } catch (error) { 36 | console.error('Error fetching signed key:', error); 37 | return NextResponse.json( 38 | { error: 'Failed to fetch signed key' }, 39 | { status: 500 } 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/api/auth/session-signers/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getNeynarClient } from '~/lib/neynar'; 3 | 4 | export async function GET(request: Request) { 5 | try { 6 | const { searchParams } = new URL(request.url); 7 | const message = searchParams.get('message'); 8 | const signature = searchParams.get('signature'); 9 | 10 | if (!message || !signature) { 11 | return NextResponse.json( 12 | { error: 'Message and signature are required' }, 13 | { status: 400 } 14 | ); 15 | } 16 | 17 | const client = getNeynarClient(); 18 | const data = await client.fetchSigners({ message, signature }); 19 | const signers = data.signers; 20 | 21 | // Fetch user data if signers exist 22 | let user = null; 23 | if (signers && signers.length > 0 && signers[0].fid) { 24 | const { 25 | users: [fetchedUser], 26 | } = await client.fetchBulkUsers({ 27 | fids: [signers[0].fid], 28 | }); 29 | user = fetchedUser; 30 | } 31 | 32 | return NextResponse.json({ 33 | signers, 34 | user, 35 | }); 36 | } catch (error) { 37 | console.error('Error in session-signers API:', error); 38 | return NextResponse.json( 39 | { error: 'Failed to fetch signers' }, 40 | { status: 500 } 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | interface ButtonProps extends React.ButtonHTMLAttributes { 2 | children: React.ReactNode; 3 | isLoading?: boolean; 4 | variant?: 'primary' | 'secondary' | 'outline'; 5 | size?: 'sm' | 'md' | 'lg'; 6 | } 7 | 8 | export function Button({ 9 | children, 10 | className = "", 11 | isLoading = false, 12 | variant = 'primary', 13 | size = 'md', 14 | ...props 15 | }: ButtonProps) { 16 | const baseClasses = "btn"; 17 | 18 | const variantClasses = { 19 | primary: "btn-primary", 20 | secondary: "btn-secondary", 21 | outline: "btn-outline" 22 | }; 23 | 24 | const sizeClasses = { 25 | sm: "px-3 py-1.5 text-xs", 26 | md: "px-4 py-2 text-sm", 27 | lg: "px-6 py-3 text-base" 28 | }; 29 | 30 | const fullWidthClasses = "w-full max-w-xs mx-auto block"; 31 | 32 | const combinedClasses = [ 33 | baseClasses, 34 | variantClasses[variant], 35 | sizeClasses[size], 36 | fullWidthClasses, 37 | className 38 | ].join(' '); 39 | 40 | return ( 41 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/app/api/auth/validate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { createClient, Errors } from '@farcaster/quick-auth'; 3 | 4 | const client = createClient(); 5 | 6 | export async function POST(request: Request) { 7 | try { 8 | const { token } = await request.json(); 9 | 10 | if (!token) { 11 | return NextResponse.json({ error: 'Token is required' }, { status: 400 }); 12 | } 13 | 14 | // Get domain from environment or request 15 | const domain = process.env.NEXT_PUBLIC_URL 16 | ? new URL(process.env.NEXT_PUBLIC_URL).hostname 17 | : request.headers.get('host') || 'localhost'; 18 | 19 | try { 20 | // Use the official QuickAuth library to verify the JWT 21 | const payload = await client.verifyJwt({ 22 | token, 23 | domain, 24 | }); 25 | 26 | return NextResponse.json({ 27 | success: true, 28 | user: { 29 | fid: payload.sub, 30 | }, 31 | }); 32 | } catch (e) { 33 | if (e instanceof Errors.InvalidTokenError) { 34 | console.info('Invalid token:', e.message); 35 | return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); 36 | } 37 | throw e; 38 | } 39 | } catch (error) { 40 | console.error('Token validation error:', error); 41 | return NextResponse.json( 42 | { error: 'Internal server error' }, 43 | { status: 500 }, 44 | ); 45 | } 46 | } -------------------------------------------------------------------------------- /scripts/cleanup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execSync } from 'child_process'; 4 | 5 | // Parse arguments 6 | const args = process.argv.slice(2); 7 | let port = 3000; // default port 8 | 9 | // Look for --port=XXXX, --port XXXX, -p=XXXX, or -p XXXX 10 | args.forEach((arg, index) => { 11 | if (arg.startsWith('--port=')) { 12 | port = arg.split('=')[1]; 13 | } else if (arg === '--port' && args[index + 1]) { 14 | port = args[index + 1]; 15 | } else if (arg.startsWith('-p=')) { 16 | port = arg.split('=')[1]; 17 | } else if (arg === '-p' && args[index + 1]) { 18 | port = args[index + 1]; 19 | } 20 | }); 21 | 22 | try { 23 | console.log(`Checking for processes on port ${port}...`); 24 | 25 | // Find processes using the port 26 | const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim(); 27 | 28 | if (pids) { 29 | console.log(`Found processes: ${pids.replace(/\n/g, ', ')}`); 30 | 31 | // Kill the processes 32 | execSync(`kill -9 ${pids.replace(/\n/g, ' ')}`); 33 | console.log(`✓ Processes on port ${port} have been terminated`); 34 | } else { 35 | console.log(`No processes found on port ${port}`); 36 | } 37 | } catch (error) { 38 | if (error.status === 1) { 39 | // lsof returns status 1 when no processes found 40 | console.log(`No processes found on port ${port}`); 41 | } else { 42 | console.error(`Error: ${error.message}`); 43 | process.exit(1); 44 | } 45 | } -------------------------------------------------------------------------------- /src/app/providers.NeynarAuth.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import dynamic from 'next/dynamic'; 4 | import type { Session } from 'next-auth'; 5 | import { SessionProvider } from 'next-auth/react'; 6 | import { AuthKitProvider } from '@farcaster/auth-kit'; 7 | import { MiniAppProvider } from '@neynar/react'; 8 | import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; 9 | import { ANALYTICS_ENABLED, RETURN_URL } from '~/lib/constants'; 10 | 11 | const WagmiProvider = dynamic( 12 | () => import('~/components/providers/WagmiProvider'), 13 | { 14 | ssr: false, 15 | } 16 | ); 17 | 18 | export function Providers({ 19 | session, 20 | children, 21 | }: { 22 | session: Session | null; 23 | children: React.ReactNode; 24 | }) { 25 | const solanaEndpoint = 26 | process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; 27 | return ( 28 | 29 | 30 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/api/best-friends/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET(request: Request) { 4 | const apiKey = process.env.NEYNAR_API_KEY; 5 | const { searchParams } = new URL(request.url); 6 | const fid = searchParams.get('fid'); 7 | 8 | if (!apiKey) { 9 | return NextResponse.json( 10 | { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, 11 | { status: 500 } 12 | ); 13 | } 14 | 15 | if (!fid) { 16 | return NextResponse.json( 17 | { error: 'FID parameter is required' }, 18 | { status: 400 } 19 | ); 20 | } 21 | 22 | try { 23 | const response = await fetch( 24 | `https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`, 25 | { 26 | headers: { 27 | "x-api-key": apiKey, 28 | }, 29 | } 30 | ); 31 | 32 | if (!response.ok) { 33 | throw new Error(`Neynar API error: ${response.statusText}`); 34 | } 35 | 36 | const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] }; 37 | 38 | return NextResponse.json({ bestFriends: users }); 39 | } catch (error) { 40 | console.error('Failed to fetch best friends:', error); 41 | return NextResponse.json( 42 | { error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' }, 43 | { status: 500 } 44 | ); 45 | } 46 | } -------------------------------------------------------------------------------- /src/lib/kv.ts: -------------------------------------------------------------------------------- 1 | import { MiniAppNotificationDetails } from '@farcaster/miniapp-sdk'; 2 | import { Redis } from '@upstash/redis'; 3 | import { APP_NAME } from './constants'; 4 | 5 | // In-memory fallback storage 6 | const localStore = new Map(); 7 | 8 | // Use Redis if KV env vars are present, otherwise use in-memory 9 | const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN; 10 | const redis = useRedis 11 | ? new Redis({ 12 | url: process.env.KV_REST_API_URL!, 13 | token: process.env.KV_REST_API_TOKEN!, 14 | }) 15 | : null; 16 | 17 | function getUserNotificationDetailsKey(fid: number): string { 18 | return `${APP_NAME}:user:${fid}`; 19 | } 20 | 21 | export async function getUserNotificationDetails( 22 | fid: number 23 | ): Promise { 24 | const key = getUserNotificationDetailsKey(fid); 25 | if (redis) { 26 | return await redis.get(key); 27 | } 28 | return localStore.get(key) || null; 29 | } 30 | 31 | export async function setUserNotificationDetails( 32 | fid: number, 33 | notificationDetails: MiniAppNotificationDetails 34 | ): Promise { 35 | const key = getUserNotificationDetailsKey(fid); 36 | if (redis) { 37 | await redis.set(key, notificationDetails); 38 | } else { 39 | localStore.set(key, notificationDetails); 40 | } 41 | } 42 | 43 | export async function deleteUserNotificationDetails( 44 | fid: number 45 | ): Promise { 46 | const key = getUserNotificationDetailsKey(fid); 47 | if (redis) { 48 | await redis.del(key); 49 | } else { 50 | localStore.delete(key); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@neynar/create-farcaster-mini-app", 3 | "version": "1.9.1", 4 | "type": "module", 5 | "private": false, 6 | "access": "public", 7 | "exports": { 8 | ".": { 9 | "types": "./index.d.ts", 10 | "import": "./bin/init.js" 11 | } 12 | }, 13 | "types": "./index.d.ts", 14 | "files": [ 15 | "bin/index.js", 16 | "bin/init.js", 17 | "index.d.ts" 18 | ], 19 | "keywords": [ 20 | "farcaster", 21 | "frames", 22 | "frame", 23 | "frames-v2", 24 | "farcaster-frames", 25 | "miniapps", 26 | "miniapp", 27 | "mini-apps", 28 | "mini-app", 29 | "neynar", 30 | "web3" 31 | ], 32 | "scripts": { 33 | "dev": "node scripts/dev.js", 34 | "build": "next build", 35 | "build:raw": "next build", 36 | "start": "next start", 37 | "lint": "next lint", 38 | "deploy:vercel": "tsx scripts/deploy.ts", 39 | "deploy:raw": "vercel --prod", 40 | "cleanup": "node scripts/cleanup.js" 41 | }, 42 | "bin": { 43 | "@neynar/create-farcaster-mini-app": "./bin/index.js" 44 | }, 45 | "dependencies": { 46 | "dotenv": "^16.4.7", 47 | "inquirer": "^12.4.3", 48 | "viem": "^2.23.6" 49 | }, 50 | "devDependencies": { 51 | "@neynar/nodejs-sdk": "^2.19.0", 52 | "@types/node": "^22.13.10", 53 | "typescript": "^5.6.3" 54 | }, 55 | "overrides": { 56 | "chalk": "5.3.0", 57 | "strip-ansi": "6.0.1", 58 | "wrap-ansi": "8.1.0", 59 | "ansi-styles": "6.2.3", 60 | "color-convert": "2.0.1", 61 | "color-name": "1.1.4", 62 | "is-core-module": "2.13.1", 63 | "error-ex": "1.3.2", 64 | "simple-swizzle": "0.2.2", 65 | "has-ansi": "5.0.1" 66 | } 67 | } -------------------------------------------------------------------------------- /src/lib/notifs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SendNotificationRequest, 3 | sendNotificationResponseSchema, 4 | } from "@farcaster/miniapp-sdk"; 5 | import { getUserNotificationDetails } from "~/lib/kv"; 6 | import { APP_URL } from "./constants"; 7 | 8 | type SendMiniAppNotificationResult = 9 | | { 10 | state: "error"; 11 | error: unknown; 12 | } 13 | | { state: "no_token" } 14 | | { state: "rate_limit" } 15 | | { state: "success" }; 16 | 17 | export async function sendMiniAppNotification({ 18 | fid, 19 | title, 20 | body, 21 | }: { 22 | fid: number; 23 | title: string; 24 | body: string; 25 | }): Promise { 26 | const notificationDetails = await getUserNotificationDetails(fid); 27 | if (!notificationDetails) { 28 | return { state: "no_token" }; 29 | } 30 | 31 | const response = await fetch(notificationDetails.url, { 32 | method: "POST", 33 | headers: { 34 | "Content-Type": "application/json", 35 | }, 36 | body: JSON.stringify({ 37 | notificationId: crypto.randomUUID(), 38 | title, 39 | body, 40 | targetUrl: APP_URL, 41 | tokens: [notificationDetails.token], 42 | } satisfies SendNotificationRequest), 43 | }); 44 | 45 | const responseJson = await response.json(); 46 | 47 | if (response.status === 200) { 48 | const responseBody = sendNotificationResponseSchema.safeParse(responseJson); 49 | if (responseBody.success === false) { 50 | // Malformed response 51 | return { state: "error", error: responseBody.error.errors }; 52 | } 53 | 54 | if (responseBody.data.result.rateLimitedTokens.length) { 55 | // Rate limited 56 | return { state: "rate_limit" }; 57 | } 58 | 59 | return { state: "success" }; 60 | } else { 61 | // Error response 62 | return { state: "error", error: responseJson }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | import { Manifest } from '@farcaster/miniapp-core/src/manifest'; 4 | import { 5 | APP_BUTTON_TEXT, 6 | APP_DESCRIPTION, 7 | APP_ICON_URL, 8 | APP_NAME, 9 | APP_OG_IMAGE_URL, 10 | APP_PRIMARY_CATEGORY, 11 | APP_SPLASH_BACKGROUND_COLOR, 12 | APP_SPLASH_URL, 13 | APP_TAGS, 14 | APP_URL, 15 | APP_WEBHOOK_URL, 16 | APP_ACCOUNT_ASSOCIATION, 17 | } from './constants'; 18 | 19 | export function cn(...inputs: ClassValue[]) { 20 | return twMerge(clsx(inputs)); 21 | } 22 | 23 | export function getMiniAppEmbedMetadata(ogImageUrl?: string) { 24 | return { 25 | version: 'next', 26 | imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL, 27 | ogTitle: APP_NAME, 28 | ogDescription: APP_DESCRIPTION, 29 | ogImageUrl: ogImageUrl ?? APP_OG_IMAGE_URL, 30 | button: { 31 | title: APP_BUTTON_TEXT, 32 | action: { 33 | type: 'launch_frame', 34 | name: APP_NAME, 35 | url: APP_URL, 36 | splashImageUrl: APP_SPLASH_URL, 37 | iconUrl: APP_ICON_URL, 38 | splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR, 39 | description: APP_DESCRIPTION, 40 | primaryCategory: APP_PRIMARY_CATEGORY, 41 | tags: APP_TAGS, 42 | }, 43 | }, 44 | }; 45 | } 46 | 47 | export async function getFarcasterDomainManifest(): Promise { 48 | return { 49 | accountAssociation: APP_ACCOUNT_ASSOCIATION!, 50 | miniapp: { 51 | version: '1', 52 | name: APP_NAME ?? 'Neynar Starter Kit', 53 | homeUrl: APP_URL, 54 | iconUrl: APP_ICON_URL, 55 | imageUrl: APP_OG_IMAGE_URL, 56 | buttonTitle: APP_BUTTON_TEXT ?? 'Launch Mini App', 57 | splashImageUrl: APP_SPLASH_URL, 58 | splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR, 59 | webhookUrl: APP_WEBHOOK_URL, 60 | }, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | /** 4 | * Tailwind CSS Configuration 5 | * 6 | * This configuration centralizes all theme colors for the mini app. 7 | * To change the app's color scheme, simply update the 'primary' color value below. 8 | * 9 | * Example theme changes: 10 | * - Blue theme: primary: "#3182CE" 11 | * - Green theme: primary: "#059669" 12 | * - Red theme: primary: "#DC2626" 13 | * - Orange theme: primary: "#EA580C" 14 | */ 15 | export default { 16 | darkMode: "media", 17 | content: [ 18 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 19 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 20 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 21 | ], 22 | theme: { 23 | extend: { 24 | colors: { 25 | // Main theme color - change this to update the entire app's color scheme 26 | primary: "#8b5cf6", // Main brand color 27 | "primary-light": "#a78bfa", // For hover states 28 | "primary-dark": "#7c3aed", // For active states 29 | 30 | // Secondary colors for backgrounds and text 31 | secondary: "#f8fafc", // Light backgrounds 32 | "secondary-dark": "#334155", // Dark backgrounds 33 | 34 | // Legacy CSS variables for backward compatibility 35 | background: 'var(--background)', 36 | foreground: 'var(--foreground)' 37 | }, 38 | borderRadius: { 39 | lg: 'var(--radius)', 40 | md: 'calc(var(--radius) - 2px)', 41 | sm: 'calc(var(--radius) - 4px)' 42 | }, 43 | // Custom spacing for consistent layout 44 | spacing: { 45 | '18': '4.5rem', 46 | '88': '22rem', 47 | }, 48 | // Custom container sizes 49 | maxWidth: { 50 | 'xs': '20rem', 51 | 'sm': '24rem', 52 | 'md': '28rem', 53 | 'lg': '32rem', 54 | 'xl': '36rem', 55 | '2xl': '42rem', 56 | } 57 | } 58 | }, 59 | plugins: [require("tailwindcss-animate")], 60 | } satisfies Config; 61 | -------------------------------------------------------------------------------- /src/app/api/send-notification/route.ts: -------------------------------------------------------------------------------- 1 | import { notificationDetailsSchema } from "@farcaster/miniapp-sdk"; 2 | import { NextRequest } from "next/server"; 3 | import { z } from "zod"; 4 | import { setUserNotificationDetails } from "~/lib/kv"; 5 | import { sendMiniAppNotification } from "~/lib/notifs"; 6 | import { sendNeynarMiniAppNotification } from "~/lib/neynar"; 7 | 8 | const requestSchema = z.object({ 9 | fid: z.number(), 10 | notificationDetails: notificationDetailsSchema, 11 | }); 12 | 13 | export async function POST(request: NextRequest) { 14 | // If Neynar is enabled, we don't need to store notification details 15 | // as they will be managed by Neynar's system 16 | const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; 17 | 18 | const requestJson = await request.json(); 19 | const requestBody = requestSchema.safeParse(requestJson); 20 | 21 | if (requestBody.success === false) { 22 | return Response.json( 23 | { success: false, errors: requestBody.error.errors }, 24 | { status: 400 } 25 | ); 26 | } 27 | 28 | // Only store notification details if not using Neynar 29 | if (!neynarEnabled) { 30 | await setUserNotificationDetails( 31 | Number(requestBody.data.fid), 32 | requestBody.data.notificationDetails 33 | ); 34 | } 35 | 36 | // Use appropriate notification function based on Neynar status 37 | const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification; 38 | const sendResult = await sendNotification({ 39 | fid: Number(requestBody.data.fid), 40 | title: "Test notification", 41 | body: "Sent at " + new Date().toISOString(), 42 | }); 43 | 44 | if (sendResult.state === "error") { 45 | return Response.json( 46 | { success: false, error: sendResult.error }, 47 | { status: 500 } 48 | ); 49 | } else if (sendResult.state === "rate_limit") { 50 | return Response.json( 51 | { success: false, error: "Rate limited" }, 52 | { status: 429 } 53 | ); 54 | } 55 | 56 | return Response.json({ success: true }); 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/neynar.ts: -------------------------------------------------------------------------------- 1 | import { NeynarAPIClient, Configuration, WebhookUserCreated } from '@neynar/nodejs-sdk'; 2 | import { APP_URL } from './constants'; 3 | 4 | let neynarClient: NeynarAPIClient | null = null; 5 | 6 | // Example usage: 7 | // const client = getNeynarClient(); 8 | // const user = await client.lookupUserByFid(fid); 9 | export function getNeynarClient() { 10 | if (!neynarClient) { 11 | const apiKey = process.env.NEYNAR_API_KEY; 12 | if (!apiKey) { 13 | throw new Error('NEYNAR_API_KEY not configured'); 14 | } 15 | const config = new Configuration({ apiKey }); 16 | neynarClient = new NeynarAPIClient(config); 17 | } 18 | return neynarClient; 19 | } 20 | 21 | type User = WebhookUserCreated['data']; 22 | 23 | export async function getNeynarUser(fid: number): Promise { 24 | try { 25 | const client = getNeynarClient(); 26 | const usersResponse = await client.fetchBulkUsers({ fids: [fid] }); 27 | return usersResponse.users[0]; 28 | } catch (error) { 29 | console.error('Error getting Neynar user:', error); 30 | return null; 31 | } 32 | } 33 | 34 | type SendMiniAppNotificationResult = 35 | | { 36 | state: "error"; 37 | error: unknown; 38 | } 39 | | { state: "no_token" } 40 | | { state: "rate_limit" } 41 | | { state: "success" }; 42 | 43 | export async function sendNeynarMiniAppNotification({ 44 | fid, 45 | title, 46 | body, 47 | }: { 48 | fid: number; 49 | title: string; 50 | body: string; 51 | }): Promise { 52 | try { 53 | const client = getNeynarClient(); 54 | const targetFids = [fid]; 55 | const notification = { 56 | title, 57 | body, 58 | target_url: APP_URL, 59 | }; 60 | 61 | const result = await client.publishFrameNotifications({ 62 | targetFids, 63 | notification 64 | }); 65 | 66 | if (result.notification_deliveries.length > 0) { 67 | return { state: "success" }; 68 | } else if (result.notification_deliveries.length === 0) { 69 | return { state: "no_token" }; 70 | } else { 71 | return { state: "error", error: result || "Unknown error" }; 72 | } 73 | } catch (error) { 74 | return { state: "error", error }; 75 | } 76 | } -------------------------------------------------------------------------------- /src/components/ui/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tab } from "~/components/App"; 3 | 4 | interface FooterProps { 5 | activeTab: Tab; 6 | setActiveTab: (tab: Tab) => void; 7 | showWallet?: boolean; 8 | } 9 | 10 | export const Footer: React.FC = ({ activeTab, setActiveTab, showWallet = false }) => ( 11 |
12 |
13 | 22 | 31 | 40 | {showWallet && ( 41 | 50 | )} 51 |
52 |
53 | ); 54 | -------------------------------------------------------------------------------- /src/lib/errorUtils.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement } from "react"; 2 | import { BaseError, UserRejectedRequestError } from "viem"; 3 | 4 | /** 5 | * Renders an error object in a user-friendly format. 6 | * 7 | * This utility function takes an error object and renders it as a React element 8 | * with consistent styling. It handles different types of errors including: 9 | * - Error objects with message properties 10 | * - Objects with error properties 11 | * - String errors 12 | * - Unknown error types 13 | * - User rejection errors (special handling for wallet rejections) 14 | * 15 | * The rendered error is displayed in a gray container with monospace font 16 | * for better readability of technical error details. User rejections are 17 | * displayed with a simpler, more user-friendly message. 18 | * 19 | * @param error - The error object to render 20 | * @returns ReactElement - A styled error display component, or null if no error 21 | * 22 | * @example 23 | * ```tsx 24 | * {isError && renderError(error)} 25 | * ``` 26 | */ 27 | export function renderError(error: unknown): ReactElement | null { 28 | // Handle null/undefined errors 29 | if (!error) return null; 30 | 31 | // Special handling for user rejections in wallet operations 32 | if (error instanceof BaseError) { 33 | const isUserRejection = error.walk( 34 | (e) => e instanceof UserRejectedRequestError 35 | ); 36 | 37 | if (isUserRejection) { 38 | return ( 39 |
40 |
User Rejection
41 |
Transaction was rejected by user.
42 |
43 | ); 44 | } 45 | } 46 | 47 | // Extract error message from different error types 48 | let errorMessage: string; 49 | 50 | if (error instanceof Error) { 51 | errorMessage = error.message; 52 | } else if (typeof error === 'object' && error !== null && 'error' in error) { 53 | errorMessage = String(error.error); 54 | } else if (typeof error === 'string') { 55 | errorMessage = error; 56 | } else { 57 | errorMessage = 'Unknown error occurred'; 58 | } 59 | 60 | return ( 61 |
62 |
Error
63 |
{errorMessage}
64 |
65 | ); 66 | } -------------------------------------------------------------------------------- /src/components/providers/SafeFarcasterSolanaProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useState } from "react"; 2 | import dynamic from "next/dynamic"; 3 | import { sdk } from '@farcaster/miniapp-sdk'; 4 | 5 | const FarcasterSolanaProvider = dynamic( 6 | () => import('@farcaster/mini-app-solana').then(mod => mod.FarcasterSolanaProvider), 7 | { ssr: false } 8 | ); 9 | 10 | type SafeFarcasterSolanaProviderProps = { 11 | endpoint: string; 12 | children: React.ReactNode; 13 | }; 14 | 15 | const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ hasSolanaProvider: false }); 16 | 17 | export function SafeFarcasterSolanaProvider({ endpoint, children }: SafeFarcasterSolanaProviderProps) { 18 | const isClient = typeof window !== "undefined"; 19 | const [hasSolanaProvider, setHasSolanaProvider] = useState(false); 20 | const [checked, setChecked] = useState(false); 21 | 22 | useEffect(() => { 23 | if (!isClient) return; 24 | let cancelled = false; 25 | (async () => { 26 | try { 27 | const provider = await sdk.wallet.getSolanaProvider(); 28 | if (!cancelled) { 29 | setHasSolanaProvider(!!provider); 30 | } 31 | } catch { 32 | if (!cancelled) { 33 | setHasSolanaProvider(false); 34 | } 35 | } finally { 36 | if (!cancelled) { 37 | setChecked(true); 38 | } 39 | } 40 | })(); 41 | return () => { 42 | cancelled = true; 43 | }; 44 | }, [isClient]); 45 | 46 | useEffect(() => { 47 | let errorShown = false; 48 | const origError = console.error; 49 | console.error = (...args) => { 50 | if ( 51 | typeof args[0] === "string" && 52 | args[0].includes("WalletConnectionError: could not get Solana provider") 53 | ) { 54 | if (!errorShown) { 55 | origError(...args); 56 | errorShown = true; 57 | } 58 | return; 59 | } 60 | origError(...args); 61 | }; 62 | return () => { 63 | console.error = origError; 64 | }; 65 | }, []); 66 | 67 | if (!isClient || !checked) { 68 | return null; 69 | } 70 | 71 | return ( 72 | 73 | {hasSolanaProvider ? ( 74 | 75 | {children} 76 | 77 | ) : ( 78 | <>{children} 79 | )} 80 | 81 | ); 82 | } 83 | 84 | export function useHasSolanaProvider() { 85 | return React.useContext(SolanaProviderContext).hasSolanaProvider; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/ui/wallet/SignEvmMessage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback } from "react"; 4 | import { useAccount, useConnect, useSignMessage } from "wagmi"; 5 | import { base } from "wagmi/chains"; 6 | import { Button } from "../Button"; 7 | import { config } from "../../providers/WagmiProvider"; 8 | import { APP_NAME } from "../../../lib/constants"; 9 | import { renderError } from "../../../lib/errorUtils"; 10 | 11 | /** 12 | * SignEvmMessage component handles signing messages on EVM-compatible chains. 13 | * 14 | * This component provides a simple interface for users to sign messages using 15 | * their connected EVM wallet. It automatically handles wallet connection if 16 | * the user is not already connected, and displays the signature result. 17 | * 18 | * Features: 19 | * - Automatic wallet connection if needed 20 | * - Message signing with app name 21 | * - Error handling and display 22 | * - Signature result display 23 | * 24 | * @example 25 | * ```tsx 26 | * 27 | * ``` 28 | */ 29 | export function SignEvmMessage() { 30 | // --- Hooks --- 31 | const { isConnected } = useAccount(); 32 | const { connectAsync } = useConnect(); 33 | const { 34 | signMessage, 35 | data: evmMessageSignature, 36 | error: evmSignMessageError, 37 | isError: isEvmSignMessageError, 38 | isPending: isEvmSignMessagePending, 39 | } = useSignMessage(); 40 | 41 | // --- Handlers --- 42 | /** 43 | * Handles the message signing process. 44 | * 45 | * This function first ensures the user is connected to an EVM wallet, 46 | * then requests them to sign a message containing the app name. 47 | * If the user is not connected, it automatically connects using the 48 | * Farcaster Frame connector. 49 | * 50 | * @returns Promise 51 | */ 52 | const signEvmMessage = useCallback(async () => { 53 | if (!isConnected) { 54 | await connectAsync({ 55 | chainId: base.id, 56 | connector: config.connectors[0], 57 | }); 58 | } 59 | 60 | signMessage({ message: `Hello from ${APP_NAME}!` }); 61 | }, [connectAsync, isConnected, signMessage]); 62 | 63 | // --- Render --- 64 | return ( 65 | <> 66 | 73 | {isEvmSignMessageError && renderError(evmSignMessageError)} 74 | {evmMessageSignature && ( 75 |
76 |
Signature: {evmMessageSignature}
77 |
78 | )} 79 | 80 | ); 81 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Farcaster Mini Apps (formerly Frames v2) Quickstart by Neynar 🪐 2 | 3 | A Farcaster Mini Apps quickstart npx script. 4 | 5 | This is a [NextJS](https://nextjs.org/) + TypeScript + React app. 6 | 7 | ## Guide 8 | 9 | Check out [this Neynar docs page](https://docs.neynar.com/docs/create-farcaster-miniapp-in-60s) for a simple guide on how to create a Farcaster Mini App in less than 60 seconds! 10 | 11 | ## Getting Started 12 | 13 | To create a new mini app project, run: 14 | ```{bash} 15 | npx @neynar/create-farcaster-mini-app@latest 16 | ``` 17 | 18 | To run the project: 19 | ```{bash} 20 | cd 21 | npm run dev 22 | ``` 23 | 24 | ### Importing the CLI 25 | To invoke the CLI directly in JavaScript, add the npm package to your project and use the following import statement: 26 | ```{javascript} 27 | import { init } from '@neynar/create-farcaster-mini-app'; 28 | ``` 29 | 30 | ## Deploying to Vercel 31 | For projects that have made minimal changes to the quickstart template, deploy to vercel by running: 32 | ```{bash} 33 | npm run deploy:vercel 34 | ``` 35 | 36 | ## Building for Production 37 | 38 | To create a production build, run: 39 | ```{bash} 40 | npm run build 41 | ``` 42 | 43 | The above command will generate a `.env` file based on the `.env.local` file and user input. Be sure to configure those environment variables on your hosting platform. 44 | 45 | ## Developing Script Locally 46 | 47 | This section is only for working on the script and template. If you simply want to create a mini app and _use_ the template, this section is not for you. 48 | 49 | ### Recommended: Using `npm link` for Local Development 50 | 51 | To iterate on the CLI and test changes in a generated app without publishing to npm: 52 | 53 | 1. In your installer/template repo (this repo), run: 54 | ```bash 55 | npm link 56 | ``` 57 | This makes your local version globally available as a symlinked package. 58 | 59 | 60 | 1. Now, when you run: 61 | ```bash 62 | npx @neynar/create-farcaster-mini-app 63 | ``` 64 | ...it will use your local changes (including any edits to `init.js` or other files) instead of the published npm version. 65 | 66 | ### Alternative: Running the Script Directly 67 | 68 | You can also run the script directly for quick iteration: 69 | 70 | ```bash 71 | node ./bin/index.js 72 | ``` 73 | 74 | However, this does not fully replicate the npx install flow and may not catch all issues that would occur in a real user environment. 75 | 76 | ### Environment Variables and Scripts 77 | 78 | If you update environment variable handling, remember to replicate any changes in the `dev`, `build`, and `deploy` scripts as needed. The `build` and `deploy` scripts may need further updates and are less critical for most development workflows. 79 | 80 | -------------------------------------------------------------------------------- /src/app/api/auth/signer/signed_key/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getNeynarClient } from '~/lib/neynar'; 3 | import { mnemonicToAccount } from 'viem/accounts'; 4 | import { 5 | SIGNED_KEY_REQUEST_TYPE, 6 | SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, 7 | } from '~/lib/constants'; 8 | 9 | const postRequiredFields = ['signerUuid', 'publicKey']; 10 | 11 | export async function POST(request: Request) { 12 | const body = await request.json(); 13 | 14 | // Validate required fields 15 | for (const field of postRequiredFields) { 16 | if (!body[field]) { 17 | return NextResponse.json( 18 | { error: `${field} is required` }, 19 | { status: 400 } 20 | ); 21 | } 22 | } 23 | 24 | const { signerUuid, publicKey, redirectUrl } = body; 25 | 26 | if (redirectUrl && typeof redirectUrl !== 'string') { 27 | return NextResponse.json( 28 | { error: 'redirectUrl must be a string' }, 29 | { status: 400 } 30 | ); 31 | } 32 | 33 | try { 34 | // Get the app's account from seed phrase 35 | const seedPhrase = process.env.SEED_PHRASE; 36 | const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; 37 | 38 | if (!seedPhrase) { 39 | return NextResponse.json( 40 | { error: 'App configuration missing (SEED_PHRASE or FID)' }, 41 | { status: 500 } 42 | ); 43 | } 44 | 45 | const neynarClient = getNeynarClient(); 46 | 47 | const account = mnemonicToAccount(seedPhrase); 48 | 49 | const { 50 | user: { fid }, 51 | } = await neynarClient.lookupUserByCustodyAddress({ 52 | custodyAddress: account.address, 53 | }); 54 | 55 | const appFid = fid; 56 | 57 | // Generate deadline (24 hours from now) 58 | const deadline = Math.floor(Date.now() / 1000) + 86400; 59 | 60 | // Generate EIP-712 signature 61 | const signature = await account.signTypedData({ 62 | domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, 63 | types: { 64 | SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, 65 | }, 66 | primaryType: 'SignedKeyRequest', 67 | message: { 68 | requestFid: BigInt(appFid), 69 | key: publicKey, 70 | deadline: BigInt(deadline), 71 | }, 72 | }); 73 | 74 | const signer = await neynarClient.registerSignedKey({ 75 | appFid, 76 | deadline, 77 | signature, 78 | signerUuid, 79 | ...(redirectUrl && { redirectUrl }), 80 | ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), 81 | }); 82 | 83 | return NextResponse.json(signer); 84 | } catch (error) { 85 | console.error('Error registering signed key:', error); 86 | return NextResponse.json( 87 | { error: 'Failed to register signed key' }, 88 | { status: 500 } 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { APP_NAME } from "~/lib/constants"; 5 | import sdk from "@farcaster/miniapp-sdk"; 6 | import { useMiniApp } from "@neynar/react"; 7 | 8 | type HeaderProps = { 9 | neynarUser?: { 10 | fid: number; 11 | score: number; 12 | } | null; 13 | }; 14 | 15 | export function Header({ neynarUser }: HeaderProps) { 16 | const { context } = useMiniApp(); 17 | const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); 18 | 19 | return ( 20 |
21 |
24 |
25 | Welcome to {APP_NAME}! 26 |
27 | {context?.user && ( 28 |
{ 31 | setIsUserDropdownOpen(!isUserDropdownOpen); 32 | }} 33 | > 34 | {context.user.pfpUrl && ( 35 | Profile 40 | )} 41 |
42 | )} 43 |
44 | {context?.user && ( 45 | <> 46 | {isUserDropdownOpen && ( 47 |
48 |
49 |
50 |

sdk.actions.viewProfile({ fid: context.user.fid })} 53 | > 54 | {context.user.displayName || context.user.username} 55 |

56 |

57 | @{context.user.username} 58 |

59 |

60 | FID: {context.user.fid} 61 |

62 | {neynarUser && ( 63 | <> 64 |

65 | Neynar Score: {neynarUser.score} 66 |

67 | 68 | )} 69 |
70 |
71 |
72 | )} 73 | 74 | )} 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/ui/wallet/SignSolanaMessage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useState } from "react"; 4 | import { Button } from "../Button"; 5 | import { renderError } from "../../../lib/errorUtils"; 6 | 7 | interface SignSolanaMessageProps { 8 | signMessage?: (message: Uint8Array) => Promise; 9 | } 10 | 11 | /** 12 | * SignSolanaMessage component handles signing messages on Solana. 13 | * 14 | * This component provides a simple interface for users to sign messages using 15 | * their connected Solana wallet. It accepts a signMessage function as a prop 16 | * and handles the complete signing flow including error handling. 17 | * 18 | * Features: 19 | * - Message signing with Solana wallet 20 | * - Error handling and display 21 | * - Signature result display (base64 encoded) 22 | * - Loading state management 23 | * 24 | * @param props - Component props 25 | * @param props.signMessage - Function to sign messages with Solana wallet 26 | * 27 | * @example 28 | * ```tsx 29 | * 30 | * ``` 31 | */ 32 | export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) { 33 | // --- State --- 34 | const [signature, setSignature] = useState(); 35 | const [signError, setSignError] = useState(); 36 | const [signPending, setSignPending] = useState(false); 37 | 38 | // --- Handlers --- 39 | /** 40 | * Handles the Solana message signing process. 41 | * 42 | * This function encodes a message as UTF-8 bytes, signs it using the provided 43 | * signMessage function, and displays the base64-encoded signature result. 44 | * It includes comprehensive error handling and loading state management. 45 | * 46 | * @returns Promise 47 | */ 48 | const handleSignMessage = useCallback(async () => { 49 | setSignPending(true); 50 | try { 51 | if (!signMessage) { 52 | throw new Error('no Solana signMessage'); 53 | } 54 | const input = new TextEncoder().encode("Hello from Solana!"); 55 | const signatureBytes = await signMessage(input); 56 | const signature = btoa(String.fromCharCode(...signatureBytes)); 57 | setSignature(signature); 58 | setSignError(undefined); 59 | } catch (e) { 60 | if (e instanceof Error) { 61 | setSignError(e); 62 | } 63 | } finally { 64 | setSignPending(false); 65 | } 66 | }, [signMessage]); 67 | 68 | // --- Render --- 69 | return ( 70 | <> 71 | 79 | {signError && renderError(signError)} 80 | {signature && ( 81 |
82 |
Signature: {signature}
83 |
84 | )} 85 | 86 | ); 87 | } -------------------------------------------------------------------------------- /src/components/providers/WagmiProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createConfig, http, WagmiProvider } from "wagmi"; 2 | import { base, degen, mainnet, optimism, unichain, celo } from "wagmi/chains"; 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { farcasterFrame } from "@farcaster/miniapp-wagmi-connector"; 5 | import { coinbaseWallet, metaMask } from 'wagmi/connectors'; 6 | import { APP_NAME, APP_ICON_URL, APP_URL } from "~/lib/constants"; 7 | import { useEffect, useState } from "react"; 8 | import { useConnect, useAccount } from "wagmi"; 9 | import React from "react"; 10 | 11 | // Custom hook for Coinbase Wallet detection and auto-connection 12 | function useCoinbaseWalletAutoConnect() { 13 | const [isCoinbaseWallet, setIsCoinbaseWallet] = useState(false); 14 | const { connect, connectors } = useConnect(); 15 | const { isConnected } = useAccount(); 16 | 17 | useEffect(() => { 18 | // Check if we're running in Coinbase Wallet 19 | const checkCoinbaseWallet = () => { 20 | const isInCoinbaseWallet = window.ethereum?.isCoinbaseWallet || 21 | window.ethereum?.isCoinbaseWalletExtension || 22 | window.ethereum?.isCoinbaseWalletBrowser; 23 | setIsCoinbaseWallet(!!isInCoinbaseWallet); 24 | }; 25 | 26 | checkCoinbaseWallet(); 27 | window.addEventListener('ethereum#initialized', checkCoinbaseWallet); 28 | 29 | return () => { 30 | window.removeEventListener('ethereum#initialized', checkCoinbaseWallet); 31 | }; 32 | }, []); 33 | 34 | useEffect(() => { 35 | // Auto-connect if in Coinbase Wallet and not already connected 36 | if (isCoinbaseWallet && !isConnected) { 37 | connect({ connector: connectors[1] }); // Coinbase Wallet connector 38 | } 39 | }, [isCoinbaseWallet, isConnected, connect, connectors]); 40 | 41 | return isCoinbaseWallet; 42 | } 43 | 44 | export const config = createConfig({ 45 | chains: [base, optimism, mainnet, degen, unichain, celo], 46 | transports: { 47 | [base.id]: http(), 48 | [optimism.id]: http(), 49 | [mainnet.id]: http(), 50 | [degen.id]: http(), 51 | [unichain.id]: http(), 52 | [celo.id]: http(), 53 | }, 54 | connectors: [ 55 | farcasterFrame(), 56 | coinbaseWallet({ 57 | appName: APP_NAME, 58 | appLogoUrl: APP_ICON_URL, 59 | preference: 'all', 60 | }), 61 | metaMask({ 62 | dappMetadata: { 63 | name: APP_NAME, 64 | url: APP_URL, 65 | }, 66 | }), 67 | ], 68 | }); 69 | 70 | const queryClient = new QueryClient(); 71 | 72 | // Wrapper component that provides Coinbase Wallet auto-connection 73 | function CoinbaseWalletAutoConnect({ children }: { children: React.ReactNode }) { 74 | useCoinbaseWalletAutoConnect(); 75 | return <>{children}; 76 | } 77 | 78 | export default function Provider({ children }: { children: React.ReactNode }) { 79 | return ( 80 | 81 | 82 | 83 | {children} 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/app/api/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParseWebhookEvent, 3 | parseWebhookEvent, 4 | verifyAppKeyWithNeynar, 5 | } from "@farcaster/miniapp-node"; 6 | import { NextRequest } from "next/server"; 7 | import { APP_NAME } from "~/lib/constants"; 8 | import { 9 | deleteUserNotificationDetails, 10 | setUserNotificationDetails, 11 | } from "~/lib/kv"; 12 | import { sendMiniAppNotification } from "~/lib/notifs"; 13 | 14 | export async function POST(request: NextRequest) { 15 | // If Neynar is enabled, we don't need to handle webhooks here 16 | // as they will be handled by Neynar's webhook endpoint 17 | const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; 18 | if (neynarEnabled) { 19 | return Response.json({ success: true }); 20 | } 21 | 22 | const requestJson = await request.json(); 23 | 24 | let data; 25 | try { 26 | data = await parseWebhookEvent(requestJson, verifyAppKeyWithNeynar); 27 | } catch (e: unknown) { 28 | const error = e as ParseWebhookEvent.ErrorType; 29 | 30 | switch (error.name) { 31 | case "VerifyJsonFarcasterSignature.InvalidDataError": 32 | case "VerifyJsonFarcasterSignature.InvalidEventDataError": 33 | // The request data is invalid 34 | return Response.json( 35 | { success: false, error: error.message }, 36 | { status: 400 } 37 | ); 38 | case "VerifyJsonFarcasterSignature.InvalidAppKeyError": 39 | // The app key is invalid 40 | return Response.json( 41 | { success: false, error: error.message }, 42 | { status: 401 } 43 | ); 44 | case "VerifyJsonFarcasterSignature.VerifyAppKeyError": 45 | // Internal error verifying the app key (caller may want to try again) 46 | return Response.json( 47 | { success: false, error: error.message }, 48 | { status: 500 } 49 | ); 50 | } 51 | } 52 | 53 | const fid = data.fid; 54 | const event = data.event; 55 | 56 | // Only handle notifications if Neynar is not enabled 57 | // When Neynar is enabled, notifications are handled through their webhook 58 | switch (event.event) { 59 | case "miniapp_added": 60 | if (event.notificationDetails) { 61 | await setUserNotificationDetails(fid, event.notificationDetails); 62 | await sendMiniAppNotification({ 63 | fid, 64 | title: `Welcome to ${APP_NAME}`, 65 | body: "Mini app is now added to your client", 66 | }); 67 | } else { 68 | await deleteUserNotificationDetails(fid); 69 | } 70 | break; 71 | 72 | case "miniapp_removed": 73 | await deleteUserNotificationDetails(fid); 74 | break; 75 | 76 | case "notifications_enabled": 77 | await setUserNotificationDetails(fid, event.notificationDetails); 78 | await sendMiniAppNotification({ 79 | fid, 80 | title: `Welcome to ${APP_NAME}`, 81 | body: "Notifications are now enabled", 82 | }); 83 | break; 84 | 85 | case "notifications_disabled": 86 | await deleteUserNotificationDetails(fid); 87 | break; 88 | } 89 | 90 | return Response.json({ success: true }); 91 | } 92 | -------------------------------------------------------------------------------- /src/components/ui/NeynarAuthButton/ProfileButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState } from 'react'; 4 | import { useDetectClickOutside } from '~/hooks/useDetectClickOutside'; 5 | import { cn } from '~/lib/utils'; 6 | 7 | export function ProfileButton({ 8 | userData, 9 | onSignOut, 10 | }: { 11 | userData?: { fid?: number; pfpUrl?: string; username?: string }; 12 | onSignOut: () => void; 13 | }) { 14 | const [showDropdown, setShowDropdown] = useState(false); 15 | const ref = useRef(null); 16 | 17 | useDetectClickOutside(ref, () => setShowDropdown(false)); 18 | 19 | const name = userData?.username ?? `!${userData?.fid}`; 20 | const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; 21 | 22 | return ( 23 |
24 | 63 | 64 | {showDropdown && ( 65 |
66 | 88 |
89 | )} 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { init } from './init.js'; 4 | 5 | // Parse command line arguments 6 | const args = process.argv.slice(2); 7 | let projectName = null; 8 | let autoAcceptDefaults = false; 9 | let apiKey = null; 10 | let noWallet = false; 11 | let sponsoredSigner = false; 12 | let seedPhrase = null; 13 | let returnUrl = null; 14 | 15 | // Check for -y flag 16 | const yIndex = args.indexOf('-y'); 17 | if (yIndex !== -1) { 18 | autoAcceptDefaults = true; 19 | args.splice(yIndex, 1); // Remove -y from args 20 | } 21 | 22 | // Parse other arguments 23 | for (let i = 0; i < args.length; i++) { 24 | const arg = args[i]; 25 | 26 | if (arg === '-p' || arg === '--project') { 27 | if (i + 1 < args.length) { 28 | projectName = args[i + 1]; 29 | if (projectName.startsWith('-')) { 30 | console.error('Error: Project name cannot start with a dash (-)'); 31 | process.exit(1); 32 | } 33 | args.splice(i, 2); // Remove both the flag and its value 34 | i--; // Adjust index since we removed 2 elements 35 | } else { 36 | console.error('Error: -p/--project requires a project name'); 37 | process.exit(1); 38 | } 39 | } else if (arg === '-k' || arg === '--api-key') { 40 | if (i + 1 < args.length) { 41 | apiKey = args[i + 1]; 42 | if (apiKey.startsWith('-')) { 43 | console.error('Error: API key cannot start with a dash (-)'); 44 | process.exit(1); 45 | } 46 | args.splice(i, 2); // Remove both the flag and its value 47 | i--; // Adjust index since we removed 2 elements 48 | } else { 49 | console.error('Error: -k/--api-key requires an API key'); 50 | process.exit(1); 51 | } 52 | } else if (arg === '--no-wallet') { 53 | noWallet = true; 54 | args.splice(i, 1); // Remove the flag 55 | i--; // Adjust index since we removed 1 element 56 | } else if (arg === '--sponsored-signer') { 57 | sponsoredSigner = true; 58 | args.splice(i, 1); // Remove the flag 59 | i--; // Adjust index since we removed 1 element 60 | } else if (arg === '--seed-phrase') { 61 | if (i + 1 < args.length) { 62 | seedPhrase = args[i + 1]; 63 | if (seedPhrase.startsWith('-')) { 64 | console.error('Error: Seed phrase cannot start with a dash (-)'); 65 | process.exit(1); 66 | } 67 | args.splice(i, 2); // Remove both the flag and its value 68 | i--; // Adjust index since we removed 2 elements 69 | } else { 70 | console.error('Error: --seed-phrase requires a seed phrase'); 71 | process.exit(1); 72 | } 73 | } else if (arg === '-r' || arg === '--return-url') { 74 | if (i + 1 < args.length) { 75 | returnUrl = args[i + 1]; 76 | if (returnUrl.startsWith('-')) { 77 | console.error('Error: Return URL cannot start with a dash (-)'); 78 | process.exit(1); 79 | } 80 | args.splice(i, 2); // Remove both the flag and its value 81 | i--; // Adjust index since we removed 2 elements 82 | } else { 83 | console.error('Error: -r/--return-url requires a return URL'); 84 | process.exit(1); 85 | } 86 | } 87 | } 88 | 89 | 90 | 91 | // Validate that if -y is used, a project name must be provided 92 | if (autoAcceptDefaults && !projectName) { 93 | console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.'); 94 | process.exit(1); 95 | } 96 | 97 | init(projectName, autoAcceptDefaults, apiKey, noWallet, sponsoredSigner, seedPhrase, returnUrl).catch((err) => { 98 | console.error('Error:', err); 99 | process.exit(1); 100 | }); 101 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | /** 2 | * DESIGN SYSTEM - DO NOT EDIT UNLESS NECESSARY 3 | * 4 | * This file contains the centralized design system for the mini app. 5 | * These component classes establish the visual consistency across all components. 6 | * 7 | * ⚠️ AI SHOULD NOT NORMALLY EDIT THIS FILE ⚠️ 8 | * 9 | * Instead of modifying these classes, AI should: 10 | * 1. Use existing component classes (e.g., .btn, .card, .input) 11 | * 2. Use Tailwind utilities for one-off styling 12 | * 3. Create new React components rather than new CSS classes 13 | * 4. Only edit this file for specific bug fixes or accessibility improvements 14 | * 15 | * When AI needs to style something: 16 | * ✅ Good: 17 | * ✅ Good:
Custom
18 | * ❌ Bad: Adding new CSS classes here for component-specific styling 19 | * 20 | * This design system is intentionally minimal to prevent bloat and maintain consistency. 21 | */ 22 | 23 | @tailwind base; 24 | @tailwind components; 25 | @tailwind utilities; 26 | 27 | :root { 28 | --background: #ffffff; 29 | --foreground: #171717; 30 | } 31 | 32 | @media (prefers-color-scheme: dark) { 33 | :root { 34 | --background: #0a0a0a; 35 | --foreground: #ededed; 36 | } 37 | } 38 | 39 | body { 40 | color: var(--foreground); 41 | background: var(--background); 42 | font-family: 'Inter', Helvetica, Arial, sans-serif; 43 | } 44 | 45 | * { 46 | scrollbar-width: none; /* Firefox */ 47 | -ms-overflow-style: none; /* IE and Edge */ 48 | } 49 | 50 | *::-webkit-scrollbar { 51 | display: none; 52 | } 53 | 54 | @layer base { 55 | :root { 56 | --radius: 0.5rem; 57 | } 58 | } 59 | 60 | @layer components { 61 | /* Global container styles for consistent layout */ 62 | .container { 63 | @apply mx-auto max-w-md px-4; 64 | } 65 | 66 | .container-wide { 67 | @apply mx-auto max-w-lg px-4; 68 | } 69 | 70 | .container-narrow { 71 | @apply mx-auto max-w-sm px-4; 72 | } 73 | 74 | /* Global card styles */ 75 | .card { 76 | @apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm; 77 | } 78 | 79 | .card-primary { 80 | @apply bg-primary/10 border-primary/20; 81 | } 82 | 83 | /* Global button styles */ 84 | .btn { 85 | @apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none; 86 | } 87 | 88 | .btn-primary { 89 | @apply bg-primary text-white hover:bg-primary-dark focus:ring-primary; 90 | } 91 | 92 | .btn-secondary { 93 | @apply bg-secondary text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-secondary-dark dark:text-gray-100 dark:hover:bg-gray-600; 94 | } 95 | 96 | .btn-outline { 97 | @apply border border-gray-300 bg-transparent hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800; 98 | } 99 | 100 | /* Global input styles */ 101 | .input { 102 | @apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder-gray-500 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400; 103 | } 104 | 105 | /* Global loading spinner */ 106 | .spinner { 107 | @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary; 108 | } 109 | 110 | .spinner-primary { 111 | @apply animate-spin rounded-full border-2 border-white border-t-transparent; 112 | } 113 | 114 | /* Global focus styles */ 115 | .focus-ring { 116 | @apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useMiniApp } from "@neynar/react"; 5 | import { Header } from "~/components/ui/Header"; 6 | import { Footer } from "~/components/ui/Footer"; 7 | import { HomeTab, ActionsTab, ContextTab, WalletTab } from "~/components/ui/tabs"; 8 | import { USE_WALLET } from "~/lib/constants"; 9 | import { useNeynarUser } from "../hooks/useNeynarUser"; 10 | 11 | // --- Types --- 12 | export enum Tab { 13 | Home = "home", 14 | Actions = "actions", 15 | Context = "context", 16 | Wallet = "wallet", 17 | } 18 | 19 | export interface AppProps { 20 | title?: string; 21 | } 22 | 23 | /** 24 | * App component serves as the main container for the mini app interface. 25 | * 26 | * This component orchestrates the overall mini app experience by: 27 | * - Managing tab navigation and state 28 | * - Handling Farcaster mini app initialization 29 | * - Coordinating wallet and context state 30 | * - Providing error handling and loading states 31 | * - Rendering the appropriate tab content based on user selection 32 | * 33 | * The component integrates with the Neynar SDK for Farcaster functionality 34 | * and Wagmi for wallet management. It provides a complete mini app 35 | * experience with multiple tabs for different functionality areas. 36 | * 37 | * Features: 38 | * - Tab-based navigation (Home, Actions, Context, Wallet) 39 | * - Farcaster mini app integration 40 | * - Wallet connection management 41 | * - Error handling and display 42 | * - Loading states for async operations 43 | * 44 | * @param props - Component props 45 | * @param props.title - Optional title for the mini app (defaults to "Neynar Starter Kit") 46 | * 47 | * @example 48 | * ```tsx 49 | * 50 | * ``` 51 | */ 52 | export default function App( 53 | { title }: AppProps = { title: "Neynar Starter Kit" } 54 | ) { 55 | // --- Hooks --- 56 | const { 57 | isSDKLoaded, 58 | context, 59 | setInitialTab, 60 | setActiveTab, 61 | currentTab, 62 | } = useMiniApp(); 63 | 64 | // --- Neynar user hook --- 65 | const { user: neynarUser } = useNeynarUser(context || undefined); 66 | 67 | // --- Effects --- 68 | /** 69 | * Sets the initial tab to "home" when the SDK is loaded. 70 | * 71 | * This effect ensures that users start on the home tab when they first 72 | * load the mini app. It only runs when the SDK is fully loaded to 73 | * prevent errors during initialization. 74 | */ 75 | useEffect(() => { 76 | if (isSDKLoaded) { 77 | setInitialTab(Tab.Home); 78 | } 79 | }, [isSDKLoaded, setInitialTab]); 80 | 81 | // --- Early Returns --- 82 | if (!isSDKLoaded) { 83 | return ( 84 |
85 |
86 |
87 |

Loading SDK...

88 |
89 |
90 | ); 91 | } 92 | 93 | // --- Render --- 94 | return ( 95 |
103 | {/* Header should be full width */} 104 |
105 | 106 | {/* Main content and footer should be centered */} 107 |
108 | {/* Main title */} 109 |

{title}

110 | 111 | {/* Tab content rendering */} 112 | {currentTab === Tab.Home && } 113 | {currentTab === Tab.Actions && } 114 | {currentTab === Tab.Context && } 115 | {currentTab === Tab.Wallet && } 116 | 117 | {/* Footer with navigation */} 118 |
119 |
120 |
121 | ); 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/components/ui/Share.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState, useEffect } from 'react'; 4 | import { Button } from './Button'; 5 | import { useMiniApp } from '@neynar/react'; 6 | import { type ComposeCast } from "@farcaster/miniapp-sdk"; 7 | import { APP_URL } from '~/lib/constants'; 8 | 9 | interface EmbedConfig { 10 | path?: string; 11 | url?: string; 12 | imageUrl?: () => Promise; 13 | } 14 | 15 | interface CastConfig extends Omit { 16 | bestFriends?: boolean; 17 | embeds?: (string | EmbedConfig)[]; 18 | } 19 | 20 | interface ShareButtonProps { 21 | buttonText: string; 22 | cast: CastConfig; 23 | className?: string; 24 | isLoading?: boolean; 25 | } 26 | 27 | export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) { 28 | const [isProcessing, setIsProcessing] = useState(false); 29 | const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null); 30 | const [isLoadingBestFriends, setIsLoadingBestFriends] = useState(false); 31 | const { context, actions } = useMiniApp(); 32 | 33 | // Fetch best friends if needed 34 | useEffect(() => { 35 | if (cast.bestFriends && context?.user?.fid) { 36 | setIsLoadingBestFriends(true); 37 | fetch(`/api/best-friends?fid=${context.user.fid}`) 38 | .then(res => res.json()) 39 | .then(data => setBestFriends(data.bestFriends)) 40 | .catch(err => console.error('Failed to fetch best friends:', err)) 41 | .finally(() => setIsLoadingBestFriends(false)); 42 | } 43 | }, [cast.bestFriends, context?.user?.fid]); 44 | 45 | const handleShare = useCallback(async () => { 46 | try { 47 | setIsProcessing(true); 48 | 49 | let finalText = cast.text || ''; 50 | 51 | // Process best friends if enabled and data is loaded 52 | if (cast.bestFriends) { 53 | if (bestFriends) { 54 | // Replace @N with usernames, or remove if no matching friend 55 | finalText = finalText.replace(/@\d+/g, (match) => { 56 | const friendIndex = parseInt(match.slice(1)) - 1; 57 | const friend = bestFriends[friendIndex]; 58 | if (friend) { 59 | return `@${friend.username}`; 60 | } 61 | return ''; // Remove @N if no matching friend 62 | }); 63 | } else { 64 | // If bestFriends is not loaded but bestFriends is enabled, remove @N patterns 65 | finalText = finalText.replace(/@\d+/g, ''); 66 | } 67 | } 68 | 69 | // Process embeds 70 | const processedEmbeds = await Promise.all( 71 | (cast.embeds || []).map(async (embed) => { 72 | if (typeof embed === 'string') { 73 | return embed; 74 | } 75 | if (embed.path) { 76 | const baseUrl = APP_URL || window.location.origin; 77 | const url = new URL(`${baseUrl}${embed.path}`); 78 | 79 | // Add UTM parameters 80 | url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`); 81 | 82 | // If custom image generator is provided, use it 83 | if (embed.imageUrl) { 84 | const imageUrl = await embed.imageUrl(); 85 | url.searchParams.set('share_image_url', imageUrl); 86 | } 87 | 88 | return url.toString(); 89 | } 90 | return embed.url || ''; 91 | }) 92 | ); 93 | 94 | // Open cast composer with all supported intents 95 | await actions.composeCast({ 96 | text: finalText, 97 | embeds: processedEmbeds as [string] | [string, string] | undefined, 98 | parent: cast.parent, 99 | channelKey: cast.channelKey, 100 | close: cast.close, 101 | }); 102 | } catch (error) { 103 | console.error('Failed to share:', error); 104 | } finally { 105 | setIsProcessing(false); 106 | } 107 | }, [cast, bestFriends, context?.user?.fid, actions]); 108 | 109 | return ( 110 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/components/ui/wallet/SignIn.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState } from 'react'; 4 | import { SignIn as SignInCore } from '@farcaster/miniapp-sdk'; 5 | import { useQuickAuth } from '~/hooks/useQuickAuth'; 6 | import { Button } from '../Button'; 7 | 8 | /** 9 | * SignIn component handles Farcaster authentication using QuickAuth. 10 | * 11 | * This component provides a complete authentication flow for Farcaster users: 12 | * - Uses the built-in QuickAuth functionality from the Farcaster SDK 13 | * - Manages authentication state in memory (no persistence) 14 | * - Provides sign-out functionality 15 | * - Displays authentication status and results 16 | * 17 | * The component integrates with the Farcaster Frame SDK and QuickAuth 18 | * to provide seamless authentication within mini apps. 19 | * 20 | * @example 21 | * ```tsx 22 | * 23 | * ``` 24 | */ 25 | 26 | interface AuthState { 27 | signingIn: boolean; 28 | signingOut: boolean; 29 | } 30 | 31 | export function SignIn() { 32 | // --- State --- 33 | const [authState, setAuthState] = useState({ 34 | signingIn: false, 35 | signingOut: false, 36 | }); 37 | const [signInFailure, setSignInFailure] = useState(); 38 | 39 | // --- Hooks --- 40 | const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); 41 | 42 | // --- Handlers --- 43 | /** 44 | * Handles the sign-in process using QuickAuth. 45 | * 46 | * This function uses the built-in QuickAuth functionality: 47 | * 1. Gets a token from QuickAuth (handles SIWF flow automatically) 48 | * 2. Validates the token with our server 49 | * 3. Updates the session state 50 | * 51 | * @returns Promise 52 | */ 53 | const handleSignIn = useCallback(async () => { 54 | try { 55 | setAuthState(prev => ({ ...prev, signingIn: true })); 56 | setSignInFailure(undefined); 57 | 58 | const success = await signIn(); 59 | 60 | if (!success) { 61 | setSignInFailure('Authentication failed'); 62 | } 63 | } catch (e) { 64 | if (e instanceof SignInCore.RejectedByUser) { 65 | setSignInFailure('Rejected by user'); 66 | return; 67 | } 68 | setSignInFailure('Unknown error'); 69 | } finally { 70 | setAuthState(prev => ({ ...prev, signingIn: false })); 71 | } 72 | }, [signIn]); 73 | 74 | /** 75 | * Handles the sign-out process. 76 | * 77 | * This function clears the QuickAuth session and resets the local state. 78 | * 79 | * @returns Promise 80 | */ 81 | const handleSignOut = useCallback(async () => { 82 | try { 83 | setAuthState(prev => ({ ...prev, signingOut: true })); 84 | await signOut(); 85 | } finally { 86 | setAuthState(prev => ({ ...prev, signingOut: false })); 87 | } 88 | }, [signOut]); 89 | 90 | // --- Render --- 91 | return ( 92 | <> 93 | {/* Authentication Buttons */} 94 | {status !== 'authenticated' && ( 95 | 98 | )} 99 | {status === 'authenticated' && ( 100 | 103 | )} 104 | 105 | {/* Session Information */} 106 | {authenticatedUser && ( 107 |
108 |
109 | Authenticated User 110 |
111 |
112 | {JSON.stringify(authenticatedUser, null, 2)} 113 |
114 |
115 | )} 116 | 117 | {/* Error Display */} 118 | {signInFailure && !authState.signingIn && ( 119 |
120 |
121 | Authentication Error 122 |
123 |
124 | {signInFailure} 125 |
126 |
127 | )} 128 | 129 | ); 130 | } -------------------------------------------------------------------------------- /src/components/ui/wallet/SendSolana.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useState } from "react"; 4 | import { useConnection as useSolanaConnection, useWallet as useSolanaWallet } from '@solana/wallet-adapter-react'; 5 | import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; 6 | import { Button } from "../Button"; 7 | import { truncateAddress } from "../../../lib/truncateAddress"; 8 | import { renderError } from "../../../lib/errorUtils"; 9 | 10 | /** 11 | * SendSolana component handles sending SOL transactions on Solana. 12 | * 13 | * This component provides a simple interface for users to send SOL transactions 14 | * using their connected Solana wallet. It includes transaction status tracking 15 | * and error handling. 16 | * 17 | * Features: 18 | * - SOL transaction sending 19 | * - Transaction status tracking 20 | * - Error handling and display 21 | * - Loading state management 22 | * 23 | * Note: This component is a placeholder implementation. In a real application, 24 | * you would integrate with a Solana wallet adapter and transaction library 25 | * like @solana/web3.js to handle actual transactions. 26 | * 27 | * @example 28 | * ```tsx 29 | * 30 | * ``` 31 | */ 32 | export function SendSolana() { 33 | const [solanaTransactionState, setSolanaTransactionState] = useState< 34 | | { status: 'none' } 35 | | { status: 'pending' } 36 | | { status: 'error'; error: Error } 37 | | { status: 'success'; signature: string } 38 | >({ status: 'none' }); 39 | 40 | const { connection: solanaConnection } = useSolanaConnection(); 41 | const { sendTransaction, publicKey } = useSolanaWallet(); 42 | 43 | // This should be replaced but including it from the original demo 44 | // https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx#L718 45 | const ashoatsPhantomSolanaWallet = 'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1'; 46 | 47 | /** 48 | * Handles sending the Solana transaction 49 | */ 50 | const sendSolanaTransaction = useCallback(async () => { 51 | setSolanaTransactionState({ status: 'pending' }); 52 | try { 53 | if (!publicKey) { 54 | throw new Error('no Solana publicKey'); 55 | } 56 | 57 | const { blockhash } = await solanaConnection.getLatestBlockhash(); 58 | if (!blockhash) { 59 | throw new Error('failed to fetch latest Solana blockhash'); 60 | } 61 | 62 | const fromPubkeyStr = publicKey.toBase58(); 63 | const toPubkeyStr = ashoatsPhantomSolanaWallet; 64 | const transaction = new Transaction(); 65 | transaction.add( 66 | SystemProgram.transfer({ 67 | fromPubkey: new PublicKey(fromPubkeyStr), 68 | toPubkey: new PublicKey(toPubkeyStr), 69 | lamports: 0n, 70 | }), 71 | ); 72 | transaction.recentBlockhash = blockhash; 73 | transaction.feePayer = new PublicKey(fromPubkeyStr); 74 | 75 | const simulation = await solanaConnection.simulateTransaction(transaction); 76 | if (simulation.value.err) { 77 | // Gather logs and error details for debugging 78 | const logs = simulation.value.logs?.join('\n') ?? 'No logs'; 79 | const errDetail = JSON.stringify(simulation.value.err); 80 | throw new Error(`Simulation failed: ${errDetail}\nLogs:\n${logs}`); 81 | } 82 | const signature = await sendTransaction(transaction, solanaConnection); 83 | setSolanaTransactionState({ status: 'success', signature }); 84 | } catch (e) { 85 | if (e instanceof Error) { 86 | setSolanaTransactionState({ status: 'error', error: e }); 87 | } else { 88 | setSolanaTransactionState({ status: 'none' }); 89 | } 90 | } 91 | }, [sendTransaction, publicKey, solanaConnection]); 92 | 93 | return ( 94 | <> 95 | 103 | {solanaTransactionState.status === 'error' && renderError(solanaTransactionState.error)} 104 | {solanaTransactionState.status === 'success' && ( 105 |
106 |
Hash: {truncateAddress(solanaTransactionState.signature)}
107 |
108 | )} 109 | 110 | ); 111 | } -------------------------------------------------------------------------------- /src/components/ui/wallet/SendEth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useMemo } from "react"; 4 | import { useAccount, useSendTransaction, useWaitForTransactionReceipt } from "wagmi"; 5 | import { arbitrum, base, mainnet, optimism, polygon, scroll, shape, zkSync, zora } from "wagmi/chains"; 6 | import { Button } from "../Button"; 7 | import { truncateAddress } from "../../../lib/truncateAddress"; 8 | import { renderError } from "../../../lib/errorUtils"; 9 | 10 | /** 11 | * SendEth component handles sending ETH transactions to protocol guild addresses. 12 | * 13 | * This component provides a simple interface for users to send small amounts 14 | * of ETH to protocol guild addresses. It automatically selects the appropriate 15 | * recipient address based on the current chain and displays transaction status. 16 | * 17 | * Features: 18 | * - Chain-specific recipient addresses 19 | * - Transaction status tracking 20 | * - Error handling and display 21 | * - Transaction hash display 22 | * 23 | * @example 24 | * ```tsx 25 | * 26 | * ``` 27 | */ 28 | export function SendEth() { 29 | // --- Hooks --- 30 | const { isConnected, chainId } = useAccount(); 31 | const { 32 | sendTransaction, 33 | data: ethTransactionHash, 34 | error: ethTransactionError, 35 | isError: isEthTransactionError, 36 | isPending: isEthTransactionPending, 37 | } = useSendTransaction(); 38 | 39 | const { isLoading: isEthTransactionConfirming, isSuccess: isEthTransactionConfirmed } = 40 | useWaitForTransactionReceipt({ 41 | hash: ethTransactionHash, 42 | }); 43 | 44 | // --- Computed Values --- 45 | /** 46 | * Determines the recipient address based on the current chain. 47 | * 48 | * Uses different protocol guild addresses for different chains. 49 | * Defaults to Ethereum mainnet address if chain is not recognized. 50 | * Addresses are taken from the protocol guilds documentation: https://protocol-guild.readthedocs.io/en/latest/ 51 | * 52 | * @returns string - The recipient address for the current chain 53 | */ 54 | const protocolGuildRecipientAddress = useMemo(() => { 55 | switch (chainId) { 56 | case mainnet.id: 57 | return "0x25941dC771bB64514Fc8abBce970307Fb9d477e9"; 58 | case arbitrum.id: 59 | return "0x7F8DCFd764bA8e9B3BA577dC641D5c664B74c47b"; 60 | case base.id: 61 | return "0xd16713A5D4Eb7E3aAc9D2228eB72f6f7328FADBD"; 62 | case optimism.id: 63 | return "0x58ae0925077527a87D3B785aDecA018F9977Ec34"; 64 | case polygon.id: 65 | return "0xccccEbdBdA2D68bABA6da99449b9CA41Dba9d4FF"; 66 | case scroll.id: 67 | return "0xccccEbdBdA2D68bABA6da99449b9CA41Dba9d4FF"; 68 | case shape.id: 69 | return "0x700fccD433E878F1AF9B64A433Cb2E09f5226CE8"; 70 | case zkSync.id: 71 | return "0x9fb5F754f5222449F98b904a34494cB21AADFdf8"; 72 | case zora.id: 73 | return "0x32e3C7fD24e175701A35c224f2238d18439C7dBC"; 74 | default: 75 | // Default to Ethereum mainnet address 76 | return "0x25941dC771bB64514Fc8abBce970307Fb9d477e9"; 77 | } 78 | }, [chainId]); 79 | 80 | // --- Handlers --- 81 | /** 82 | * Handles sending the ETH transaction. 83 | * 84 | * This function sends a small amount of ETH (1 wei) to the protocol guild 85 | * address for the current chain. The transaction is sent using the wagmi 86 | * sendTransaction hook. 87 | */ 88 | const sendEthTransaction = useCallback(() => { 89 | sendTransaction({ 90 | to: protocolGuildRecipientAddress, 91 | value: 1n, 92 | }); 93 | }, [protocolGuildRecipientAddress, sendTransaction]); 94 | 95 | // --- Render --- 96 | return ( 97 | <> 98 | 105 | {isEthTransactionError && renderError(ethTransactionError)} 106 | {ethTransactionHash && ( 107 |
108 |
Hash: {truncateAddress(ethTransactionHash)}
109 |
110 | Status:{" "} 111 | {isEthTransactionConfirming 112 | ? "Confirming..." 113 | : isEthTransactionConfirmed 114 | ? "Confirmed!" 115 | : "Pending"} 116 |
117 |
118 | )} 119 | 120 | ); 121 | } -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { type AccountAssociation } from '@farcaster/miniapp-core/src/manifest'; 2 | 3 | /** 4 | * Application constants and configuration values. 5 | * 6 | * This file contains all the configuration constants used throughout the mini app. 7 | * These values are either sourced from environment variables or hardcoded and provide 8 | * configuration for the app's appearance, behavior, and integration settings. 9 | * 10 | * NOTE: This file is automatically updated by the init script. 11 | * Manual changes may be overwritten during project initialization. 12 | */ 13 | 14 | // --- App Configuration --- 15 | /** 16 | * The base URL of the application. 17 | * Used for generating absolute URLs for assets and API endpoints. 18 | */ 19 | export const APP_URL: string = process.env.NEXT_PUBLIC_URL!; 20 | 21 | /** 22 | * The name of the mini app as displayed to users. 23 | * Used in titles, headers, and app store listings. 24 | */ 25 | export const APP_NAME: string = 'Starter Kit'; 26 | 27 | /** 28 | * A brief description of the mini app's functionality. 29 | * Used in app store listings and metadata. 30 | */ 31 | export const APP_DESCRIPTION: string = 'A demo of the Neynar Starter Kit'; 32 | 33 | /** 34 | * The primary category for the mini app. 35 | * Used for app store categorization and discovery. 36 | */ 37 | export const APP_PRIMARY_CATEGORY: string = 'developer-tools'; 38 | 39 | /** 40 | * Tags associated with the mini app. 41 | * Used for search and discovery in app stores. 42 | */ 43 | export const APP_TAGS: string[] = ['neynar', 'starter-kit', 'demo']; 44 | 45 | // --- Asset URLs --- 46 | /** 47 | * URL for the app's icon image. 48 | * Used in app store listings and UI elements. 49 | */ 50 | export const APP_ICON_URL: string = `${APP_URL}/icon.png`; 51 | 52 | /** 53 | * URL for the app's Open Graph image. 54 | * Used for social media sharing and previews. 55 | */ 56 | export const APP_OG_IMAGE_URL: string = `${APP_URL}/api/opengraph-image`; 57 | 58 | /** 59 | * URL for the app's splash screen image. 60 | * Displayed during app loading. 61 | */ 62 | export const APP_SPLASH_URL: string = `${APP_URL}/splash.png`; 63 | 64 | /** 65 | * Background color for the splash screen. 66 | * Used as fallback when splash image is loading. 67 | */ 68 | export const APP_SPLASH_BACKGROUND_COLOR: string = '#f7f7f7'; 69 | 70 | /** 71 | * Account association for the mini app. 72 | * Used to associate the mini app with a Farcaster account. 73 | * If not provided, the mini app will be unsigned and have limited capabilities. 74 | */ 75 | export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = 76 | undefined; 77 | 78 | // --- UI Configuration --- 79 | /** 80 | * Text displayed on the main action button. 81 | * Used for the primary call-to-action in the mini app. 82 | */ 83 | export const APP_BUTTON_TEXT: string = 'Launch Mini App'; 84 | 85 | // --- Integration Configuration --- 86 | /** 87 | * Webhook URL for receiving events from Neynar. 88 | * 89 | * If Neynar API key and client ID are configured, uses the official 90 | * Neynar webhook endpoint. Otherwise, falls back to a local webhook 91 | * endpoint for development and testing. 92 | */ 93 | export const APP_WEBHOOK_URL: string = 94 | process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID 95 | ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` 96 | : `${APP_URL}/api/webhook`; 97 | 98 | /** 99 | * Flag to enable/disable wallet functionality. 100 | * 101 | * When true, wallet-related components and features are rendered. 102 | * When false, wallet functionality is completely hidden from the UI. 103 | * Useful for mini apps that don't require wallet integration. 104 | */ 105 | export const USE_WALLET: boolean = false; 106 | 107 | /** 108 | * Flag to enable/disable analytics tracking. 109 | * 110 | * When true, usage analytics are collected and sent to Neynar. 111 | * When false, analytics collection is disabled. 112 | * Useful for privacy-conscious users or development environments. 113 | */ 114 | export const ANALYTICS_ENABLED: boolean = true; 115 | 116 | /** 117 | * Required chains for the mini app. 118 | * 119 | * Contains an array of CAIP-2 identifiers for blockchains that the mini app requires. 120 | * If the host does not support all chains listed here, it will not render the mini app. 121 | * If empty or undefined, the mini app will be rendered regardless of chain support. 122 | * 123 | * Supported chains: eip155:1, eip155:137, eip155:42161, eip155:10, eip155:8453, 124 | * solana:mainnet, solana:devnet 125 | */ 126 | export const APP_REQUIRED_CHAINS: string[] = []; 127 | 128 | /** 129 | * Return URL for the mini app. 130 | * 131 | * If provided, the mini app will be rendered with a return URL to be rendered if the 132 | * back button is pressed from the home page. 133 | */ 134 | export const RETURN_URL: string | undefined = undefined; 135 | 136 | // PLEASE DO NOT UPDATE THIS 137 | export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { 138 | name: 'Farcaster SignedKeyRequestValidator', 139 | version: '1', 140 | chainId: 10, 141 | verifyingContract: 142 | '0x00000000fc700472606ed4fa22623acf62c60553' as `0x${string}`, 143 | }; 144 | 145 | // PLEASE DO NOT UPDATE THIS 146 | export const SIGNED_KEY_REQUEST_TYPE = [ 147 | { name: 'requestFid', type: 'uint256' }, 148 | { name: 'key', type: 'bytes' }, 149 | { name: 'deadline', type: 'uint256' }, 150 | ]; 151 | -------------------------------------------------------------------------------- /scripts/dev.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import { createServer } from 'net'; 3 | import dotenv from 'dotenv'; 4 | import path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | // Load environment variables 8 | dotenv.config({ path: '.env.local' }); 9 | 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11 | const projectRoot = path.resolve(path.normalize(path.join(__dirname, '..'))); 12 | 13 | let nextDev; 14 | let isCleaningUp = false; 15 | 16 | // Parse command line arguments for port 17 | const args = process.argv.slice(2); 18 | let port = 3000; // default port 19 | 20 | // Look for --port=XXXX, --port XXXX, -p=XXXX, or -p XXXX 21 | args.forEach((arg, index) => { 22 | if (arg.startsWith('--port=')) { 23 | port = parseInt(arg.split('=')[1]); 24 | } else if (arg === '--port' && args[index + 1]) { 25 | port = parseInt(args[index + 1]); 26 | } else if (arg.startsWith('-p=')) { 27 | port = parseInt(arg.split('=')[1]); 28 | } else if (arg === '-p' && args[index + 1]) { 29 | port = parseInt(args[index + 1]); 30 | } 31 | }); 32 | 33 | async function checkPort(port) { 34 | return new Promise((resolve) => { 35 | const server = createServer(); 36 | 37 | server.once('error', () => { 38 | resolve(true); // Port is in use 39 | }); 40 | 41 | server.once('listening', () => { 42 | server.close(); 43 | resolve(false); // Port is free 44 | }); 45 | 46 | server.listen(port); 47 | }); 48 | } 49 | 50 | async function killProcessOnPort(port) { 51 | try { 52 | if (process.platform === 'win32') { 53 | // Windows: Use netstat to find the process 54 | const netstat = spawn('netstat', ['-ano', '|', 'findstr', `:${port}`]); 55 | netstat.stdout.on('data', (data) => { 56 | const match = data.toString().match(/\s+(\d+)$/); 57 | if (match) { 58 | const pid = match[1]; 59 | spawn('taskkill', ['/F', '/PID', pid]); 60 | } 61 | }); 62 | await new Promise((resolve) => netstat.on('close', resolve)); 63 | } else { 64 | // Unix-like systems: Use lsof 65 | const lsof = spawn('lsof', ['-ti', `:${port}`]); 66 | lsof.stdout.on('data', (data) => { 67 | data.toString().split('\n').forEach(pid => { 68 | if (pid) { 69 | try { 70 | process.kill(parseInt(pid), 'SIGKILL'); 71 | } catch (e) { 72 | if (e.code !== 'ESRCH') throw e; 73 | } 74 | } 75 | }); 76 | }); 77 | await new Promise((resolve) => lsof.on('close', resolve)); 78 | } 79 | } catch (e) { 80 | // Ignore errors if no process found 81 | } 82 | } 83 | 84 | async function startDev() { 85 | // Check if the specified port is already in use 86 | const isPortInUse = await checkPort(port); 87 | if (isPortInUse) { 88 | console.error(`Port ${port} is already in use. To find and kill the process using this port:\n\n` + 89 | (process.platform === 'win32' 90 | ? `1. Run: netstat -ano | findstr :${port}\n` + 91 | '2. Note the PID (Process ID) from the output\n' + 92 | '3. Run: taskkill /PID /F\n' 93 | : `On macOS/Linux, run:\nnpm run cleanup\n`) + 94 | '\nThen try running this command again.'); 95 | process.exit(1); 96 | } 97 | 98 | const miniAppUrl = `http://localhost:${port}`; 99 | 100 | console.log(` 101 | 💻 Your mini app is running at: ${miniAppUrl} 102 | 103 | 🌐 To test with the Farcaster preview tool: 104 | 105 | 1. Create a free ngrok account at https://ngrok.com/download/mac-os 106 | 2. Download and install ngrok following the instructions 107 | 3. In a NEW terminal window, run: ngrok http ${port} 108 | 4. Copy the forwarding URL (e.g., https://xxxx-xx-xx-xx-xx.ngrok-free.app) 109 | 5. Navigate to: https://farcaster.xyz/~/developers/mini-apps/preview 110 | 6. Enter your ngrok URL and click "Preview" to test your mini app 111 | `) 112 | 113 | // Start next dev with appropriate configuration 114 | const nextBin = path.normalize(path.join(projectRoot, 'node_modules', '.bin', 'next')); 115 | 116 | nextDev = spawn(nextBin, ['dev', '-p', port.toString()], { 117 | stdio: 'inherit', 118 | env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl }, 119 | cwd: projectRoot, 120 | shell: process.platform === 'win32' // Add shell option for Windows 121 | }); 122 | 123 | // Handle cleanup 124 | const cleanup = async () => { 125 | if (isCleaningUp) return; 126 | isCleaningUp = true; 127 | 128 | console.log('\n\nShutting down...'); 129 | 130 | try { 131 | if (nextDev) { 132 | try { 133 | // Kill the main process first 134 | nextDev.kill('SIGKILL'); 135 | // Then kill any remaining child processes in the group 136 | if (nextDev?.pid) { 137 | try { 138 | process.kill(-nextDev.pid); 139 | } catch (e) { 140 | // Ignore ESRCH errors when killing process group 141 | if (e.code !== 'ESRCH') throw e; 142 | } 143 | } 144 | console.log('🛑 Next.js dev server stopped'); 145 | } catch (e) { 146 | // Ignore errors when killing nextDev 147 | console.log('Note: Next.js process already terminated'); 148 | } 149 | } 150 | 151 | // Force kill any remaining processes on the specified port 152 | await killProcessOnPort(port); 153 | } catch (error) { 154 | console.error('Error during cleanup:', error); 155 | } finally { 156 | process.exit(0); 157 | } 158 | }; 159 | 160 | // Handle process termination 161 | process.on('SIGINT', cleanup); 162 | process.on('SIGTERM', cleanup); 163 | process.on('exit', cleanup); 164 | } 165 | 166 | startDev().catch(console.error); -------------------------------------------------------------------------------- /src/hooks/useQuickAuth.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useCallback } from 'react'; 4 | import { sdk } from '@farcaster/miniapp-sdk'; 5 | 6 | /** 7 | * Represents the current authenticated user state 8 | */ 9 | interface AuthenticatedUser { 10 | /** The user's Farcaster ID (FID) */ 11 | fid: number; 12 | } 13 | 14 | /** 15 | * Possible authentication states for QuickAuth 16 | */ 17 | type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; 18 | 19 | /** 20 | * Return type for the useQuickAuth hook 21 | */ 22 | interface UseQuickAuthReturn { 23 | /** Current authenticated user data, or null if not authenticated */ 24 | authenticatedUser: AuthenticatedUser | null; 25 | /** Current authentication status */ 26 | status: QuickAuthStatus; 27 | /** Function to initiate the sign-in process using QuickAuth */ 28 | signIn: () => Promise; 29 | /** Function to sign out and clear the current authentication state */ 30 | signOut: () => Promise; 31 | /** Function to retrieve the current authentication token */ 32 | getToken: () => Promise; 33 | } 34 | 35 | /** 36 | * Custom hook for managing QuickAuth authentication state 37 | * 38 | * This hook provides a complete authentication flow using Farcaster's QuickAuth: 39 | * - Automatically checks for existing authentication on mount 40 | * - Validates tokens with the server-side API 41 | * - Manages authentication state in memory (no persistence) 42 | * - Provides sign-in/sign-out functionality 43 | * 44 | * QuickAuth tokens are managed in memory only, so signing out of the Farcaster 45 | * client will automatically sign the user out of this mini app as well. 46 | * 47 | * @returns {UseQuickAuthReturn} Object containing user state and authentication methods 48 | * 49 | * @example 50 | * ```tsx 51 | * const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); 52 | * 53 | * if (status === 'loading') return
Loading...
; 54 | * if (status === 'unauthenticated') return ; 55 | * 56 | * return ( 57 | *
58 | *

Welcome, FID: {authenticatedUser?.fid}

59 | * 60 | *
61 | * ); 62 | * ``` 63 | */ 64 | export function useQuickAuth(): UseQuickAuthReturn { 65 | // Current authenticated user data 66 | const [authenticatedUser, setAuthenticatedUser] = 67 | useState(null); 68 | // Current authentication status 69 | const [status, setStatus] = useState('loading'); 70 | 71 | /** 72 | * Validates a QuickAuth token with the server-side API 73 | * 74 | * @param {string} authToken - The JWT token to validate 75 | * @returns {Promise} User data if valid, null otherwise 76 | */ 77 | const validateTokenWithServer = async ( 78 | authToken: string, 79 | ): Promise => { 80 | try { 81 | const validationResponse = await fetch('/api/auth/validate', { 82 | method: 'POST', 83 | headers: { 'Content-Type': 'application/json' }, 84 | body: JSON.stringify({ token: authToken }), 85 | }); 86 | 87 | if (validationResponse.ok) { 88 | const responseData = await validationResponse.json(); 89 | return responseData.user; 90 | } 91 | 92 | return null; 93 | } catch (error) { 94 | console.error('Token validation failed:', error); 95 | return null; 96 | } 97 | }; 98 | 99 | /** 100 | * Checks for existing authentication token and validates it on component mount 101 | * This runs automatically when the hook is first used 102 | */ 103 | useEffect(() => { 104 | const checkExistingAuthentication = async () => { 105 | try { 106 | // Attempt to retrieve existing token from QuickAuth SDK 107 | const { token } = await sdk.quickAuth.getToken(); 108 | 109 | if (token) { 110 | // Validate the token with our server-side API 111 | const validatedUserSession = await validateTokenWithServer(token); 112 | 113 | if (validatedUserSession) { 114 | // Token is valid, set authenticated state 115 | setAuthenticatedUser(validatedUserSession); 116 | setStatus('authenticated'); 117 | } else { 118 | // Token is invalid or expired, clear authentication state 119 | setStatus('unauthenticated'); 120 | } 121 | } else { 122 | // No existing token found, user is not authenticated 123 | setStatus('unauthenticated'); 124 | } 125 | } catch (error) { 126 | console.error('Error checking existing authentication:', error); 127 | setStatus('unauthenticated'); 128 | } 129 | }; 130 | 131 | checkExistingAuthentication(); 132 | }, []); 133 | 134 | /** 135 | * Initiates the QuickAuth sign-in process 136 | * 137 | * Uses sdk.quickAuth.getToken() to get a QuickAuth session token. 138 | * If there is already a session token in memory that hasn't expired, 139 | * it will be immediately returned, otherwise a fresh one will be acquired. 140 | * 141 | * @returns {Promise} True if sign-in was successful, false otherwise 142 | */ 143 | const signIn = useCallback(async (): Promise => { 144 | try { 145 | setStatus('loading'); 146 | 147 | // Get QuickAuth session token 148 | const { token } = await sdk.quickAuth.getToken(); 149 | 150 | if (token) { 151 | // Validate the token with our server-side API 152 | const validatedUserSession = await validateTokenWithServer(token); 153 | 154 | if (validatedUserSession) { 155 | // Authentication successful, update user state 156 | setAuthenticatedUser(validatedUserSession); 157 | setStatus('authenticated'); 158 | return true; 159 | } 160 | } 161 | 162 | // Authentication failed, clear user state 163 | setStatus('unauthenticated'); 164 | return false; 165 | } catch (error) { 166 | console.error('Sign-in process failed:', error); 167 | setStatus('unauthenticated'); 168 | return false; 169 | } 170 | }, []); 171 | 172 | /** 173 | * Signs out the current user and clears the authentication state 174 | * 175 | * Since QuickAuth tokens are managed in memory only, this simply clears 176 | * the local user state. The actual token will be cleared when the 177 | * user signs out of their Farcaster client. 178 | */ 179 | const signOut = useCallback(async (): Promise => { 180 | // Clear local user state 181 | setAuthenticatedUser(null); 182 | setStatus('unauthenticated'); 183 | }, []); 184 | 185 | /** 186 | * Retrieves the current authentication token from QuickAuth 187 | * 188 | * @returns {Promise} The current auth token, or null if not authenticated 189 | */ 190 | const getToken = useCallback(async (): Promise => { 191 | try { 192 | const { token } = await sdk.quickAuth.getToken(); 193 | return token; 194 | } catch (error) { 195 | console.error('Failed to retrieve authentication token:', error); 196 | return null; 197 | } 198 | }, []); 199 | 200 | return { 201 | authenticatedUser, 202 | status, 203 | signIn, 204 | signOut, 205 | getToken, 206 | }; 207 | } -------------------------------------------------------------------------------- /src/components/ui/tabs/ActionsTab.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState } from 'react'; 4 | import { useMiniApp } from '@neynar/react'; 5 | import { ShareButton } from '../Share'; 6 | import { Button } from '../Button'; 7 | import { SignIn } from '../wallet/SignIn'; 8 | import { type Haptics } from '@farcaster/miniapp-sdk'; 9 | import { APP_URL } from '~/lib/constants'; 10 | 11 | /** 12 | * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. 13 | * 14 | * This component provides the main interaction interface for users to: 15 | * - Share the mini app with others 16 | * - Sign in with Farcaster 17 | * - Send notifications to their account 18 | * - Trigger haptic feedback 19 | * - Add the mini app to their client 20 | * - Copy share URLs 21 | * 22 | * The component uses the useMiniApp hook to access Farcaster context and actions. 23 | * All state is managed locally within this component. 24 | * 25 | * @example 26 | * ```tsx 27 | * 28 | * ``` 29 | */ 30 | export function ActionsTab() { 31 | // --- Hooks --- 32 | const { actions, added, notificationDetails, haptics, context } = 33 | useMiniApp(); 34 | 35 | // --- State --- 36 | const [notificationState, setNotificationState] = useState({ 37 | sendStatus: '', 38 | shareUrlCopied: false, 39 | }); 40 | const [selectedHapticIntensity, setSelectedHapticIntensity] = 41 | useState('medium'); 42 | 43 | // --- Handlers --- 44 | /** 45 | * Sends a notification to the current user's Farcaster account. 46 | * 47 | * This function makes a POST request to the /api/send-notification endpoint 48 | * with the user's FID and notification details. It handles different response 49 | * statuses including success (200), rate limiting (429), and errors. 50 | * 51 | * @returns Promise that resolves when the notification is sent or fails 52 | */ 53 | const sendFarcasterNotification = useCallback(async () => { 54 | setNotificationState((prev) => ({ ...prev, sendStatus: '' })); 55 | if (!notificationDetails || !context) { 56 | return; 57 | } 58 | try { 59 | const response = await fetch('/api/send-notification', { 60 | method: 'POST', 61 | mode: 'same-origin', 62 | headers: { 'Content-Type': 'application/json' }, 63 | body: JSON.stringify({ 64 | fid: context.user.fid, 65 | notificationDetails, 66 | }), 67 | }); 68 | if (response.status === 200) { 69 | setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); 70 | return; 71 | } else if (response.status === 429) { 72 | setNotificationState((prev) => ({ 73 | ...prev, 74 | sendStatus: 'Rate limited', 75 | })); 76 | return; 77 | } 78 | const responseText = await response.text(); 79 | setNotificationState((prev) => ({ 80 | ...prev, 81 | sendStatus: `Error: ${responseText}`, 82 | })); 83 | } catch (error) { 84 | setNotificationState((prev) => ({ 85 | ...prev, 86 | sendStatus: `Error: ${error}`, 87 | })); 88 | } 89 | }, [context, notificationDetails]); 90 | 91 | /** 92 | * Copies the share URL for the current user to the clipboard. 93 | * 94 | * This function generates a share URL using the user's FID and copies it 95 | * to the clipboard. It shows a temporary "Copied!" message for 2 seconds. 96 | */ 97 | const copyUserShareUrl = useCallback(async () => { 98 | if (context?.user?.fid) { 99 | const userShareUrl = `${APP_URL}/share/${context.user.fid}`; 100 | await navigator.clipboard.writeText(userShareUrl); 101 | setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); 102 | setTimeout( 103 | () => 104 | setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 105 | 2000 106 | ); 107 | } 108 | }, [context?.user?.fid]); 109 | 110 | /** 111 | * Triggers haptic feedback with the selected intensity. 112 | * 113 | * This function calls the haptics.impactOccurred method with the current 114 | * selectedHapticIntensity setting. It handles errors gracefully by logging them. 115 | */ 116 | const triggerHapticFeedback = useCallback(async () => { 117 | try { 118 | await haptics.impactOccurred(selectedHapticIntensity); 119 | } catch (error) { 120 | console.error('Haptic feedback failed:', error); 121 | } 122 | }, [haptics, selectedHapticIntensity]); 123 | 124 | // --- Render --- 125 | return ( 126 |
127 | {/* Share functionality */} 128 | 137 | 138 | {/* Authentication */} 139 | 140 | 141 | {/* Mini app actions */} 142 | 150 | 151 | 154 | 155 | {/* Notification functionality */} 156 | {notificationState.sendStatus && ( 157 |
158 | Send notification result: {notificationState.sendStatus} 159 |
160 | )} 161 | 168 | 169 | {/* Share URL copying */} 170 | 177 | 178 | {/* Haptic feedback controls */} 179 |
180 | 183 | 198 | 201 |
202 |
203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /src/components/ui/tabs/ActionsTab.NeynarAuth.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useState } from 'react'; 4 | import { useMiniApp } from '@neynar/react'; 5 | import { ShareButton } from '../Share'; 6 | import { Button } from '../Button'; 7 | import { SignIn } from '../wallet/SignIn'; 8 | import { type Haptics } from '@farcaster/miniapp-sdk'; 9 | import { APP_URL } from '~/lib/constants'; 10 | import { NeynarAuthButton } from '../NeynarAuthButton'; 11 | 12 | /** 13 | * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. 14 | * 15 | * This component provides the main interaction interface for users to: 16 | * - Share the mini app with others 17 | * - Sign in with Farcaster 18 | * - Send notifications to their account 19 | * - Trigger haptic feedback 20 | * - Add the mini app to their client 21 | * - Copy share URLs 22 | * 23 | * The component uses the useMiniApp hook to access Farcaster context and actions. 24 | * All state is managed locally within this component. 25 | * 26 | * @example 27 | * ```tsx 28 | * 29 | * ``` 30 | */ 31 | export function ActionsTab() { 32 | // --- Hooks --- 33 | const { actions, added, notificationDetails, haptics, context } = 34 | useMiniApp(); 35 | 36 | // --- State --- 37 | const [notificationState, setNotificationState] = useState({ 38 | sendStatus: '', 39 | shareUrlCopied: false, 40 | }); 41 | const [selectedHapticIntensity, setSelectedHapticIntensity] = 42 | useState('medium'); 43 | 44 | // --- Handlers --- 45 | /** 46 | * Sends a notification to the current user's Farcaster account. 47 | * 48 | * This function makes a POST request to the /api/send-notification endpoint 49 | * with the user's FID and notification details. It handles different response 50 | * statuses including success (200), rate limiting (429), and errors. 51 | * 52 | * @returns Promise that resolves when the notification is sent or fails 53 | */ 54 | const sendFarcasterNotification = useCallback(async () => { 55 | setNotificationState((prev) => ({ ...prev, sendStatus: '' })); 56 | if (!notificationDetails || !context) { 57 | return; 58 | } 59 | try { 60 | const response = await fetch('/api/send-notification', { 61 | method: 'POST', 62 | mode: 'same-origin', 63 | headers: { 'Content-Type': 'application/json' }, 64 | body: JSON.stringify({ 65 | fid: context.user.fid, 66 | notificationDetails, 67 | }), 68 | }); 69 | if (response.status === 200) { 70 | setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); 71 | return; 72 | } else if (response.status === 429) { 73 | setNotificationState((prev) => ({ 74 | ...prev, 75 | sendStatus: 'Rate limited', 76 | })); 77 | return; 78 | } 79 | const responseText = await response.text(); 80 | setNotificationState((prev) => ({ 81 | ...prev, 82 | sendStatus: `Error: ${responseText}`, 83 | })); 84 | } catch (error) { 85 | setNotificationState((prev) => ({ 86 | ...prev, 87 | sendStatus: `Error: ${error}`, 88 | })); 89 | } 90 | }, [context, notificationDetails]); 91 | 92 | /** 93 | * Copies the share URL for the current user to the clipboard. 94 | * 95 | * This function generates a share URL using the user's FID and copies it 96 | * to the clipboard. It shows a temporary "Copied!" message for 2 seconds. 97 | */ 98 | const copyUserShareUrl = useCallback(async () => { 99 | if (context?.user?.fid) { 100 | const userShareUrl = `${APP_URL}/share/${context.user.fid}`; 101 | await navigator.clipboard.writeText(userShareUrl); 102 | setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); 103 | setTimeout( 104 | () => 105 | setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 106 | 2000 107 | ); 108 | } 109 | }, [context?.user?.fid]); 110 | 111 | /** 112 | * Triggers haptic feedback with the selected intensity. 113 | * 114 | * This function calls the haptics.impactOccurred method with the current 115 | * selectedHapticIntensity setting. It handles errors gracefully by logging them. 116 | */ 117 | const triggerHapticFeedback = useCallback(async () => { 118 | try { 119 | await haptics.impactOccurred(selectedHapticIntensity); 120 | } catch (error) { 121 | console.error('Haptic feedback failed:', error); 122 | } 123 | }, [haptics, selectedHapticIntensity]); 124 | 125 | // --- Render --- 126 | return ( 127 |
128 | {/* Share functionality */} 129 | 138 | 139 | {/* Authentication */} 140 | 141 | 142 | {/* Neynar Authentication */} 143 | 144 | 145 | {/* Mini app actions */} 146 | 154 | 155 | 158 | 159 | {/* Notification functionality */} 160 | {notificationState.sendStatus && ( 161 |
162 | Send notification result: {notificationState.sendStatus} 163 |
164 | )} 165 | 172 | 173 | {/* Share URL copying */} 174 | 181 | 182 | {/* Haptic feedback controls */} 183 |
184 | 187 | 202 | 205 |
206 |
207 | ); 208 | } 209 | -------------------------------------------------------------------------------- /src/components/ui/NeynarAuthButton/AuthDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export function AuthDialog({ 4 | open, 5 | onClose, 6 | url, 7 | isError, 8 | error, 9 | step, 10 | isLoading, 11 | signerApprovalUrl, 12 | }: { 13 | open: boolean; 14 | onClose: () => void; 15 | url?: string; 16 | isError: boolean; 17 | error?: Error | null; 18 | step: 'signin' | 'access' | 'loading'; 19 | isLoading?: boolean; 20 | signerApprovalUrl?: string | null; 21 | }) { 22 | if (!open) return null; 23 | 24 | const getStepContent = () => { 25 | switch (step) { 26 | case 'signin': 27 | return { 28 | title: 'Sign in', 29 | description: 30 | "To sign in, scan the code below with your phone's camera.", 31 | showQR: true, 32 | qrUrl: url, 33 | showOpenButton: true, 34 | }; 35 | 36 | case 'loading': 37 | return { 38 | title: 'Setting up access...', 39 | description: 40 | 'Checking your account permissions and setting up secure access.', 41 | showQR: false, 42 | qrUrl: '', 43 | showOpenButton: false, 44 | }; 45 | 46 | case 'access': 47 | return { 48 | title: 'Grant Access', 49 | description: ( 50 |
51 |

52 | Allow this app to access your Farcaster account: 53 |

54 |
55 |
56 |
57 | 62 | 67 | 68 |
69 |
70 |
71 | Read Access 72 |
73 |
74 | View your profile and public information 75 |
76 |
77 |
78 |
79 |
80 | 85 | 86 | 87 |
88 |
89 |
90 | Write Access 91 |
92 |
93 | Post casts, likes, and update your profile 94 |
95 |
96 |
97 |
98 |
99 | ), 100 | // Show QR code if we have signer approval URL, otherwise show loading 101 | showQR: !!signerApprovalUrl, 102 | qrUrl: signerApprovalUrl || '', 103 | showOpenButton: !!signerApprovalUrl, 104 | }; 105 | 106 | default: 107 | return { 108 | title: 'Sign in', 109 | description: 110 | "To signin, scan the code below with your phone's camera.", 111 | showQR: true, 112 | qrUrl: url, 113 | showOpenButton: true, 114 | }; 115 | } 116 | }; 117 | 118 | const content = getStepContent(); 119 | 120 | return ( 121 |
122 |
123 |
124 |

125 | {isError ? 'Error' : content.title} 126 |

127 | 145 |
146 | 147 |
148 | {isError ? ( 149 |
150 |
151 | {error?.message || 'Unknown error, please try again.'} 152 |
153 |
154 | ) : ( 155 |
156 |
157 | {typeof content.description === 'string' ? ( 158 |

159 | {content.description} 160 |

161 | ) : ( 162 | content.description 163 | )} 164 |
165 | 166 |
167 | {content.showQR && content.qrUrl ? ( 168 |
169 | {/* eslint-disable-next-line @next/next/no-img-element */} 170 | QR Code 177 |
178 | ) : step === 'loading' || isLoading ? ( 179 |
180 |
181 |
182 | 183 | {step === 'loading' 184 | ? 'Setting up access...' 185 | : 'Loading...'} 186 | 187 |
188 |
189 | ) : null} 190 |
191 | 192 | {content.showOpenButton && content.qrUrl && ( 193 | 214 | )} 215 |
216 | )} 217 |
218 |
219 |
220 | ); 221 | } 222 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthOptions, getServerSession } from 'next-auth'; 2 | import CredentialsProvider from 'next-auth/providers/credentials'; 3 | import { createAppClient, viemConnector } from '@farcaster/auth-client'; 4 | 5 | declare module 'next-auth' { 6 | interface Session { 7 | provider?: string; 8 | user?: { 9 | fid: number; 10 | object?: 'user'; 11 | username?: string; 12 | display_name?: string; 13 | pfp_url?: string; 14 | custody_address?: string; 15 | profile?: { 16 | bio: { 17 | text: string; 18 | mentioned_profiles?: Array<{ 19 | object: 'user_dehydrated'; 20 | fid: number; 21 | username: string; 22 | display_name: string; 23 | pfp_url: string; 24 | custody_address: string; 25 | }>; 26 | mentioned_profiles_ranges?: Array<{ 27 | start: number; 28 | end: number; 29 | }>; 30 | }; 31 | location?: { 32 | latitude: number; 33 | longitude: number; 34 | address: { 35 | city: string; 36 | state: string; 37 | country: string; 38 | country_code: string; 39 | }; 40 | }; 41 | }; 42 | follower_count?: number; 43 | following_count?: number; 44 | verifications?: string[]; 45 | verified_addresses?: { 46 | eth_addresses: string[]; 47 | sol_addresses: string[]; 48 | primary: { 49 | eth_address: string; 50 | sol_address: string; 51 | }; 52 | }; 53 | verified_accounts?: Array>; 54 | power_badge?: boolean; 55 | url?: string; 56 | experimental?: { 57 | neynar_user_score: number; 58 | deprecation_notice: string; 59 | }; 60 | score?: number; 61 | }; 62 | signers?: { 63 | object: 'signer'; 64 | signer_uuid: string; 65 | public_key: string; 66 | status: 'approved'; 67 | fid: number; 68 | }[]; 69 | } 70 | 71 | interface User { 72 | provider?: string; 73 | signers?: Array<{ 74 | object: 'signer'; 75 | signer_uuid: string; 76 | public_key: string; 77 | status: 'approved'; 78 | fid: number; 79 | }>; 80 | user?: { 81 | object: 'user'; 82 | fid: number; 83 | username: string; 84 | display_name: string; 85 | pfp_url: string; 86 | custody_address: string; 87 | profile: { 88 | bio: { 89 | text: string; 90 | mentioned_profiles?: Array<{ 91 | object: 'user_dehydrated'; 92 | fid: number; 93 | username: string; 94 | display_name: string; 95 | pfp_url: string; 96 | custody_address: string; 97 | }>; 98 | mentioned_profiles_ranges?: Array<{ 99 | start: number; 100 | end: number; 101 | }>; 102 | }; 103 | location?: { 104 | latitude: number; 105 | longitude: number; 106 | address: { 107 | city: string; 108 | state: string; 109 | country: string; 110 | country_code: string; 111 | }; 112 | }; 113 | }; 114 | follower_count: number; 115 | following_count: number; 116 | verifications: string[]; 117 | verified_addresses: { 118 | eth_addresses: string[]; 119 | sol_addresses: string[]; 120 | primary: { 121 | eth_address: string; 122 | sol_address: string; 123 | }; 124 | }; 125 | verified_accounts: Array>; 126 | power_badge: boolean; 127 | url?: string; 128 | experimental?: { 129 | neynar_user_score: number; 130 | deprecation_notice: string; 131 | }; 132 | score: number; 133 | }; 134 | } 135 | 136 | interface JWT { 137 | provider?: string; 138 | signers?: Array<{ 139 | object: 'signer'; 140 | signer_uuid: string; 141 | public_key: string; 142 | status: 'approved'; 143 | fid: number; 144 | }>; 145 | user?: { 146 | object: 'user'; 147 | fid: number; 148 | username: string; 149 | display_name: string; 150 | pfp_url: string; 151 | custody_address: string; 152 | profile: { 153 | bio: { 154 | text: string; 155 | mentioned_profiles?: Array<{ 156 | object: 'user_dehydrated'; 157 | fid: number; 158 | username: string; 159 | display_name: string; 160 | pfp_url: string; 161 | custody_address: string; 162 | }>; 163 | mentioned_profiles_ranges?: Array<{ 164 | start: number; 165 | end: number; 166 | }>; 167 | }; 168 | location?: { 169 | latitude: number; 170 | longitude: number; 171 | address: { 172 | city: string; 173 | state: string; 174 | country: string; 175 | country_code: string; 176 | }; 177 | }; 178 | }; 179 | follower_count: number; 180 | following_count: number; 181 | verifications: string[]; 182 | verified_addresses: { 183 | eth_addresses: string[]; 184 | sol_addresses: string[]; 185 | primary: { 186 | eth_address: string; 187 | sol_address: string; 188 | }; 189 | }; 190 | verified_accounts?: Array>; 191 | power_badge?: boolean; 192 | url?: string; 193 | experimental?: { 194 | neynar_user_score: number; 195 | deprecation_notice: string; 196 | }; 197 | score?: number; 198 | }; 199 | } 200 | } 201 | 202 | function getDomainFromUrl(urlString: string | undefined): string { 203 | if (!urlString) { 204 | console.warn('NEXTAUTH_URL is not set, using localhost:3000 as fallback'); 205 | return 'localhost:3000'; 206 | } 207 | try { 208 | const url = new URL(urlString); 209 | return url.hostname; 210 | } catch (error) { 211 | console.error('Invalid NEXTAUTH_URL:', urlString, error); 212 | console.warn('Using localhost:3000 as fallback'); 213 | return 'localhost:3000'; 214 | } 215 | } 216 | 217 | export const authOptions: AuthOptions = { 218 | // Configure one or more authentication providers 219 | providers: [ 220 | CredentialsProvider({ 221 | id: 'neynar', 222 | name: 'Sign in with Neynar', 223 | credentials: { 224 | message: { 225 | label: 'Message', 226 | type: 'text', 227 | placeholder: '0x0', 228 | }, 229 | signature: { 230 | label: 'Signature', 231 | type: 'text', 232 | placeholder: '0x0', 233 | }, 234 | nonce: { 235 | label: 'Nonce', 236 | type: 'text', 237 | placeholder: 'Custom nonce (optional)', 238 | }, 239 | fid: { 240 | label: 'FID', 241 | type: 'text', 242 | placeholder: '0', 243 | }, 244 | signers: { 245 | label: 'Signers', 246 | type: 'text', 247 | placeholder: 'JSON string of signers', 248 | }, 249 | user: { 250 | label: 'User Data', 251 | type: 'text', 252 | placeholder: 'JSON string of user data', 253 | }, 254 | }, 255 | async authorize(credentials) { 256 | const nonce = credentials?.nonce; 257 | 258 | if (!nonce) { 259 | console.error('No nonce or CSRF token provided for Neynar auth'); 260 | return null; 261 | } 262 | 263 | // For Neynar, we can use a different validation approach 264 | // This could involve validating against Neynar's API or using their SDK 265 | try { 266 | // Validate the signature using Farcaster's auth client (same as Farcaster provider) 267 | const appClient = createAppClient({ 268 | // USE your own RPC URL or else you might get 401 error 269 | ethereum: viemConnector(), 270 | }); 271 | 272 | const baseUrl = 273 | process.env.VERCEL_ENV === 'production' 274 | ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` 275 | : process.env.VERCEL_URL 276 | ? `https://${process.env.VERCEL_URL}` 277 | : process.env.NEXTAUTH_URL || `http://localhost:${process.env.PORT ?? 3000}`; 278 | 279 | const domain = getDomainFromUrl(baseUrl); 280 | 281 | const verifyResponse = await appClient.verifySignInMessage({ 282 | message: credentials?.message as string, 283 | signature: credentials?.signature as `0x${string}`, 284 | domain, 285 | nonce, 286 | }); 287 | 288 | const { success, fid } = verifyResponse; 289 | 290 | if (!success) { 291 | return null; 292 | } 293 | 294 | // Validate that the provided FID matches the verified FID 295 | if (credentials?.fid && parseInt(credentials.fid) !== fid) { 296 | console.error('FID mismatch in Neynar auth'); 297 | return null; 298 | } 299 | 300 | return { 301 | id: fid.toString(), 302 | provider: 'neynar', 303 | signers: credentials?.signers 304 | ? JSON.parse(credentials.signers) 305 | : undefined, 306 | user: credentials?.user ? JSON.parse(credentials.user) : undefined, 307 | }; 308 | } catch (error) { 309 | console.error('Error in Neynar auth:', error); 310 | return null; 311 | } 312 | }, 313 | }), 314 | ], 315 | callbacks: { 316 | session: async ({ session, token }) => { 317 | // Set provider at the root level 318 | session.provider = token.provider as string; 319 | 320 | if (token.provider === 'neynar') { 321 | // For Neynar, use full user data structure from user 322 | session.user = token.user as typeof session.user; 323 | session.signers = token.signers as typeof session.signers; 324 | } 325 | 326 | return session; 327 | }, 328 | jwt: async ({ token, user }) => { 329 | if (user) { 330 | token.provider = user.provider; 331 | token.signers = user.signers; 332 | token.user = user.user; 333 | } 334 | return token; 335 | }, 336 | }, 337 | cookies: { 338 | sessionToken: { 339 | name: `next-auth.session-token`, 340 | options: { 341 | httpOnly: true, 342 | sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', 343 | path: '/', 344 | secure: process.env.NODE_ENV === 'production', 345 | }, 346 | }, 347 | callbackUrl: { 348 | name: `next-auth.callback-url`, 349 | options: { 350 | sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', 351 | path: '/', 352 | secure: process.env.NODE_ENV === 'production', 353 | }, 354 | }, 355 | csrfToken: { 356 | name: `next-auth.csrf-token`, 357 | options: { 358 | httpOnly: true, 359 | sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', 360 | path: '/', 361 | secure: process.env.NODE_ENV === 'production', 362 | }, 363 | }, 364 | }, 365 | }; 366 | 367 | export const getSession = async () => { 368 | try { 369 | return await getServerSession(authOptions); 370 | } catch (error) { 371 | console.error('Error getting server session:', error); 372 | return null; 373 | } 374 | }; 375 | -------------------------------------------------------------------------------- /src/components/ui/tabs/WalletTab.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useMemo, useState, useEffect } from "react"; 4 | import { useAccount, useSendTransaction, useSignTypedData, useWaitForTransactionReceipt, useDisconnect, useConnect, useSwitchChain, useChainId, type Connector } from "wagmi"; 5 | import { useWallet as useSolanaWallet } from '@solana/wallet-adapter-react'; 6 | import { base, degen, mainnet, optimism, unichain } from "wagmi/chains"; 7 | import { Button } from "../Button"; 8 | import { truncateAddress } from "../../../lib/truncateAddress"; 9 | import { renderError } from "../../../lib/errorUtils"; 10 | import { SignEvmMessage } from "../wallet/SignEvmMessage"; 11 | import { SendEth } from "../wallet/SendEth"; 12 | import { SignSolanaMessage } from "../wallet/SignSolanaMessage"; 13 | import { SendSolana } from "../wallet/SendSolana"; 14 | import { USE_WALLET, APP_NAME } from "../../../lib/constants"; 15 | import { useMiniApp } from "@neynar/react"; 16 | 17 | /** 18 | * WalletTab component manages wallet-related UI for both EVM and Solana chains. 19 | * 20 | * This component provides a comprehensive wallet interface that supports: 21 | * - EVM wallet connections (Farcaster Frame, Coinbase Wallet, MetaMask) 22 | * - Solana wallet integration 23 | * - Message signing for both chains 24 | * - Transaction sending for both chains 25 | * - Chain switching for EVM chains 26 | * - Auto-connection in Farcaster clients 27 | * 28 | * The component automatically detects when running in a Farcaster client 29 | * and attempts to auto-connect using the Farcaster Frame connector. 30 | * 31 | * @example 32 | * ```tsx 33 | * 34 | * ``` 35 | */ 36 | 37 | interface WalletStatusProps { 38 | address?: string; 39 | chainId?: number; 40 | } 41 | 42 | /** 43 | * Displays the current wallet address and chain ID. 44 | */ 45 | function WalletStatus({ address, chainId }: WalletStatusProps) { 46 | return ( 47 | <> 48 | {address && ( 49 |
50 | Address:
{truncateAddress(address)}
51 |
52 | )} 53 | {chainId && ( 54 |
55 | Chain ID:
{chainId}
56 |
57 | )} 58 | 59 | ); 60 | } 61 | 62 | interface ConnectionControlsProps { 63 | isConnected: boolean; 64 | context: { 65 | user?: { fid?: number }; 66 | client?: unknown; 67 | } | null; 68 | connect: (args: { connector: Connector }) => void; 69 | connectors: readonly Connector[]; 70 | disconnect: () => void; 71 | } 72 | 73 | /** 74 | * Renders wallet connection controls based on connection state and context. 75 | */ 76 | function ConnectionControls({ 77 | isConnected, 78 | context, 79 | connect, 80 | connectors, 81 | disconnect, 82 | }: ConnectionControlsProps) { 83 | if (isConnected) { 84 | return ( 85 | 88 | ); 89 | } 90 | if (context) { 91 | return ( 92 |
93 | 96 | 106 |
107 | ); 108 | } 109 | return ( 110 |
111 | 114 | 117 |
118 | ); 119 | } 120 | 121 | export function WalletTab() { 122 | // --- State --- 123 | const [evmContractTransactionHash, setEvmContractTransactionHash] = useState(null); 124 | 125 | // --- Hooks --- 126 | const { context } = useMiniApp(); 127 | const { address, isConnected } = useAccount(); 128 | const chainId = useChainId(); 129 | const solanaWallet = useSolanaWallet(); 130 | const { publicKey: solanaPublicKey } = solanaWallet; 131 | 132 | // --- Wagmi Hooks --- 133 | const { 134 | sendTransaction, 135 | error: evmTransactionError, 136 | isError: isEvmTransactionError, 137 | isPending: isEvmTransactionPending, 138 | } = useSendTransaction(); 139 | 140 | const { isLoading: isEvmTransactionConfirming, isSuccess: isEvmTransactionConfirmed } = 141 | useWaitForTransactionReceipt({ 142 | hash: evmContractTransactionHash as `0x${string}`, 143 | }); 144 | 145 | const { 146 | signTypedData, 147 | error: evmSignTypedDataError, 148 | isError: isEvmSignTypedDataError, 149 | isPending: isEvmSignTypedDataPending, 150 | } = useSignTypedData(); 151 | 152 | const { disconnect } = useDisconnect(); 153 | const { connect, connectors } = useConnect(); 154 | 155 | const { 156 | switchChain, 157 | error: chainSwitchError, 158 | isError: isChainSwitchError, 159 | isPending: isChainSwitchPending, 160 | } = useSwitchChain(); 161 | 162 | // --- Effects --- 163 | /** 164 | * Auto-connect when Farcaster context is available. 165 | * 166 | * This effect detects when the app is running in a Farcaster client 167 | * and automatically attempts to connect using the Farcaster Frame connector. 168 | * It includes comprehensive logging for debugging connection issues. 169 | */ 170 | useEffect(() => { 171 | // Check if we're in a Farcaster client environment 172 | const isInFarcasterClient = typeof window !== 'undefined' && 173 | (window.location.href.includes('warpcast.com') || 174 | window.location.href.includes('farcaster') || 175 | window.ethereum?.isFarcaster || 176 | context?.client); 177 | 178 | if (context?.user?.fid && !isConnected && connectors.length > 0 && isInFarcasterClient) { 179 | console.log("Attempting auto-connection with Farcaster context..."); 180 | console.log("- User FID:", context.user.fid); 181 | console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`)); 182 | console.log("- Using connector:", connectors[0].name); 183 | console.log("- In Farcaster client:", isInFarcasterClient); 184 | 185 | // Use the first connector (farcasterFrame) for auto-connection 186 | try { 187 | connect({ connector: connectors[0] }); 188 | } catch (error) { 189 | console.error("Auto-connection failed:", error); 190 | } 191 | } else { 192 | console.log("Auto-connection conditions not met:"); 193 | console.log("- Has context:", !!context?.user?.fid); 194 | console.log("- Is connected:", isConnected); 195 | console.log("- Has connectors:", connectors.length > 0); 196 | console.log("- In Farcaster client:", isInFarcasterClient); 197 | } 198 | }, [context?.user?.fid, isConnected, connectors, connect, context?.client]); 199 | 200 | // --- Computed Values --- 201 | /** 202 | * Determines the next chain to switch to based on the current chain. 203 | * Cycles through: Base → Optimism → Degen → Mainnet → Unichain → Base 204 | */ 205 | const nextChain = useMemo(() => { 206 | if (chainId === base.id) { 207 | return optimism; 208 | } else if (chainId === optimism.id) { 209 | return degen; 210 | } else if (chainId === degen.id) { 211 | return mainnet; 212 | } else if (chainId === mainnet.id) { 213 | return unichain; 214 | } else { 215 | return base; 216 | } 217 | }, [chainId]); 218 | 219 | // --- Handlers --- 220 | /** 221 | * Handles switching to the next chain in the rotation. 222 | * Uses the switchChain function from wagmi to change the active chain. 223 | */ 224 | const handleSwitchChain = useCallback(() => { 225 | switchChain({ chainId: nextChain.id }); 226 | }, [switchChain, nextChain.id]); 227 | 228 | /** 229 | * Sends a transaction to call the yoink() function on the Yoink contract. 230 | * 231 | * This function sends a transaction to a specific contract address with 232 | * the encoded function call data for the yoink() function. 233 | */ 234 | const sendEvmContractTransaction = useCallback(() => { 235 | sendTransaction( 236 | { 237 | // call yoink() on Yoink contract 238 | to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878", 239 | data: "0x9846cd9efc000023c0", 240 | }, 241 | { 242 | onSuccess: (hash) => { 243 | setEvmContractTransactionHash(hash); 244 | }, 245 | } 246 | ); 247 | }, [sendTransaction]); 248 | 249 | /** 250 | * Signs typed data using EIP-712 standard. 251 | * 252 | * This function creates a typed data structure with the app name, version, 253 | * and chain ID, then requests the user to sign it. 254 | */ 255 | const signTyped = useCallback(() => { 256 | signTypedData({ 257 | domain: { 258 | name: APP_NAME, 259 | version: "1", 260 | chainId, 261 | }, 262 | types: { 263 | Message: [{ name: "content", type: "string" }], 264 | }, 265 | message: { 266 | content: `Hello from ${APP_NAME}!`, 267 | }, 268 | primaryType: "Message", 269 | }); 270 | }, [chainId, signTypedData]); 271 | 272 | // --- Early Return --- 273 | if (!USE_WALLET) { 274 | return null; 275 | } 276 | 277 | // --- Render --- 278 | return ( 279 |
280 | {/* Wallet Information Display */} 281 | 282 | 283 | {/* Connection Controls */} 284 | 291 | 292 | {/* EVM Wallet Components */} 293 | 294 | 295 | {isConnected && ( 296 | <> 297 | 298 | 306 | {isEvmTransactionError && renderError(evmTransactionError)} 307 | {evmContractTransactionHash && ( 308 |
309 |
Hash: {truncateAddress(evmContractTransactionHash)}
310 |
311 | Status:{" "} 312 | {isEvmTransactionConfirming 313 | ? "Confirming..." 314 | : isEvmTransactionConfirmed 315 | ? "Confirmed!" 316 | : "Pending"} 317 |
318 |
319 | )} 320 | 328 | {isEvmSignTypedDataError && renderError(evmSignTypedDataError)} 329 | 337 | {isChainSwitchError && renderError(chainSwitchError)} 338 | 339 | )} 340 | 341 | {/* Solana Wallet Components */} 342 | {solanaPublicKey && ( 343 | <> 344 | 345 | 346 | 347 | )} 348 |
349 | ); 350 | } -------------------------------------------------------------------------------- /src/components/ui/NeynarAuthButton/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | /** 4 | * This authentication system is designed to work both in a regular web browser and inside a miniapp. 5 | * In other words, it supports authentication when the miniapp context is not present (web browser) as well as when the app is running inside the miniapp. 6 | * If you only need authentication for a web application, follow the Webapp flow; 7 | * if you only need authentication inside a miniapp, follow the Miniapp flow. 8 | */ 9 | 10 | import '@farcaster/auth-kit/styles.css'; 11 | import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; 12 | import { useCallback, useEffect, useState, useRef } from 'react'; 13 | import { cn } from '~/lib/utils'; 14 | import { Button } from '~/components/ui/Button'; 15 | import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; 16 | import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; 17 | import { getItem, removeItem, setItem } from '~/lib/localStorage'; 18 | import { useMiniApp } from '@neynar/react'; 19 | import { 20 | signIn as miniappSignIn, 21 | signOut as miniappSignOut, 22 | useSession, 23 | } from 'next-auth/react'; 24 | import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk'; 25 | 26 | type User = { 27 | fid: number; 28 | username: string; 29 | display_name: string; 30 | pfp_url: string; 31 | // Add other user properties as needed 32 | }; 33 | 34 | const STORAGE_KEY = 'neynar_authenticated_user'; 35 | const FARCASTER_FID = 9152; 36 | 37 | interface StoredAuthState { 38 | isAuthenticated: boolean; 39 | user: { 40 | object: 'user'; 41 | fid: number; 42 | username: string; 43 | display_name: string; 44 | pfp_url: string; 45 | custody_address: string; 46 | profile: { 47 | bio: { 48 | text: string; 49 | mentioned_profiles?: Array<{ 50 | object: 'user_dehydrated'; 51 | fid: number; 52 | username: string; 53 | display_name: string; 54 | pfp_url: string; 55 | custody_address: string; 56 | }>; 57 | mentioned_profiles_ranges?: Array<{ 58 | start: number; 59 | end: number; 60 | }>; 61 | }; 62 | location?: { 63 | latitude: number; 64 | longitude: number; 65 | address: { 66 | city: string; 67 | state: string; 68 | country: string; 69 | country_code: string; 70 | }; 71 | }; 72 | }; 73 | follower_count: number; 74 | following_count: number; 75 | verifications: string[]; 76 | verified_addresses: { 77 | eth_addresses: string[]; 78 | sol_addresses: string[]; 79 | primary: { 80 | eth_address: string; 81 | sol_address: string; 82 | }; 83 | }; 84 | verified_accounts: Array>; 85 | power_badge: boolean; 86 | url?: string; 87 | experimental?: { 88 | neynar_user_score: number; 89 | deprecation_notice: string; 90 | }; 91 | score: number; 92 | } | null; 93 | signers: { 94 | object: 'signer'; 95 | signer_uuid: string; 96 | public_key: string; 97 | status: 'approved'; 98 | fid: number; 99 | }[]; 100 | } 101 | 102 | // Main Custom SignInButton Component 103 | export function NeynarAuthButton() { 104 | const [nonce, setNonce] = useState(null); 105 | const [storedAuth, setStoredAuth] = useState(null); 106 | const [signersLoading, setSignersLoading] = useState(false); 107 | const { context } = useMiniApp(); 108 | const { data: session } = useSession(); 109 | // New state for unified dialog flow 110 | const [showDialog, setShowDialog] = useState(false); 111 | const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( 112 | 'loading' 113 | ); 114 | const [signerApprovalUrl, setSignerApprovalUrl] = useState( 115 | null 116 | ); 117 | const [pollingInterval, setPollingInterval] = useState( 118 | null 119 | ); 120 | const [message, setMessage] = useState(null); 121 | const [signature, setSignature] = useState(null); 122 | const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); 123 | const signerFlowStartedRef = useRef(false); 124 | 125 | // Determine which flow to use based on context 126 | const useMiniappFlow = context !== undefined; 127 | 128 | // Helper function to create a signer 129 | const createSigner = useCallback(async () => { 130 | try { 131 | const response = await fetch('/api/auth/signer', { 132 | method: 'POST', 133 | }); 134 | 135 | if (!response.ok) { 136 | throw new Error('Failed to create signer'); 137 | } 138 | 139 | const signerData = await response.json(); 140 | return signerData; 141 | } catch (error) { 142 | console.error('❌ Error creating signer:', error); 143 | // throw error; 144 | } 145 | }, []); 146 | 147 | // Helper function to update session with signers (miniapp flow only) 148 | const updateSessionWithSigners = useCallback( 149 | async ( 150 | signers: StoredAuthState['signers'], 151 | user: StoredAuthState['user'] 152 | ) => { 153 | if (!useMiniappFlow) return; 154 | 155 | try { 156 | // For miniapp flow, we need to sign in again with the additional data 157 | if (message && signature) { 158 | const signInData = { 159 | message, 160 | signature, 161 | redirect: false, 162 | nonce: nonce || '', 163 | fid: user?.fid?.toString() || '', 164 | signers: JSON.stringify(signers), 165 | user: JSON.stringify(user), 166 | }; 167 | 168 | await miniappSignIn('neynar', signInData); 169 | } 170 | } catch (error) { 171 | console.error('❌ Error updating session with signers:', error); 172 | } 173 | }, 174 | [useMiniappFlow, message, signature, nonce] 175 | ); 176 | 177 | // Helper function to fetch user data from Neynar API 178 | const fetchUserData = useCallback( 179 | async (fid: number): Promise => { 180 | try { 181 | const response = await fetch(`/api/users?fids=${fid}`); 182 | if (response.ok) { 183 | const data = await response.json(); 184 | return data.users?.[0] || null; 185 | } 186 | return null; 187 | } catch (error) { 188 | console.error('Error fetching user data:', error); 189 | return null; 190 | } 191 | }, 192 | [] 193 | ); 194 | 195 | // Helper function to generate signed key request 196 | const generateSignedKeyRequest = useCallback( 197 | async (signerUuid: string, publicKey: string) => { 198 | try { 199 | // Prepare request body 200 | const requestBody: { 201 | signerUuid: string; 202 | publicKey: string; 203 | sponsor?: { sponsored_by_neynar: boolean }; 204 | } = { 205 | signerUuid, 206 | publicKey, 207 | }; 208 | 209 | const response = await fetch('/api/auth/signer/signed_key', { 210 | method: 'POST', 211 | headers: { 212 | 'Content-Type': 'application/json', 213 | }, 214 | body: JSON.stringify(requestBody), 215 | }); 216 | 217 | if (!response.ok) { 218 | const errorData = await response.json(); 219 | throw new Error( 220 | `Failed to generate signed key request: ${errorData.error}` 221 | ); 222 | } 223 | 224 | const data = await response.json(); 225 | 226 | return data; 227 | } catch (error) { 228 | console.error('❌ Error generating signed key request:', error); 229 | // throw error; 230 | } 231 | }, 232 | [] 233 | ); 234 | 235 | // Helper function to fetch all signers 236 | const fetchAllSigners = useCallback( 237 | async (message: string, signature: string) => { 238 | try { 239 | setSignersLoading(true); 240 | 241 | const endpoint = useMiniappFlow 242 | ? `/api/auth/session-signers?message=${encodeURIComponent( 243 | message 244 | )}&signature=${signature}` 245 | : `/api/auth/signers?message=${encodeURIComponent( 246 | message 247 | )}&signature=${signature}`; 248 | 249 | const response = await fetch(endpoint); 250 | const signerData = await response.json(); 251 | 252 | if (response.ok) { 253 | if (useMiniappFlow) { 254 | // For miniapp flow, update session with signers 255 | if (signerData.signers && signerData.signers.length > 0) { 256 | const user = 257 | signerData.user || 258 | (await fetchUserData(signerData.signers[0].fid)); 259 | await updateSessionWithSigners(signerData.signers, user); 260 | } 261 | return signerData.signers; 262 | } else { 263 | // For webapp flow, store in localStorage 264 | let user: StoredAuthState['user'] | null = null; 265 | 266 | if (signerData.signers && signerData.signers.length > 0) { 267 | const fetchedUser = (await fetchUserData( 268 | signerData.signers[0].fid 269 | )) as StoredAuthState['user']; 270 | user = fetchedUser; 271 | } 272 | 273 | // Store signers in localStorage, preserving existing auth data 274 | const updatedState: StoredAuthState = { 275 | isAuthenticated: !!user, 276 | signers: signerData.signers || [], 277 | user, 278 | }; 279 | setItem(STORAGE_KEY, updatedState); 280 | setStoredAuth(updatedState); 281 | 282 | return signerData.signers; 283 | } 284 | } else { 285 | console.error('❌ Failed to fetch signers'); 286 | // throw new Error('Failed to fetch signers'); 287 | } 288 | } catch (error) { 289 | console.error('❌ Error fetching signers:', error); 290 | // throw error; 291 | } finally { 292 | setSignersLoading(false); 293 | } 294 | }, 295 | [useMiniappFlow, fetchUserData, updateSessionWithSigners] 296 | ); 297 | 298 | // Helper function to poll signer status 299 | const startPolling = useCallback( 300 | (signerUuid: string, message: string, signature: string) => { 301 | // Clear any existing polling interval before starting a new one 302 | if (pollingInterval) { 303 | clearInterval(pollingInterval); 304 | } 305 | 306 | let retryCount = 0; 307 | const maxRetries = 10; // Maximum 10 retries (20 seconds total) 308 | const maxPollingTime = 60000; // Maximum 60 seconds of polling 309 | const startTime = Date.now(); 310 | 311 | const interval = setInterval(async () => { 312 | // Check if we've been polling too long 313 | if (Date.now() - startTime > maxPollingTime) { 314 | clearInterval(interval); 315 | setPollingInterval(null); 316 | return; 317 | } 318 | 319 | try { 320 | const response = await fetch( 321 | `/api/auth/signer?signerUuid=${signerUuid}` 322 | ); 323 | 324 | if (!response.ok) { 325 | // Check if it's a rate limit error 326 | if (response.status === 429) { 327 | clearInterval(interval); 328 | setPollingInterval(null); 329 | return; 330 | } 331 | 332 | // Increment retry count for other errors 333 | retryCount++; 334 | if (retryCount >= maxRetries) { 335 | clearInterval(interval); 336 | setPollingInterval(null); 337 | return; 338 | } 339 | 340 | throw new Error(`Failed to poll signer status: ${response.status}`); 341 | } 342 | 343 | const signerData = await response.json(); 344 | 345 | if (signerData.status === 'approved') { 346 | clearInterval(interval); 347 | setPollingInterval(null); 348 | setShowDialog(false); 349 | setDialogStep('signin'); 350 | setSignerApprovalUrl(null); 351 | 352 | // Refetch all signers 353 | await fetchAllSigners(message, signature); 354 | } 355 | } catch (error) { 356 | console.error('❌ Error polling signer:', error); 357 | } 358 | }, 2000); // Poll every 2 second 359 | 360 | setPollingInterval(interval); 361 | }, 362 | [fetchAllSigners, pollingInterval] 363 | ); 364 | 365 | // Cleanup polling on unmount 366 | useEffect(() => { 367 | return () => { 368 | if (pollingInterval) { 369 | clearInterval(pollingInterval); 370 | } 371 | signerFlowStartedRef.current = false; 372 | }; 373 | }, [pollingInterval]); 374 | 375 | // Generate nonce 376 | useEffect(() => { 377 | const generateNonce = async () => { 378 | try { 379 | const response = await fetch('/api/auth/nonce'); 380 | if (response.ok) { 381 | const data = await response.json(); 382 | setNonce(data.nonce); 383 | } else { 384 | console.error('Failed to fetch nonce'); 385 | } 386 | } catch (error) { 387 | console.error('Error generating nonce:', error); 388 | } 389 | }; 390 | 391 | generateNonce(); 392 | }, []); 393 | 394 | // Load stored auth state on mount (only for webapp flow) 395 | useEffect(() => { 396 | if (!useMiniappFlow) { 397 | const stored = getItem(STORAGE_KEY); 398 | if (stored && stored.isAuthenticated) { 399 | setStoredAuth(stored); 400 | } 401 | } 402 | }, [useMiniappFlow]); 403 | 404 | // Success callback - this is critical! 405 | const onSuccessCallback = useCallback( 406 | async (res: UseSignInData) => { 407 | if (!useMiniappFlow) { 408 | // Only handle localStorage for webapp flow 409 | const existingAuth = getItem(STORAGE_KEY); 410 | const user = res.fid ? await fetchUserData(res.fid) : null; 411 | const authState: StoredAuthState = { 412 | ...existingAuth, 413 | isAuthenticated: true, 414 | user: user as StoredAuthState['user'], 415 | signers: existingAuth?.signers || [], // Preserve existing signers 416 | }; 417 | setItem(STORAGE_KEY, authState); 418 | setStoredAuth(authState); 419 | } 420 | // For miniapp flow, the session will be handled by NextAuth 421 | }, 422 | [useMiniappFlow, fetchUserData] 423 | ); 424 | 425 | // Error callback 426 | const onErrorCallback = useCallback((error?: Error | null) => { 427 | console.error('❌ Sign in error:', error); 428 | }, []); 429 | 430 | const signInState = useSignIn({ 431 | nonce: nonce || undefined, 432 | onSuccess: onSuccessCallback, 433 | onError: onErrorCallback, 434 | }); 435 | 436 | const { 437 | signIn: webappSignIn, 438 | signOut: webappSignOut, 439 | connect, 440 | reconnect, 441 | isSuccess, 442 | isError, 443 | error, 444 | channelToken, 445 | url, 446 | data, 447 | validSignature, 448 | } = signInState; 449 | 450 | useEffect(() => { 451 | setMessage(data?.message || null); 452 | setSignature(data?.signature || null); 453 | 454 | // Reset the signer flow flag when message/signature change 455 | if (data?.message && data?.signature) { 456 | signerFlowStartedRef.current = false; 457 | } 458 | }, [data?.message, data?.signature]); 459 | 460 | // Connect for webapp flow when nonce is available 461 | useEffect(() => { 462 | if (!useMiniappFlow && nonce && !channelToken) { 463 | connect(); 464 | } 465 | }, [useMiniappFlow, nonce, channelToken, connect]); 466 | 467 | // Handle fetching signers after successful authentication 468 | useEffect(() => { 469 | if ( 470 | message && 471 | signature && 472 | !isSignerFlowRunning && 473 | !signerFlowStartedRef.current 474 | ) { 475 | signerFlowStartedRef.current = true; 476 | 477 | const handleSignerFlow = async () => { 478 | setIsSignerFlowRunning(true); 479 | try { 480 | const clientContext = context?.client as Record; 481 | const isMobileContext = 482 | clientContext?.platformType === 'mobile' && 483 | clientContext?.clientFid === FARCASTER_FID; 484 | 485 | // Step 1: Change to loading state 486 | setDialogStep('loading'); 487 | 488 | // Show dialog if not using miniapp flow or in browser farcaster 489 | if ((useMiniappFlow && !isMobileContext) || !useMiniappFlow) 490 | setShowDialog(true); 491 | 492 | // First, fetch existing signers 493 | const signers = await fetchAllSigners(message, signature); 494 | 495 | if (useMiniappFlow && isMobileContext) setSignersLoading(true); 496 | 497 | // Check if no signers exist or if we have empty signers 498 | if (!signers || signers.length === 0) { 499 | // Step 1: Create a signer 500 | const newSigner = await createSigner(); 501 | 502 | // Step 2: Generate signed key request 503 | const signedKeyData = await generateSignedKeyRequest( 504 | newSigner.signer_uuid, 505 | newSigner.public_key 506 | ); 507 | 508 | // Step 3: Show QR code in access dialog for signer approval 509 | setSignerApprovalUrl(signedKeyData.signer_approval_url); 510 | 511 | if (isMobileContext) { 512 | setShowDialog(false); 513 | await sdk.actions.openUrl( 514 | signedKeyData.signer_approval_url.replace( 515 | 'https://client.farcaster.xyz/deeplinks/signed-key-request', 516 | 'https://farcaster.xyz/~/connect' 517 | ) 518 | ); 519 | } else { 520 | setShowDialog(true); // Ensure dialog is shown during loading 521 | setDialogStep('access'); 522 | } 523 | 524 | // Step 4: Start polling for signer approval 525 | startPolling(newSigner.signer_uuid, message, signature); 526 | } else { 527 | // If signers exist, close the dialog 528 | setSignersLoading(false); 529 | setShowDialog(false); 530 | setDialogStep('signin'); 531 | } 532 | } catch (error) { 533 | console.error('❌ Error in signer flow:', error); 534 | // On error, reset to signin step and hide dialog 535 | setDialogStep('signin'); 536 | setSignersLoading(false); 537 | setShowDialog(false); 538 | setSignerApprovalUrl(null); 539 | } finally { 540 | setIsSignerFlowRunning(false); 541 | } 542 | }; 543 | 544 | handleSignerFlow(); 545 | } 546 | }, [message, signature]); // Simplified dependencies 547 | 548 | // Miniapp flow using NextAuth 549 | const handleMiniappSignIn = useCallback(async () => { 550 | if (!nonce) { 551 | console.error('❌ No nonce available for miniapp sign-in'); 552 | return; 553 | } 554 | 555 | try { 556 | setSignersLoading(true); 557 | const result = await sdk.actions.signIn({ nonce }); 558 | 559 | const signInData = { 560 | message: result.message, 561 | signature: result.signature, 562 | redirect: false, 563 | nonce: nonce, 564 | }; 565 | 566 | const nextAuthResult = await miniappSignIn('neynar', signInData); 567 | if (nextAuthResult?.ok) { 568 | setMessage(result.message); 569 | setSignature(result.signature); 570 | } else { 571 | console.error('❌ NextAuth sign-in failed:', nextAuthResult); 572 | } 573 | } catch (e) { 574 | if (e instanceof SignInCore.RejectedByUser) { 575 | console.log('ℹ️ Sign-in rejected by user'); 576 | } else { 577 | console.error('❌ Miniapp sign-in error:', e); 578 | } 579 | } finally { 580 | setSignersLoading(false); 581 | } 582 | }, [nonce]); 583 | 584 | const handleWebappSignIn = useCallback(() => { 585 | if (isError) { 586 | reconnect(); 587 | } 588 | setDialogStep('signin'); 589 | setShowDialog(true); 590 | webappSignIn(); 591 | }, [isError, reconnect, webappSignIn]); 592 | 593 | const handleSignOut = useCallback(async () => { 594 | try { 595 | setSignersLoading(true); 596 | 597 | if (useMiniappFlow) { 598 | // Only sign out from NextAuth if the current session is from Neynar provider 599 | if (session?.provider === 'neynar') { 600 | await miniappSignOut({ redirect: false }); 601 | } 602 | } else { 603 | // Webapp flow sign out 604 | webappSignOut(); 605 | removeItem(STORAGE_KEY); 606 | setStoredAuth(null); 607 | } 608 | 609 | // Common cleanup for both flows 610 | setShowDialog(false); 611 | setDialogStep('signin'); 612 | setSignerApprovalUrl(null); 613 | setMessage(null); 614 | setSignature(null); 615 | 616 | // Reset polling interval 617 | if (pollingInterval) { 618 | clearInterval(pollingInterval); 619 | setPollingInterval(null); 620 | } 621 | 622 | // Reset signer flow flag 623 | signerFlowStartedRef.current = false; 624 | } catch (error) { 625 | console.error('❌ Error during sign out:', error); 626 | // Optionally handle error state 627 | } finally { 628 | setSignersLoading(false); 629 | } 630 | }, [useMiniappFlow, webappSignOut, pollingInterval, session]); 631 | 632 | const authenticated = useMiniappFlow 633 | ? !!( 634 | session?.provider === 'neynar' && 635 | session?.user?.fid && 636 | session?.signers && 637 | session.signers.length > 0 638 | ) 639 | : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && 640 | !!(storedAuth?.signers && storedAuth.signers.length > 0); 641 | 642 | const userData = useMiniappFlow 643 | ? { 644 | fid: session?.user?.fid, 645 | username: session?.user?.username || '', 646 | pfpUrl: session?.user?.pfp_url || '', 647 | } 648 | : { 649 | fid: storedAuth?.user?.fid, 650 | username: storedAuth?.user?.username || '', 651 | pfpUrl: storedAuth?.user?.pfp_url || '', 652 | }; 653 | 654 | // Show loading state while nonce is being fetched or signers are loading 655 | if (!nonce || signersLoading) { 656 | return ( 657 |
658 |
659 |
660 | 661 | Loading... 662 | 663 |
664 |
665 | ); 666 | } 667 | 668 | return ( 669 | <> 670 | {authenticated ? ( 671 | 672 | ) : ( 673 | 694 | )} 695 | 696 | {/* Unified Auth Dialog */} 697 | { 698 | { 701 | setShowDialog(false); 702 | setDialogStep('signin'); 703 | setSignerApprovalUrl(null); 704 | if (pollingInterval) { 705 | clearInterval(pollingInterval); 706 | setPollingInterval(null); 707 | } 708 | }} 709 | url={url} 710 | isError={isError} 711 | error={error} 712 | step={dialogStep} 713 | isLoading={signersLoading} 714 | signerApprovalUrl={signerApprovalUrl} 715 | /> 716 | } 717 | 718 | ); 719 | } 720 | --------------------------------------------------------------------------------