From 86029b2bd9cef2975b4aa922d18a6441730acbdb Mon Sep 17 00:00:00 2001 From: veganbeef Date: Mon, 14 Jul 2025 13:01:46 -0700 Subject: [PATCH 1/2] feat: replace next-auth with quick auth --- bin/init.js | 7 +- package.json | 2 +- scripts/deploy.ts | 45 +- scripts/dev.js | 2 +- src/app/api/auth/[...nextauth]/route.ts | 6 - src/app/api/auth/update-session/route.ts | 46 -- src/app/api/auth/validate/route.ts | 52 +++ .../app/.well-known/farcaster.json/route.ts | 12 + src/app/app/api/auth/nonce/route.ts | 16 + src/app/app/api/auth/session-signers/route.ts | 43 ++ src/app/app/api/auth/signer/route.ts | 42 ++ .../app/api/auth/signer/signed_key/route.ts | 91 ++++ src/app/app/api/auth/signers/route.ts | 38 ++ src/app/app/api/auth/validate/route.ts | 52 +++ src/app/app/api/best-friends/route.ts | 46 ++ src/app/app/api/opengraph-image/route.tsx | 30 ++ src/app/app/api/send-notification/route.ts | 57 +++ src/app/app/api/users/route.ts | 39 ++ src/app/app/api/webhook/route.ts | 91 ++++ src/app/app/app.tsx | 15 + src/app/app/favicon.ico | Bin 0 -> 5887 bytes src/app/app/globals.css | 118 +++++ src/app/app/layout.tsx | 24 + src/app/app/page.tsx | 24 + src/app/app/providers.tsx | 34 ++ src/app/app/share/[fid]/page.tsx | 34 ++ src/app/layout.tsx | 5 +- src/app/providers.tsx | 27 +- src/auth.ts | 441 +----------------- .../ui/NeynarAuthButton/ProfileButton.tsx | 4 +- src/components/ui/NeynarAuthButton/index.tsx | 319 +++++-------- src/components/ui/wallet/SignIn.tsx | 91 ++-- src/hooks/useQuickAuth.ts | 204 ++++++++ 33 files changed, 1260 insertions(+), 797 deletions(-) delete mode 100644 src/app/api/auth/[...nextauth]/route.ts delete mode 100644 src/app/api/auth/update-session/route.ts create mode 100644 src/app/api/auth/validate/route.ts create mode 100644 src/app/app/.well-known/farcaster.json/route.ts create mode 100644 src/app/app/api/auth/nonce/route.ts create mode 100644 src/app/app/api/auth/session-signers/route.ts create mode 100644 src/app/app/api/auth/signer/route.ts create mode 100644 src/app/app/api/auth/signer/signed_key/route.ts create mode 100644 src/app/app/api/auth/signers/route.ts create mode 100644 src/app/app/api/auth/validate/route.ts create mode 100644 src/app/app/api/best-friends/route.ts create mode 100644 src/app/app/api/opengraph-image/route.tsx create mode 100644 src/app/app/api/send-notification/route.ts create mode 100644 src/app/app/api/users/route.ts create mode 100644 src/app/app/api/webhook/route.ts create mode 100644 src/app/app/app.tsx create mode 100644 src/app/app/favicon.ico create mode 100644 src/app/app/globals.css create mode 100644 src/app/app/layout.tsx create mode 100644 src/app/app/page.tsx create mode 100644 src/app/app/providers.tsx create mode 100644 src/app/app/share/[fid]/page.tsx create mode 100644 src/hooks/useQuickAuth.ts diff --git a/bin/init.js b/bin/init.js index f177adf..d366560 100644 --- a/bin/init.js +++ b/bin/init.js @@ -460,6 +460,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', '@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', + '@farcaster/quick-auth': '>=0.0.7 <1.0.0', '@neynar/react': '^1.2.5', '@radix-ui/react-label': '^2.1.1', '@solana/wallet-adapter-react': '^0.15.38', @@ -471,7 +472,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe 'lucide-react': '^0.469.0', mipd: '^0.0.7', next: '^15', - 'next-auth': '^4.24.11', react: '^19', 'react-dom': '^19', 'tailwind-merge': '^2.6.0', @@ -483,6 +483,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe }; packageJson.devDependencies = { + "@types/inquirer": "^9.0.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -494,8 +495,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe "pino-pretty": "^13.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", - "typescript": "^5", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "typescript": "^5" }; // Add Neynar SDK if selected diff --git a/package.json b/package.json index 46bbf50..972720d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.6.2", + "version": "1.7.0", "type": "module", "private": false, "access": "public", diff --git a/scripts/deploy.ts b/scripts/deploy.ts index e64d4d2..35e6850 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -5,7 +5,6 @@ import os from 'os'; import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; import dotenv from 'dotenv'; -import crypto from 'crypto'; import { Vercel } from '@vercel/sdk'; import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants'; @@ -130,7 +129,7 @@ async function checkRequiredEnvVars(): Promise { process.env.SPONSOR_SIGNER = sponsorSigner.toString(); - if (storeSeedPhrase) { + if (process.env.SEED_PHRASE) { fs.appendFileSync( '.env.local', `\nSPONSOR_SIGNER="${sponsorSigner}"` @@ -287,17 +286,26 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: } // Get existing environment variables - const existingVars = await vercelClient.projects.getEnvironmentVariables({ + const existingVars = await vercelClient.projects.filterProjectEnvs({ idOrName: projectId, }); - const existingVar = existingVars.envs?.find((env: any) => + // Handle different response types + let envs: any[] = []; + if ('envs' in existingVars && Array.isArray(existingVars.envs)) { + envs = existingVars.envs; + } else if ('target' in existingVars && 'key' in existingVars) { + // Single environment variable response + envs = [existingVars]; + } + + const existingVar = envs.find((env: any) => env.key === key && env.target?.includes('production') ); - if (existingVar) { + if (existingVar && existingVar.id) { // Update existing variable - await vercelClient.projects.editEnvironmentVariable({ + await vercelClient.projects.editProjectEnv({ idOrName: projectId, id: existingVar.id, requestBody: { @@ -308,7 +316,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: console.log(`✅ Updated environment variable: ${key}`); } else { // Create new variable - await vercelClient.projects.createEnvironmentVariable({ + await vercelClient.projects.createProjectEnv({ idOrName: projectId, requestBody: { key: key, @@ -426,7 +434,7 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string, while (Date.now() - startTime < maxWaitTime) { try { - const deployments = await vercelClient?.deployments.list({ + const deployments = await vercelClient?.deployments.getDeployments({ projectId: projectId, limit: 1, }); @@ -558,12 +566,15 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient) { try { - const project = await vercelClient.projects.get({ - idOrName: projectId, - }); - projectName = project.name; - domain = `${projectName}.vercel.app`; - console.log('🌐 Using project name for domain:', domain); + const projects = await vercelClient.projects.getProjects({}); + const project = projects.projects.find(p => p.id === projectId || p.name === projectId); + if (project) { + projectName = project.name; + domain = `${projectName}.vercel.app`; + console.log('🌐 Using project name for domain:', domain); + } else { + throw new Error('Project not found'); + } } catch (error: unknown) { if (error instanceof Error) { console.warn('âš ī¸ Could not get project details via SDK, using CLI fallback'); @@ -615,12 +626,7 @@ async function deployToVercel(useGitHub = false): Promise { } // Prepare environment variables - const nextAuthSecret = - process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); const vercelEnv = { - NEXTAUTH_SECRET: nextAuthSecret, - AUTH_SECRET: nextAuthSecret, - NEXTAUTH_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`, ...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }), @@ -714,7 +720,6 @@ async function deployToVercel(useGitHub = false): Promise { console.log('🔄 Updating environment variables with correct domain...'); const updatedEnv: Record = { - NEXTAUTH_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`, }; diff --git a/scripts/dev.js b/scripts/dev.js index a16a46f..7a95688 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -149,7 +149,7 @@ async function startDev() { nextDev = spawn(nextBin, ['dev', '-p', port.toString()], { stdio: 'inherit', - env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl }, + env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl }, cwd: projectRoot, shell: process.platform === 'win32' // Add shell option for Windows }); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 58262ba..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import NextAuth from "next-auth" -import { authOptions } from "~/auth" - -const handler = NextAuth(authOptions) - -export { handler as GET, handler as POST } diff --git a/src/app/api/auth/update-session/route.ts b/src/app/api/auth/update-session/route.ts deleted file mode 100644 index db4b4fc..0000000 --- a/src/app/api/auth/update-session/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '~/auth'; - -export async function POST(request: Request) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.fid) { - return NextResponse.json( - { error: 'No authenticated session found' }, - { status: 401 } - ); - } - - const body = await request.json(); - const { signers, user } = body; - - if (!signers || !user) { - return NextResponse.json( - { error: 'Signers and user are required' }, - { status: 400 } - ); - } - - // For NextAuth to update the session, we need to trigger the JWT callback - // This is typically done by calling the session endpoint with updated data - // However, we can't directly modify the session token from here - - // Instead, we'll store the data temporarily and let the client refresh the session - // The session will be updated when the JWT callback is triggered - - return NextResponse.json({ - success: true, - message: 'Session update prepared', - signers, - user, - }); - } catch (error) { - console.error('Error preparing session update:', error); - return NextResponse.json( - { error: 'Failed to prepare session update' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/auth/validate/route.ts b/src/app/api/auth/validate/route.ts new file mode 100644 index 0000000..70256f8 --- /dev/null +++ b/src/app/api/auth/validate/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { createClient, Errors } from '@farcaster/quick-auth'; + +const client = createClient(); + +export async function POST(request: Request) { + try { + const { token } = await request.json(); + + if (!token) { + return NextResponse.json( + { error: 'Token is required' }, + { status: 400 } + ); + } + + // Get domain from environment or request + const domain = process.env.NEXT_PUBLIC_URL + ? new URL(process.env.NEXT_PUBLIC_URL).hostname + : request.headers.get('host') || 'localhost'; + + try { + // Use the official QuickAuth library to verify the JWT + const payload = await client.verifyJwt({ + token, + domain, + }); + + return NextResponse.json({ + success: true, + user: { + fid: payload.sub, + }, + }); + } catch (e) { + if (e instanceof Errors.InvalidTokenError) { + console.info('Invalid token:', e.message); + return NextResponse.json( + { error: 'Invalid token' }, + { status: 401 } + ); + } + throw e; + } + } catch (error) { + console.error('Token validation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/app/.well-known/farcaster.json/route.ts b/src/app/app/.well-known/farcaster.json/route.ts new file mode 100644 index 0000000..d116c4f --- /dev/null +++ b/src/app/app/.well-known/farcaster.json/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { getFarcasterDomainManifest } from '~/lib/utils'; + +export async function GET() { + try { + const config = await getFarcasterDomainManifest(); + return NextResponse.json(config); + } catch (error) { + console.error('Error generating metadata:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/app/api/auth/nonce/route.ts b/src/app/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..a1f25ea --- /dev/null +++ b/src/app/app/api/auth/nonce/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET() { + try { + const client = getNeynarClient(); + const response = await client.fetchNonce(); + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching nonce:', error); + return NextResponse.json( + { error: 'Failed to fetch nonce' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/auth/session-signers/route.ts b/src/app/app/api/auth/session-signers/route.ts new file mode 100644 index 0000000..630ef3b --- /dev/null +++ b/src/app/app/api/auth/session-signers/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message || !signature) { + return NextResponse.json( + { error: 'Message and signature are required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + + // Fetch user data if signers exist + let user = null; + if (signers && signers.length > 0 && signers[0].fid) { + const { + users: [fetchedUser], + } = await client.fetchBulkUsers({ + fids: [signers[0].fid], + }); + user = fetchedUser; + } + + return NextResponse.json({ + signers, + user, + }); + } catch (error) { + console.error('Error in session-signers API:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/auth/signer/route.ts b/src/app/app/api/auth/signer/route.ts new file mode 100644 index 0000000..f793d0e --- /dev/null +++ b/src/app/app/api/auth/signer/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function POST() { + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.createSigner(); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signer:', error); + return NextResponse.json( + { error: 'Failed to fetch signer' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const signerUuid = searchParams.get('signerUuid'); + + if (!signerUuid) { + return NextResponse.json( + { error: 'signerUuid is required' }, + { status: 400 } + ); + } + + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.lookupSigner({ + signerUuid, + }); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signed key:', error); + return NextResponse.json( + { error: 'Failed to fetch signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/auth/signer/signed_key/route.ts b/src/app/app/api/auth/signer/signed_key/route.ts new file mode 100644 index 0000000..d7a3df8 --- /dev/null +++ b/src/app/app/api/auth/signer/signed_key/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; +import { mnemonicToAccount } from 'viem/accounts'; +import { + SIGNED_KEY_REQUEST_TYPE, + SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, +} from '~/lib/constants'; + +const postRequiredFields = ['signerUuid', 'publicKey']; + +export async function POST(request: Request) { + const body = await request.json(); + + // Validate required fields + for (const field of postRequiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + const { signerUuid, publicKey, redirectUrl } = body; + + if (redirectUrl && typeof redirectUrl !== 'string') { + return NextResponse.json( + { error: 'redirectUrl must be a string' }, + { status: 400 } + ); + } + + try { + // Get the app's account from seed phrase + const seedPhrase = process.env.SEED_PHRASE; + const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; + + if (!seedPhrase) { + return NextResponse.json( + { error: 'App configuration missing (SEED_PHRASE or FID)' }, + { status: 500 } + ); + } + + const neynarClient = getNeynarClient(); + + const account = mnemonicToAccount(seedPhrase); + + const { + user: { fid }, + } = await neynarClient.lookupUserByCustodyAddress({ + custodyAddress: account.address, + }); + + const appFid = fid; + + // Generate deadline (24 hours from now) + const deadline = Math.floor(Date.now() / 1000) + 86400; + + // Generate EIP-712 signature + const signature = await account.signTypedData({ + domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, + types: { + SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, + }, + primaryType: 'SignedKeyRequest', + message: { + requestFid: BigInt(appFid), + key: publicKey, + deadline: BigInt(deadline), + }, + }); + + const signer = await neynarClient.registerSignedKey({ + appFid, + deadline, + signature, + signerUuid, + ...(redirectUrl && { redirectUrl }), + ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), + }); + + return NextResponse.json(signer); + } catch (error) { + console.error('Error registering signed key:', error); + return NextResponse.json( + { error: 'Failed to register signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/auth/signers/route.ts b/src/app/app/api/auth/signers/route.ts new file mode 100644 index 0000000..1c89acf --- /dev/null +++ b/src/app/app/api/auth/signers/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +const requiredParams = ['message', 'signature']; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const params: Record = {}; + for (const param of requiredParams) { + params[param] = searchParams.get(param); + if (!params[param]) { + return NextResponse.json( + { + error: `${param} parameter is required`, + }, + { status: 400 } + ); + } + } + + const message = params.message as string; + const signature = params.signature as string; + + try { + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + return NextResponse.json({ + signers, + }); + } catch (error) { + console.error('Error fetching signers:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/auth/validate/route.ts b/src/app/app/api/auth/validate/route.ts new file mode 100644 index 0000000..70256f8 --- /dev/null +++ b/src/app/app/api/auth/validate/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { createClient, Errors } from '@farcaster/quick-auth'; + +const client = createClient(); + +export async function POST(request: Request) { + try { + const { token } = await request.json(); + + if (!token) { + return NextResponse.json( + { error: 'Token is required' }, + { status: 400 } + ); + } + + // Get domain from environment or request + const domain = process.env.NEXT_PUBLIC_URL + ? new URL(process.env.NEXT_PUBLIC_URL).hostname + : request.headers.get('host') || 'localhost'; + + try { + // Use the official QuickAuth library to verify the JWT + const payload = await client.verifyJwt({ + token, + domain, + }); + + return NextResponse.json({ + success: true, + user: { + fid: payload.sub, + }, + }); + } catch (e) { + if (e instanceof Errors.InvalidTokenError) { + console.info('Invalid token:', e.message); + return NextResponse.json( + { error: 'Invalid token' }, + { status: 401 } + ); + } + throw e; + } + } catch (error) { + console.error('Token validation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/app/api/best-friends/route.ts b/src/app/app/api/best-friends/route.ts new file mode 100644 index 0000000..925724f --- /dev/null +++ b/src/app/app/api/best-friends/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const apiKey = process.env.NEYNAR_API_KEY; + const { searchParams } = new URL(request.url); + const fid = searchParams.get('fid'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, + { status: 500 } + ); + } + + if (!fid) { + return NextResponse.json( + { error: 'FID parameter is required' }, + { status: 400 } + ); + } + + try { + const response = await fetch( + `https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`, + { + headers: { + "x-api-key": apiKey, + }, + } + ); + + if (!response.ok) { + throw new Error(`Neynar API error: ${response.statusText}`); + } + + const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] }; + + return NextResponse.json({ bestFriends: users }); + } catch (error) { + console.error('Failed to fetch best friends:', error); + return NextResponse.json( + { error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/app/api/opengraph-image/route.tsx b/src/app/app/api/opengraph-image/route.tsx new file mode 100644 index 0000000..b14415f --- /dev/null +++ b/src/app/app/api/opengraph-image/route.tsx @@ -0,0 +1,30 @@ +import { ImageResponse } from "next/og"; +import { NextRequest } from "next/server"; +import { getNeynarUser } from "~/lib/neynar"; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const fid = searchParams.get('fid'); + + const user = fid ? await getNeynarUser(Number(fid)) : null; + + return new ImageResponse( + ( +
+ {user?.pfp_url && ( +
+ Profile +
+ )} +

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

+

Powered by Neynar đŸĒ

+
+ ), + { + width: 1200, + height: 800, + } + ); +} \ No newline at end of file diff --git a/src/app/app/api/send-notification/route.ts b/src/app/app/api/send-notification/route.ts new file mode 100644 index 0000000..7563723 --- /dev/null +++ b/src/app/app/api/send-notification/route.ts @@ -0,0 +1,57 @@ +import { notificationDetailsSchema } from "@farcaster/miniapp-sdk"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { setUserNotificationDetails } from "~/lib/kv"; +import { sendMiniAppNotification } from "~/lib/notifs"; +import { sendNeynarMiniAppNotification } from "~/lib/neynar"; + +const requestSchema = z.object({ + fid: z.number(), + notificationDetails: notificationDetailsSchema, +}); + +export async function POST(request: NextRequest) { + // If Neynar is enabled, we don't need to store notification details + // as they will be managed by Neynar's system + const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; + + const requestJson = await request.json(); + const requestBody = requestSchema.safeParse(requestJson); + + if (requestBody.success === false) { + return Response.json( + { success: false, errors: requestBody.error.errors }, + { status: 400 } + ); + } + + // Only store notification details if not using Neynar + if (!neynarEnabled) { + await setUserNotificationDetails( + Number(requestBody.data.fid), + requestBody.data.notificationDetails + ); + } + + // Use appropriate notification function based on Neynar status + const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification; + const sendResult = await sendNotification({ + fid: Number(requestBody.data.fid), + title: "Test notification", + body: "Sent at " + new Date().toISOString(), + }); + + if (sendResult.state === "error") { + return Response.json( + { success: false, error: sendResult.error }, + { status: 500 } + ); + } else if (sendResult.state === "rate_limit") { + return Response.json( + { success: false, error: "Rate limited" }, + { status: 429 } + ); + } + + return Response.json({ success: true }); +} diff --git a/src/app/app/api/users/route.ts b/src/app/app/api/users/route.ts new file mode 100644 index 0000000..cca1f37 --- /dev/null +++ b/src/app/app/api/users/route.ts @@ -0,0 +1,39 @@ +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const apiKey = process.env.NEYNAR_API_KEY; + const { searchParams } = new URL(request.url); + const fids = searchParams.get('fids'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, + { status: 500 } + ); + } + + if (!fids) { + return NextResponse.json( + { error: 'FIDs parameter is required' }, + { status: 400 } + ); + } + + try { + const neynar = new NeynarAPIClient({ apiKey }); + const fidsArray = fids.split(',').map(fid => parseInt(fid.trim())); + + const { users } = await neynar.fetchBulkUsers({ + fids: fidsArray, + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error('Failed to fetch users:', error); + return NextResponse.json( + { error: 'Failed to fetch users. Please check your Neynar API key and try again.' }, + { status: 500 } + ); + } +} diff --git a/src/app/app/api/webhook/route.ts b/src/app/app/api/webhook/route.ts new file mode 100644 index 0000000..cc49676 --- /dev/null +++ b/src/app/app/api/webhook/route.ts @@ -0,0 +1,91 @@ +import { + ParseWebhookEvent, + parseWebhookEvent, + verifyAppKeyWithNeynar, +} from "@farcaster/miniapp-node"; +import { NextRequest } from "next/server"; +import { APP_NAME } from "~/lib/constants"; +import { + deleteUserNotificationDetails, + setUserNotificationDetails, +} from "~/lib/kv"; +import { sendMiniAppNotification } from "~/lib/notifs"; + +export async function POST(request: NextRequest) { + // If Neynar is enabled, we don't need to handle webhooks here + // as they will be handled by Neynar's webhook endpoint + const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; + if (neynarEnabled) { + return Response.json({ success: true }); + } + + const requestJson = await request.json(); + + let data; + try { + data = await parseWebhookEvent(requestJson, verifyAppKeyWithNeynar); + } catch (e: unknown) { + const error = e as ParseWebhookEvent.ErrorType; + + switch (error.name) { + case "VerifyJsonFarcasterSignature.InvalidDataError": + case "VerifyJsonFarcasterSignature.InvalidEventDataError": + // The request data is invalid + return Response.json( + { success: false, error: error.message }, + { status: 400 } + ); + case "VerifyJsonFarcasterSignature.InvalidAppKeyError": + // The app key is invalid + return Response.json( + { success: false, error: error.message }, + { status: 401 } + ); + case "VerifyJsonFarcasterSignature.VerifyAppKeyError": + // Internal error verifying the app key (caller may want to try again) + return Response.json( + { success: false, error: error.message }, + { status: 500 } + ); + } + } + + const fid = data.fid; + const event = data.event; + + // Only handle notifications if Neynar is not enabled + // When Neynar is enabled, notifications are handled through their webhook + switch (event.event) { + case "frame_added": + if (event.notificationDetails) { + await setUserNotificationDetails(fid, event.notificationDetails); + await sendMiniAppNotification({ + fid, + title: `Welcome to ${APP_NAME}`, + body: "Mini app is now added to your client", + }); + } else { + await deleteUserNotificationDetails(fid); + } + break; + + case "frame_removed": + await deleteUserNotificationDetails(fid); + break; + + case "notifications_enabled": + await setUserNotificationDetails(fid, event.notificationDetails); + await sendMiniAppNotification({ + fid, + title: `Welcome to ${APP_NAME}`, + body: "Notifications are now enabled", + }); + break; + + case "notifications_disabled": + await deleteUserNotificationDetails(fid); + break; + } + + return Response.json({ success: true }); +} diff --git a/src/app/app/app.tsx b/src/app/app/app.tsx new file mode 100644 index 0000000..c9d7d23 --- /dev/null +++ b/src/app/app/app.tsx @@ -0,0 +1,15 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { APP_NAME } from "~/lib/constants"; + +// note: dynamic import is required for components that use the Frame SDK +const AppComponent = dynamic(() => import("~/components/App"), { + ssr: false, +}); + +export default function App( + { title }: { title?: string } = { title: APP_NAME } +) { + return ; +} diff --git a/src/app/app/favicon.ico b/src/app/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..afa21c9b9d12e1fc608444a36d35e773ee977e07 GIT binary patch literal 5887 zcmb`LXH-+$wt!bc=u#9ziBv_9B19=7f&}SZIW&RS~oz|XWpOG0?b9#Kox-6Waa~V zS^zlHbktQ%f*`+$)}C+@u4nx?_v-l{MLBAP&@}dyBu8Cw__gL}(-;H4FBc|+E7Q{N z)5y{;ESy!JXnhdvHfST~PQZA>43O!s2|uD`4bp5@ICC8Jq_(>a>n=tSM-2s|8`%3Z z(2#tYYsZ(=PH<0=ynZ~}jZ`_M*yCFh{yrjNGt%Y<&q?aU0MK9iJL{|0VG)Ll)bO$B zzScWtOXYz(Ct=2^g03vWVQGs64NO>>IWpzp^^k7{N}P_u4AIFFQCE!D-lp8`+qp@` z5MSKYuA)IQwMrk*hZ~tC2#w9rs%p&|#PXt-gQFhPYQN7UUyrQ!Yil!x;s zRnVI>J_oro@b4HI6T~^nJg*EQHOqhf#k2XKj1ec$Z5kMnC7V3|R*Ag~S7W09O zXpv32&e`$heS2=_HpPBN@IKu}p>+LpA^l6YvnTRr!UOXiZx%yqD78nU>$>+|n3s7v zNV54Ra2eDD(b$_dozpY57lGE0Y6Dwe@_Lp`(oAzb;o7Ece-lXRo8f2gxypxz31FSR zqIbTsX2uNm0^uFB(mLIvh~RpRwL=N?X3x$?r(>-s7#kDXYN)E#!y)!F%x+*b@Qq9} zgnJeq61cjz&3X^D1Sut2S6fYR)VT5>)m)Oo@dR9eT$Ux!WX>Acor)107XhBS%?SPT z;Kf$hLg(__7gmU!W%h`j>Z$GNjKF;HWG=e6H5$PoDLG4amZQVbz*A%v5lhJR)-9aC z&;qURxjfR_or9{@{ES%PQ)vHSh>3maqB+0rbG_-`AlG8be9;yJ>Z;bmEis|qzBX@a zV?ik@O1a$pILgfR{QlrFqtl?%6PV0u5TtJ@0f`8PxI5r>-LZcxxGcNxxhX zgti~A28bvFh2j@VJu_0K<>ky_!M1pN2;kaHU&ToWvh{B-n3|#|x8T4x&0K1w?8jC; zE2^a(Se5XM)Ni54p0+9#Qb=oCz;k)|_S|h#^uqnE7!1Is5`wz(N1#7>Bv9ol;F&Cj zF@;B>W4+FHq~I)2u;)}BbE84#?kqATz*}M9vgqPca3$WO#a!PO5(06E1a%(q;%o5` z?Jau2{o-n<4>vlGOZJzUKfq?Wm3N4_L_OQ3#foa+3GyQ?9byuw(L>0Gr|Y`*UrZt+ zW2BJO9_|ZcONu9@mRmqrK;N~TrXO6W&uQ;TJJw@I3lAXN@d8f;z=migwED23LPRy| z0AO9Z_0r?sh7(_`_n4ca=jOFC0m{%i`@2u}!MZ6#o5F?43yxCq9qC!;zzOjj>&`U* z)DweMI@>bP6W)Rc8$MxA{JRILVFl(*EwV7Q6%&*7`WFcZxAveA!1~QK&Coo}t3RMc zUtCwPGl4v@-6%T(S+|ZJ9T!|T^p9e;+Gp-~67+$*!Cmmi5_CLH?8iU{9iT9@7{@bG zEkK|(6i*s+OLP8d<##Wyfm7UOP#oAjj_f60IL&|pbhP%{KZUg`0QyoM>D7hUOHDUG zN0#n+3k!CD{J?Sh7DMbM6xfc1SUiWq?9ARdoPwcML-uXOQNZ~m8uXt9kSq-VMPdNV zjuXPrAqizi--Zc-dTQVQxq{g(?A42#kLrGp($@Lj!=!N%Plqc7R!JxM?hVH+uI~&< zJFZMOJ?7;}uOxgo-m}C=wtA`h=MH!FC2uK4w)67k53L2^u3r@qeS%B6uKAiB%`-G< zlWK~?y!2Z5R3~`?FU*o4q!6@LzqbDPaeNZd2@!(x$WZUmO@11YoA%wFfmPu8xfuMe zB$Im6!1Ba>L3V`-zh9|~Mt8W;Qrt>GH;$HP1#lH7wqUhK)jobd6q51~PY-<}cyS7| zQqaL$lLg1lf3>D{dRJj&87TNIoh7)v+bxXe`1Gp&nl(ny{|sc2`&XUU_&9&S9H$-SZ*$?4Y`{TkVULhAF1zLa^#nZaBgW>Wg-5c}ufg&4kJu)f1dDmpPBE*lr zW)c-`XX&7Wkb65$g%W}1CYuDpf7~%(c_-YiS^sljCy}&z7RILviuKPt810fSCNHG) zkX=894h=lpTFY4pjeL{M?oU1H=S|Oh)poiwF4_uGt{-F)Tq)PXLcKeUG`?pUMaY<| z>`R^nf4!8+3U(i^{oJVcf@ku5cza?aHkjk$+{CbyHX9@>wM~A>;zdS@t9B*cCvHC7 zP_VIaMpB!eTBCHuy=ZRip*XVIbi?tCeD&#PBKj^m7fm8-8y~<5ifU_Lr=Nch{R;&|D!VMqwJJI+ z5wE@J?0>2kwx^^l<2Pf0e%30<#o!{#9m-~XJQFe)W*6On&<&CKi);9PcZ?0cYZ7a6 z4N~zoRi1%3p;E-sx^(QYWpOXq>N!IP?4qoEF&wk{=~j2>n3Is1gpkMgJhyXz!^C}% zCAhA%$Z0#O7~-0~K*^(nvL|NsjgM9$>9QoDNYz~8rKg(2uczzP{+}#Ie{iwv5~SE~ zZDVGfv^gPJHSZ{%hp8Uj*Fx&{bXcq}%Z81c zX~>5CZ8MT14@KHfvb-Lync3eeF_zFXot=HhA&0wgQviyjr5ckYrz_mt=#628lf+Za z82aN2+We3#=&vPtJyH)tTc_ z(P)2mwAc{(gWrSAkIVwvFWTBkHS_65tjO(5ekfAIk~m*0F}I0~-A>^?>uCFA={X$$JxKe-Nv)RnquPRLEtjK85g6tt#R`}5vx#RM~`@@FJPg5K=6Syf+s-rkantTe=HW=N>w27CJ$rP&Tz$&=F( z+QjkQbywn)`SIN+l6Trsb?QB&RNlV5{FyvX=`hbbw^JpDmbM!6BZOTBtWGz*`xk|Z z{C=If9XZ1!cG3G}rkmVmh($_KV}do6ElPX+Z*o-(*F)aNTt#&CGykDb)7T9DER)(; z(`bRUvi;&c!fFe@===R?b)YE4+WC4r=P#exkj7|luZ5-a?ub~A)n<(hV)iOK^j#(& z%r3s6F?0S54%2z5b9ZRd8M7^|6d=!}Ow1CLxblGT^nLjgjXK?AQ|@>wstNk*_XvgP z%ZeRtQb@6L&U85rtkhGwT}2SQRG`BV#QR_gb zyb_1kJo#|B#PDmTTCxEt`}`2&*+ks^;urA3ufL4Ql)h1l_c_3~V7z9ncc#fZimc1N zbdR9SVSUZeeQlO8de9zL5M$*%__QcoMwE{!$n}dZiCp+?l99&tEZ}<{6-e%SbFG=> z$iLwpMKmnNM_Ffj|IHh{3|G0GZI4L1OxFY?<2MGrrbsH;F9&KVz)dUWJbzc)czjik z65aRa4pWrH%HcdRH3RFuzs_2<<13)~)|%sV>ENyhxmiQr(>>F9iaj8>o;#wpa=Q03 z6BOw*`RkKj%pen~mF-IPo$_?s(RljeQwasj4sD!N%b2RERW=_%n?-wM-zr>TZe{vdRxOP<)J^I2C=e_HsqdaoiT@8rz8*g#lX>8dOugUv(sY0@y)Nm5b zs25t@BI^pbxJVouK;W*HoXP!z-dA*8MkANI++OWhe0#N@!b=tTk#_H<$gjAI=eXPE zi}rbYOv7cfc&N}U;`#ck$Y#Vuo|$XnRkUvWOeliN*1O(tIMKk5hhFV6m6Z1adh|J^ zT6D0y|EG)3|6HByqThc`W=7&RNqy1tROlkEX@Sr0X}d(3BD7L9fQakwVcOcI-z10n zh+n~7LR9aD0aX0=Zr&YP?46*Wr8rFIjOhO%O<7C1RW`c*!lnE9_a4vx zW_kJhZ_+#Kh;jr*l$j;GrK9&O;E?}A_06GKv9eLE7}4(RxpUhQR|uNa4YuqWU2n5& z=|IqpmKVO83Ht}}NU!m(gbKFU%5u_M{)4TOyK#9J43S5f3e-c7Ax~WWLXKSKC>sg2 z2bN_}3Vyd%km_NXs1Cwxqz+q&PzripMJbzeDP$U6GKv(%D*Z!qR=qs87gZ&DnA@e$ zvLgcZ$5QWsvMmJNP(SnFT)7ns{)m@8)~weg@~05^tHFnyVigc~hFIEoCbtLcYLfnV z@+4Y}u=InZvg=jV5;R#l6Y-0U;Oc|l-nX*md#vK)oy>qE&X^MLM^fdfZk0EJ-o3ddPE6CSof=VAwxc z5HWz?uln>hdKhsHegniBtBwm2Ge(edIx!t?gy=_IA%a z$3(iFfrx`bUcRIH4i;E}TEjg7gyMO>E@1UKEUL+{;#uUx6-oR-$#MF+9$e>9#4EPr z??&I^2Byq1gqi){AG@d=`Fshz(emM6UZLcFc?B-FkvG-ShqqS<&pB^L9zR+PYSyl3 z4-b7Z?5`}FoecbQJyc6snvXN{6~+ptCiRAD@1JMb+F2ybUA_t?6P;1Q0MHEgd2=w{#{dtd00z~#;yk9o- zPlWLAasNM#CrlaCHDTcKdgscEKT(U2zN)u9TSqRcEhU;1d=3ie;vCTLcp$)`t@Cg0 z*@x8NN0mLysuboQ1sVSyH({Q2Zb67z3&ryWL`e_P(%Bi^(~oER6Xr?ALG1cVooyS~ z71@)$kw8u4GI=9i@ZCNXIO{vwoJPZ`kx@oaIwbrd)c14IhBS2=7dYqpq-t2{>LgBp zbMYaztNcTv<^eH`F3~gW;KNlP0(K^I%HFgM0zv08w{4`~CF=stzJ)(U37fR2o!xXW ze6OQMftRX%(Z}xWvWQjVxdtvID}x|jNsNs96KO6cXHt^~_dinbD@WxOP^>o+eZbU; z&&jBsLxhYho)n+cD0f;E37nvMoLDSc{4;};IA25F7%M8P}EIY=!ayx zrNXyjWM(T#vps5uos_<7#e4f=G`@EWu)XYHf|NKG_-kIP(zojvu8+$C*z-}5hx1zf zIjbt$p1~z52V`p~cs9?nG94aBNjhpo5+jS4U|6@K-#qeOw56p`h~t;vBl+3wP!p3V zaJh6b^UA>QY#th4l2I$Y(ttx>7uSWFD$Wx zXS1dO{l$A28*$U1faRz;B*PtAz|T>Q8ck3CU?9F2gDgMOhx)C~D0fGl!!U zh0elTIsvbV#@H1zQ&4`lnQ-R%2}YoBqqY57-~)D)ErMl;J|%75*> zr^?{z&s5`R3BcH#v7AhmWYe~r4ZNVYIdSf{7zAx%Vl&y?8k#d)S_Mp$@35YTXXAqc zoef7TaYA-W$5V~(QFGuWK;4k_dldIk&A6mR0yVO45$Vxv#eG6u7YYnc8-(nn zf41y76sc&1n(QU*82@y3=0?eA%yBszoa;1_69%T_)zv~@)P@@bU0kN5oFbPrgq2!z z9Ps`*b6VRz8K7u_jVF{w-i9yl=Nh0Gpsp{SWAoiDxPmSpYHpoz)@Mg4%c^C=BQjMY zH-CHoBwx@g%%89aBQ2NbhB_|cJ>-g@kAGgN`Z7|<0lUfa>9K2>9!HmvoE$K1PUNLNijm(;zIC|j%Bdf;aPJz}tavt~Y8H!5 z{LDgiPTL}(Sy$m|=b;9OwYGU%btO@Xyk)DXp*%w-!>eno0k>&znSyOdsO^ejk$ILo z5HDBl;KwbDoNMW9l zR>8fum`PHAqiZiP^d0f16zBoR+$(~r!CI3mQ;XGS-pfUu9@8Y zC=Dm|qAWdjmr^FcV}og|P78yYpx9S5SK7=PSL}!lotjTwKmDxZ+o?Uh?~xh4-(IfS z?R++4rby5&aXx$;tGXUhq*tv> literal 0 HcmV?d00001 diff --git a/src/app/app/globals.css b/src/app/app/globals.css new file mode 100644 index 0000000..77147d0 --- /dev/null +++ b/src/app/app/globals.css @@ -0,0 +1,118 @@ +/** + * DESIGN SYSTEM - DO NOT EDIT UNLESS NECESSARY + * + * This file contains the centralized design system for the mini app. + * These component classes establish the visual consistency across all components. + * + * âš ī¸ AI SHOULD NOT NORMALLY EDIT THIS FILE âš ī¸ + * + * Instead of modifying these classes, AI should: + * 1. Use existing component classes (e.g., .btn, .card, .input) + * 2. Use Tailwind utilities for one-off styling + * 3. Create new React components rather than new CSS classes + * 4. Only edit this file for specific bug fixes or accessibility improvements + * + * When AI needs to style something: + * ✅ Good: + * ✅ Good:
Custom
+ * ❌ Bad: Adding new CSS classes here for component-specific styling + * + * This design system is intentionally minimal to prevent bloat and maintain consistency. + */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + color: var(--foreground); + background: var(--background); + font-family: 'Inter', Helvetica, Arial, sans-serif; +} + +* { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +*::-webkit-scrollbar { + display: none; +} + +@layer base { + :root { + --radius: 0.5rem; + } +} + +@layer components { + /* Global container styles for consistent layout */ + .container { + @apply mx-auto max-w-md px-4; + } + + .container-wide { + @apply mx-auto max-w-lg px-4; + } + + .container-narrow { + @apply mx-auto max-w-sm px-4; + } + + /* Global card styles */ + .card { + @apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm; + } + + .card-primary { + @apply bg-primary/10 border-primary/20; + } + + /* Global button styles */ + .btn { + @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; + } + + .btn-primary { + @apply bg-primary text-white hover:bg-primary-dark focus:ring-primary; + } + + .btn-secondary { + @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; + } + + .btn-outline { + @apply border border-gray-300 bg-transparent hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800; + } + + /* Global input styles */ + .input { + @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; + } + + /* Global loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary; + } + + .spinner-primary { + @apply animate-spin rounded-full border-2 border-white border-t-transparent; + } + + /* Global focus styles */ + .focus-ring { + @apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2; + } +} diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx new file mode 100644 index 0000000..acf3b41 --- /dev/null +++ b/src/app/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; + +import "~/app/globals.css"; +import { Providers } from "~/app/providers"; +import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; + +export const metadata: Metadata = { + title: APP_NAME, + description: APP_DESCRIPTION, +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx new file mode 100644 index 0000000..4e11816 --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,24 @@ +import { Metadata } from "next"; +import App from "./app"; +import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants"; +import { getMiniAppEmbedMetadata } from "~/lib/utils"; + +export const revalidate = 300; + +export async function generateMetadata(): Promise { + return { + title: APP_NAME, + openGraph: { + title: APP_NAME, + description: APP_DESCRIPTION, + images: [APP_OG_IMAGE_URL], + }, + other: { + "fc:frame": JSON.stringify(getMiniAppEmbedMetadata()), + }, + }; +} + +export default function Home() { + return (); +} diff --git a/src/app/app/providers.tsx b/src/app/app/providers.tsx new file mode 100644 index 0000000..b5884a5 --- /dev/null +++ b/src/app/app/providers.tsx @@ -0,0 +1,34 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { MiniAppProvider } from '@neynar/react'; +import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; +import { ANALYTICS_ENABLED } from '~/lib/constants'; + +const WagmiProvider = dynamic( + () => import('~/components/providers/WagmiProvider'), + { + ssr: false, + } +); + +export function Providers({ + children, +}: { + children: React.ReactNode; +}) { + const solanaEndpoint = + process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; + return ( + + + + {children} + + + + ); +} diff --git a/src/app/app/share/[fid]/page.tsx b/src/app/app/share/[fid]/page.tsx new file mode 100644 index 0000000..861c3cf --- /dev/null +++ b/src/app/app/share/[fid]/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { APP_URL, APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; +import { getMiniAppEmbedMetadata } from "~/lib/utils"; +export const revalidate = 300; + +// This is an example of how to generate a dynamically generated share page based on fid: +// Sharing this route e.g. exmaple.com/share/123 will generate a share page for fid 123, +// with the image dynamically generated by the opengraph-image API route. +export async function generateMetadata({ + params, +}: { + params: Promise<{ fid: string }>; +}): Promise { + const { fid } = await params; + const imageUrl = `${APP_URL}/api/opengraph-image?fid=${fid}`; + + return { + title: `${APP_NAME} - Share`, + openGraph: { + title: APP_NAME, + description: APP_DESCRIPTION, + images: [imageUrl], + }, + other: { + "fc:frame": JSON.stringify(getMiniAppEmbedMetadata(imageUrl)), + }, + }; +} + +export default function SharePage() { + // redirect to home page + redirect("/"); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b7a8dde..acf3b41 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getSession } from "~/auth" import "~/app/globals.css"; import { Providers } from "~/app/providers"; import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; @@ -15,12 +14,10 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const session = await getSession() - return ( - {children} + {children} ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 90584eb..b5884a5 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,12 +1,9 @@ 'use client'; import dynamic from 'next/dynamic'; -import type { Session } from 'next-auth'; -import { SessionProvider } from 'next-auth/react'; import { MiniAppProvider } from '@neynar/react'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { ANALYTICS_ENABLED } from '~/lib/constants'; -import { AuthKitProvider } from '@farcaster/auth-kit'; const WagmiProvider = dynamic( () => import('~/components/providers/WagmiProvider'), @@ -16,26 +13,22 @@ const WagmiProvider = dynamic( ); export function Providers({ - session, children, }: { - session: Session | null; children: React.ReactNode; }) { const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; return ( - - - - - {children} - - - - + + + + {children} + + + ); } diff --git a/src/auth.ts b/src/auth.ts index c3345fb..5733ae5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,439 +1,10 @@ -import { AuthOptions, getServerSession } from 'next-auth'; -import CredentialsProvider from 'next-auth/providers/credentials'; -import { createAppClient, viemConnector } from '@farcaster/auth-client'; +import { sdk } from '@farcaster/miniapp-sdk'; -declare module 'next-auth' { - interface Session { - provider?: string; - user?: { - fid: number; - object?: 'user'; - username?: string; - display_name?: string; - pfp_url?: string; - custody_address?: string; - profile?: { - bio: { - text: string; - mentioned_profiles?: Array<{ - object: 'user_dehydrated'; - fid: number; - username: string; - display_name: string; - pfp_url: string; - custody_address: string; - }>; - mentioned_profiles_ranges?: Array<{ - start: number; - end: number; - }>; - }; - location?: { - latitude: number; - longitude: number; - address: { - city: string; - state: string; - country: string; - country_code: string; - }; - }; - }; - follower_count?: number; - following_count?: number; - verifications?: string[]; - verified_addresses?: { - eth_addresses: string[]; - sol_addresses: string[]; - primary: { - eth_address: string; - sol_address: string; - }; - }; - verified_accounts?: Array>; - power_badge?: boolean; - url?: string; - experimental?: { - neynar_user_score: number; - deprecation_notice: string; - }; - score?: number; - }; - signers?: { - object: 'signer'; - signer_uuid: string; - public_key: string; - status: 'approved'; - fid: number; - }[]; - } - - interface User { - provider?: string; - signers?: Array<{ - object: 'signer'; - signer_uuid: string; - public_key: string; - status: 'approved'; - fid: number; - }>; - user?: { - object: 'user'; - fid: number; - username: string; - display_name: string; - pfp_url: string; - custody_address: string; - profile: { - bio: { - text: string; - mentioned_profiles?: Array<{ - object: 'user_dehydrated'; - fid: number; - username: string; - display_name: string; - pfp_url: string; - custody_address: string; - }>; - mentioned_profiles_ranges?: Array<{ - start: number; - end: number; - }>; - }; - location?: { - latitude: number; - longitude: number; - address: { - city: string; - state: string; - country: string; - country_code: string; - }; - }; - }; - follower_count: number; - following_count: number; - verifications: string[]; - verified_addresses: { - eth_addresses: string[]; - sol_addresses: string[]; - primary: { - eth_address: string; - sol_address: string; - }; - }; - verified_accounts: Array>; - power_badge: boolean; - url?: string; - experimental?: { - neynar_user_score: number; - deprecation_notice: string; - }; - score: number; - }; - } - - interface JWT { - provider?: string; - signers?: Array<{ - object: 'signer'; - signer_uuid: string; - public_key: string; - status: 'approved'; - fid: number; - }>; - user?: { - object: 'user'; - fid: number; - username: string; - display_name: string; - pfp_url: string; - custody_address: string; - profile: { - bio: { - text: string; - mentioned_profiles?: Array<{ - object: 'user_dehydrated'; - fid: number; - username: string; - display_name: string; - pfp_url: string; - custody_address: string; - }>; - mentioned_profiles_ranges?: Array<{ - start: number; - end: number; - }>; - }; - location?: { - latitude: number; - longitude: number; - address: { - city: string; - state: string; - country: string; - country_code: string; - }; - }; - }; - follower_count: number; - following_count: number; - verifications: string[]; - verified_addresses: { - eth_addresses: string[]; - sol_addresses: string[]; - primary: { - eth_address: string; - sol_address: string; - }; - }; - verified_accounts?: Array>; - power_badge?: boolean; - url?: string; - experimental?: { - neynar_user_score: number; - deprecation_notice: string; - }; - score?: number; - }; - } -} - -function getDomainFromUrl(urlString: string | undefined): string { - if (!urlString) { - console.warn('NEXTAUTH_URL is not set, using localhost:3000 as fallback'); - return 'localhost:3000'; - } - try { - const url = new URL(urlString); - return url.hostname; - } catch (error) { - console.error('Invalid NEXTAUTH_URL:', urlString, error); - console.warn('Using localhost:3000 as fallback'); - return 'localhost:3000'; - } -} - -export const authOptions: AuthOptions = { - // Configure one or more authentication providers - providers: [ - CredentialsProvider({ - id: 'farcaster', - name: 'Sign in with Farcaster', - credentials: { - message: { - label: 'Message', - type: 'text', - placeholder: '0x0', - }, - signature: { - label: 'Signature', - type: 'text', - placeholder: '0x0', - }, - nonce: { - label: 'Nonce', - type: 'text', - placeholder: 'Custom nonce (optional)', - }, - // In a production app with a server, these should be fetched from - // your Farcaster data indexer rather than have them accepted as part - // of credentials. - // question: should these natively use the Neynar API? - name: { - label: 'Name', - type: 'text', - placeholder: '0x0', - }, - pfp: { - label: 'Pfp', - type: 'text', - placeholder: '0x0', - }, - }, - async authorize(credentials, req) { - const nonce = req?.body?.csrfToken; - - if (!nonce) { - console.error('No nonce or CSRF token provided'); - return null; - } - const appClient = createAppClient({ - ethereum: viemConnector(), - }); - - const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); - - const verifyResponse = await appClient.verifySignInMessage({ - message: credentials?.message as string, - signature: credentials?.signature as `0x${string}`, - domain, - nonce, - }); - - const { success, fid } = verifyResponse; - - if (!success) { - return null; - } - - return { - id: fid.toString(), - name: credentials?.name || `User ${fid}`, - image: credentials?.pfp || null, - provider: 'farcaster', - }; - }, - }), - CredentialsProvider({ - id: 'neynar', - name: 'Sign in with Neynar', - credentials: { - message: { - label: 'Message', - type: 'text', - placeholder: '0x0', - }, - signature: { - label: 'Signature', - type: 'text', - placeholder: '0x0', - }, - nonce: { - label: 'Nonce', - type: 'text', - placeholder: 'Custom nonce (optional)', - }, - fid: { - label: 'FID', - type: 'text', - placeholder: '0', - }, - signers: { - label: 'Signers', - type: 'text', - placeholder: 'JSON string of signers', - }, - user: { - label: 'User Data', - type: 'text', - placeholder: 'JSON string of user data', - }, - }, - async authorize(credentials) { - const nonce = credentials?.nonce; - - if (!nonce) { - console.error('No nonce or CSRF token provided for Neynar auth'); - return null; - } - - // For Neynar, we can use a different validation approach - // This could involve validating against Neynar's API or using their SDK - try { - // Validate the signature using Farcaster's auth client (same as Farcaster provider) - const appClient = createAppClient({ - ethereum: viemConnector(), - }); - - const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); - - const verifyResponse = await appClient.verifySignInMessage({ - message: credentials?.message as string, - signature: credentials?.signature as `0x${string}`, - domain, - nonce, - }); - - const { success, fid } = verifyResponse; - - if (!success) { - return null; - } - - // Validate that the provided FID matches the verified FID - if (credentials?.fid && parseInt(credentials.fid) !== fid) { - console.error('FID mismatch in Neynar auth'); - return null; - } - - return { - id: fid.toString(), - provider: 'neynar', - signers: credentials?.signers - ? JSON.parse(credentials.signers) - : undefined, - user: credentials?.user ? JSON.parse(credentials.user) : undefined, - }; - } catch (error) { - console.error('Error in Neynar auth:', error); - return null; - } - }, - }), - ], - callbacks: { - session: async ({ session, token }) => { - // Set provider at the root level - session.provider = token.provider as string; - - if (token.provider === 'farcaster') { - // For Farcaster, simple structure - session.user = { - fid: parseInt(token.sub ?? ''), - }; - } else if (token.provider === 'neynar') { - // For Neynar, use full user data structure from user - session.user = token.user as typeof session.user; - session.signers = token.signers as typeof session.signers; - } - - return session; - }, - jwt: async ({ token, user }) => { - if (user) { - token.provider = user.provider; - token.signers = user.signers; - token.user = user.user; - } - return token; - }, - }, - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: 'none', - path: '/', - secure: true, - }, - }, - callbackUrl: { - name: `next-auth.callback-url`, - options: { - sameSite: 'none', - path: '/', - secure: true, - }, - }, - csrfToken: { - name: `next-auth.csrf-token`, - options: { - httpOnly: true, - sameSite: 'none', - path: '/', - secure: true, - }, - }, - }, -}; +// Export QuickAuth from the SDK +export const quickAuth = sdk.quickAuth; +// Helper function to get session (for server-side compatibility) export const getSession = async () => { - try { - return await getServerSession(authOptions); - } catch (error) { - console.error('Error getting server session:', error); - return null; - } + // For QuickAuth, sessions are managed by the SDK + return null; }; diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx index bcf1ca7..be3c688 100644 --- a/src/components/ui/NeynarAuthButton/ProfileButton.tsx +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -16,8 +16,8 @@ export function ProfileButton({ useDetectClickOutside(ref, () => setShowDropdown(false)); - const name = userData?.username ?? `!${userData?.fid}`; - const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + const name = userData?.username && userData.username.trim() !== '' ? userData.username : `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl && userData.pfpUrl.trim() !== '' ? userData.pfpUrl : 'https://farcaster.xyz/avatar.png'; return (
diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx index 8d96711..fd1f8c3 100644 --- a/src/components/ui/NeynarAuthButton/index.tsx +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -1,20 +1,13 @@ 'use client'; -import '@farcaster/auth-kit/styles.css'; -import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; import { useCallback, useEffect, useState, useRef } from 'react'; import { cn } from '~/lib/utils'; import { Button } from '~/components/ui/Button'; import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; -import { getItem, removeItem, setItem } from '~/lib/localStorage'; import { useMiniApp } from '@neynar/react'; -import { - signIn as backendSignIn, - signOut as backendSignOut, - useSession, -} from 'next-auth/react'; import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; +import { useQuickAuth } from '~/hooks/useQuickAuth'; type User = { fid: number; @@ -24,7 +17,6 @@ type User = { // Add other user properties as needed }; -const STORAGE_KEY = 'neynar_authenticated_user'; const FARCASTER_FID = 9152; interface StoredAuthState { @@ -98,7 +90,8 @@ export function NeynarAuthButton() { const [storedAuth, setStoredAuth] = useState(null); const [signersLoading, setSignersLoading] = useState(false); const { context } = useMiniApp(); - const { data: session } = useSession(); + const { authenticatedUser: quickAuthUser, signIn: quickAuthSignIn, signOut: quickAuthSignOut } = useQuickAuth(); + // New state for unified dialog flow const [showDialog, setShowDialog] = useState(false); const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( @@ -114,6 +107,7 @@ export function NeynarAuthButton() { const [signature, setSignature] = useState(null); const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); const signerFlowStartedRef = useRef(false); + const [backendUserProfile, setBackendUserProfile] = useState<{ username?: string; pfpUrl?: string }>({}); // Determine which flow to use based on context const useBackendFlow = context !== undefined; @@ -146,25 +140,15 @@ export function NeynarAuthButton() { if (!useBackendFlow) return; try { - // For backend flow, we need to sign in again with the additional data - if (message && signature) { - const signInData = { - message, - signature, - redirect: false, - nonce: nonce || '', - fid: user?.fid?.toString() || '', - signers: JSON.stringify(signers), - user: JSON.stringify(user), - }; - - await backendSignIn('neynar', signInData); + // For backend flow, use QuickAuth to sign in + if (signers && signers.length > 0) { + await quickAuthSignIn(); } } catch (error) { console.error('❌ Error updating session with signers:', error); } }, - [useBackendFlow, message, signature, nonce] + [useBackendFlow, quickAuthSignIn] ); // Helper function to fetch user data from Neynar API @@ -245,15 +229,17 @@ export function NeynarAuthButton() { if (response.ok) { if (useBackendFlow) { // For backend flow, update session with signers - if (signerData.signers && signerData.signers.length > 0) { - const user = - signerData.user || - (await fetchUserData(signerData.signers[0].fid)); + if (signerData.signers && signerData.signers.length > 0) { + // Get user data for the first signer + let user: StoredAuthState['user'] | null = null; + if (signerData.signers[0].fid) { + user = await fetchUserData(signerData.signers[0].fid) as StoredAuthState['user']; + } await updateSessionWithSigners(signerData.signers, user); } return signerData.signers; } else { - // For frontend flow, store in localStorage + // For frontend flow, store in memory only let user: StoredAuthState['user'] | null = null; if (signerData.signers && signerData.signers.length > 0) { @@ -263,13 +249,12 @@ export function NeynarAuthButton() { user = fetchedUser; } - // Store signers in localStorage, preserving existing auth data + // Store signers in memory only const updatedState: StoredAuthState = { isAuthenticated: !!user, signers: signerData.signers || [], user, }; - setItem(STORAGE_KEY, updatedState); setStoredAuth(updatedState); return signerData.signers; @@ -384,78 +369,105 @@ export function NeynarAuthButton() { generateNonce(); }, []); - // Load stored auth state on mount (only for frontend flow) - useEffect(() => { - if (!useBackendFlow) { - const stored = getItem(STORAGE_KEY); - if (stored && stored.isAuthenticated) { - setStoredAuth(stored); + // Backend flow using QuickAuth + const handleBackendSignIn = useCallback(async () => { + if (!nonce) { + console.error('❌ No nonce available for backend sign-in'); + return; + } + + try { + setSignersLoading(true); + const result = await sdk.actions.signIn({ nonce }); + + setMessage(result.message); + setSignature(result.signature); + // Use QuickAuth to sign in + const signInResult = await quickAuthSignIn(); + // Fetch user profile after sign in + if (quickAuthUser?.fid) { + const user = await fetchUserData(quickAuthUser.fid); + setBackendUserProfile({ + username: user?.username || '', + pfpUrl: user?.pfp_url || '', + }); + } + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('â„šī¸ Sign-in rejected by user'); + } else { + console.error('❌ Backend sign-in error:', e); } } - }, [useBackendFlow]); - - // Success callback - this is critical! - const onSuccessCallback = useCallback( - async (res: UseSignInData) => { - if (!useBackendFlow) { - // Only handle localStorage for frontend flow - const existingAuth = getItem(STORAGE_KEY); - const user = res.fid ? await fetchUserData(res.fid) : null; - const authState: StoredAuthState = { - ...existingAuth, - isAuthenticated: true, - user: user as StoredAuthState['user'], - signers: existingAuth?.signers || [], // Preserve existing signers - }; - setItem(STORAGE_KEY, authState); - setStoredAuth(authState); - } - // For backend flow, the session will be handled by NextAuth - }, - [useBackendFlow, fetchUserData] - ); - - // Error callback - const onErrorCallback = useCallback((error?: Error | null) => { - console.error('❌ Sign in error:', error); - }, []); - - const signInState = useSignIn({ - nonce: nonce || undefined, - onSuccess: onSuccessCallback, - onError: onErrorCallback, - }); - - const { - signIn: frontendSignIn, - signOut: frontendSignOut, - connect, - reconnect, - isSuccess, - isError, - error, - channelToken, - url, - data, - validSignature, - } = signInState; + }, [nonce, quickAuthSignIn, quickAuthUser, fetchUserData]); + // Fetch user profile when quickAuthUser.fid changes (for backend flow) useEffect(() => { - setMessage(data?.message || null); - setSignature(data?.signature || null); - - // Reset the signer flow flag when message/signature change - if (data?.message && data?.signature) { + if (useBackendFlow && quickAuthUser?.fid) { + (async () => { + const user = await fetchUserData(quickAuthUser.fid); + setBackendUserProfile({ + username: user?.username || '', + pfpUrl: user?.pfp_url || '', + }); + })(); + } + }, [useBackendFlow, quickAuthUser?.fid, fetchUserData]); + + const handleFrontEndSignIn = useCallback(async () => { + try { + setSignersLoading(true); + const result = await sdk.actions.signIn({ nonce: nonce || '' }); + + setMessage(result.message); + setSignature(result.signature); + + // For frontend flow, we'll handle the signer flow in the useEffect + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('â„šī¸ Sign-in rejected by user'); + } else { + console.error('❌ Frontend sign-in error:', e); + } + } finally { + setSignersLoading(false); + } + }, [nonce]); + + const handleSignOut = useCallback(async () => { + try { + setSignersLoading(true); + + if (useBackendFlow) { + // Use QuickAuth sign out + await quickAuthSignOut(); + } else { + // Frontend flow sign out + setStoredAuth(null); + } + + // Common cleanup for both flows + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + setMessage(null); + setSignature(null); + + // Reset polling interval + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + + // Reset signer flow flag signerFlowStartedRef.current = false; + } catch (error) { + console.error('❌ Error during sign out:', error); + // Optionally handle error state + } finally { + setSignersLoading(false); } - }, [data?.message, data?.signature]); - - // Connect for frontend flow when nonce is available - useEffect(() => { - if (!useBackendFlow && nonce && !channelToken) { - connect(); - } - }, [useBackendFlow, nonce, channelToken, connect]); + }, [useBackendFlow, pollingInterval, quickAuthSignOut]); // Handle fetching signers after successful authentication useEffect(() => { @@ -533,103 +545,15 @@ export function NeynarAuthButton() { } }, [message, signature]); // Simplified dependencies - // Backend flow using NextAuth - const handleBackendSignIn = useCallback(async () => { - if (!nonce) { - console.error('❌ No nonce available for backend sign-in'); - return; - } - - try { - setSignersLoading(true); - const result = await sdk.actions.signIn({ nonce }); - - const signInData = { - message: result.message, - signature: result.signature, - redirect: false, - nonce: nonce, - }; - - const nextAuthResult = await backendSignIn('neynar', signInData); - if (nextAuthResult?.ok) { - setMessage(result.message); - setSignature(result.signature); - } else { - console.error('❌ NextAuth sign-in failed:', nextAuthResult); - } - } catch (e) { - if (e instanceof SignInCore.RejectedByUser) { - console.log('â„šī¸ Sign-in rejected by user'); - } else { - console.error('❌ Backend sign-in error:', e); - } - } - }, [nonce]); - - const handleFrontEndSignIn = useCallback(() => { - if (isError) { - reconnect(); - } - setDialogStep('signin'); - setShowDialog(true); - frontendSignIn(); - }, [isError, reconnect, frontendSignIn]); - - const handleSignOut = useCallback(async () => { - try { - setSignersLoading(true); - - if (useBackendFlow) { - // Only sign out from NextAuth if the current session is from Neynar provider - if (session?.provider === 'neynar') { - await backendSignOut({ redirect: false }); - } - } else { - // Frontend flow sign out - frontendSignOut(); - removeItem(STORAGE_KEY); - setStoredAuth(null); - } - - // Common cleanup for both flows - setShowDialog(false); - setDialogStep('signin'); - setSignerApprovalUrl(null); - setMessage(null); - setSignature(null); - - // Reset polling interval - if (pollingInterval) { - clearInterval(pollingInterval); - setPollingInterval(null); - } - - // Reset signer flow flag - signerFlowStartedRef.current = false; - } catch (error) { - console.error('❌ Error during sign out:', error); - // Optionally handle error state - } finally { - setSignersLoading(false); - } - }, [useBackendFlow, frontendSignOut, pollingInterval, session]); - const authenticated = useBackendFlow - ? !!( - session?.provider === 'neynar' && - session?.user?.fid && - session?.signers && - session.signers.length > 0 - ) - : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && - !!(storedAuth?.signers && storedAuth.signers.length > 0); + ? !!quickAuthUser?.fid + : storedAuth?.isAuthenticated && !!(storedAuth?.signers && storedAuth.signers.length > 0); const userData = useBackendFlow ? { - fid: session?.user?.fid, - username: session?.user?.username || '', - pfpUrl: session?.user?.pfp_url || '', + fid: quickAuthUser?.fid, + username: backendUserProfile.username ?? '', + pfpUrl: backendUserProfile.pfpUrl ?? '', } : { fid: storedAuth?.user?.fid, @@ -658,18 +582,17 @@ export function NeynarAuthButton() { ) : ( )} - {status === 'authenticated' && session?.provider === 'farcaster' && ( + {status === 'authenticated' && ( )} {/* Session Information */} - {session && ( + {authenticatedUser && (
-
Session
+
Authenticated User
- {JSON.stringify(session, null, 2)} + {JSON.stringify(authenticatedUser, null, 2)}
)} @@ -142,20 +115,10 @@ export function SignIn() { {/* Error Display */} {signInFailure && !authState.signingIn && (
-
SIWF Result
+
Authentication Error
{signInFailure}
)} - - {/* Success Result Display */} - {signInResult && !authState.signingIn && ( -
-
SIWF Result
-
- {JSON.stringify(signInResult, null, 2)} -
-
- )} ); } diff --git a/src/hooks/useQuickAuth.ts b/src/hooks/useQuickAuth.ts new file mode 100644 index 0000000..f14cac5 --- /dev/null +++ b/src/hooks/useQuickAuth.ts @@ -0,0 +1,204 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { sdk } from '@farcaster/miniapp-sdk'; + +/** + * Represents the current authenticated user state + */ +interface AuthenticatedUser { + /** The user's Farcaster ID (FID) */ + fid: number; +} + +/** + * Possible authentication states for QuickAuth + */ +type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; + +/** + * Return type for the useQuickAuth hook + */ +interface UseQuickAuthReturn { + /** Current authenticated user data, or null if not authenticated */ + authenticatedUser: AuthenticatedUser | null; + /** Current authentication status */ + status: QuickAuthStatus; + /** Function to initiate the sign-in process using QuickAuth */ + signIn: () => Promise; + /** Function to sign out and clear the current authentication state */ + signOut: () => Promise; + /** Function to retrieve the current authentication token */ + getToken: () => Promise; +} + +/** + * Custom hook for managing QuickAuth authentication state + * + * This hook provides a complete authentication flow using Farcaster's QuickAuth: + * - Automatically checks for existing authentication on mount + * - Validates tokens with the server-side API + * - Manages authentication state in memory (no persistence) + * - Provides sign-in/sign-out functionality + * + * QuickAuth tokens are managed in memory only, so signing out of the Farcaster + * client will automatically sign the user out of this mini app as well. + * + * @returns {UseQuickAuthReturn} Object containing user state and authentication methods + * + * @example + * ```tsx + * const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); + * + * if (status === 'loading') return
Loading...
; + * if (status === 'unauthenticated') return ; + * + * return ( + *
+ *

Welcome, FID: {authenticatedUser?.fid}

+ * + *
+ * ); + * ``` + */ +export function useQuickAuth(): UseQuickAuthReturn { + // Current authenticated user data + const [authenticatedUser, setAuthenticatedUser] = useState(null); + // Current authentication status + const [status, setStatus] = useState('loading'); + + /** + * Validates a QuickAuth token with the server-side API + * + * @param {string} authToken - The JWT token to validate + * @returns {Promise} User data if valid, null otherwise + */ + const validateTokenWithServer = async (authToken: string): Promise => { + try { + const validationResponse = await fetch('/api/auth/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: authToken }), + }); + + if (validationResponse.ok) { + const responseData = await validationResponse.json(); + return responseData.user; + } + + return null; + } catch (error) { + console.error('Token validation failed:', error); + return null; + } + }; + + /** + * Checks for existing authentication token and validates it on component mount + * This runs automatically when the hook is first used + */ + useEffect(() => { + const checkExistingAuthentication = async () => { + try { + // Attempt to retrieve existing token from QuickAuth SDK + const { token } = await sdk.quickAuth.getToken(); + + if (token) { + // Validate the token with our server-side API + const validatedUserSession = await validateTokenWithServer(token); + + if (validatedUserSession) { + // Token is valid, set authenticated state + setAuthenticatedUser(validatedUserSession); + setStatus('authenticated'); + } else { + // Token is invalid or expired, clear authentication state + setStatus('unauthenticated'); + } + } else { + // No existing token found, user is not authenticated + setStatus('unauthenticated'); + } + } catch (error) { + console.error('Error checking existing authentication:', error); + setStatus('unauthenticated'); + } + }; + + checkExistingAuthentication(); + }, []); + + /** + * Initiates the QuickAuth sign-in process + * + * Uses sdk.quickAuth.getToken() to get a QuickAuth session token. + * If there is already a session token in memory that hasn't expired, + * it will be immediately returned, otherwise a fresh one will be acquired. + * + * @returns {Promise} True if sign-in was successful, false otherwise + */ + const signIn = useCallback(async (): Promise => { + try { + setStatus('loading'); + + // Get QuickAuth session token + const { token } = await sdk.quickAuth.getToken(); + + if (token) { + // Validate the token with our server-side API + const validatedUserSession = await validateTokenWithServer(token); + + if (validatedUserSession) { + // Authentication successful, update user state + setAuthenticatedUser(validatedUserSession); + setStatus('authenticated'); + return true; + } + } + + // Authentication failed, clear user state + setStatus('unauthenticated'); + return false; + } catch (error) { + console.error('Sign-in process failed:', error); + setStatus('unauthenticated'); + return false; + } + }, []); + + /** + * Signs out the current user and clears the authentication state + * + * Since QuickAuth tokens are managed in memory only, this simply clears + * the local user state. The actual token will be cleared when the + * user signs out of their Farcaster client. + */ + const signOut = useCallback(async (): Promise => { + // Clear local user state + setAuthenticatedUser(null); + setStatus('unauthenticated'); + }, []); + + /** + * Retrieves the current authentication token from QuickAuth + * + * @returns {Promise} The current auth token, or null if not authenticated + */ + const getToken = useCallback(async (): Promise => { + try { + const { token } = await sdk.quickAuth.getToken(); + return token; + } catch (error) { + console.error('Failed to retrieve authentication token:', error); + return null; + } + }, []); + + return { + authenticatedUser, + status, + signIn, + signOut, + getToken, + }; +} \ No newline at end of file From b9e2087bd8cd9e8ed7a5862936609b5bf29aa911 Mon Sep 17 00:00:00 2001 From: veganbeef Date: Mon, 14 Jul 2025 13:12:47 -0700 Subject: [PATCH 2/2] fix: remove duplicate app directory --- package.json | 2 +- .../app/.well-known/farcaster.json/route.ts | 12 -- src/app/app/api/auth/nonce/route.ts | 16 --- src/app/app/api/auth/session-signers/route.ts | 43 ------- src/app/app/api/auth/signer/route.ts | 42 ------- .../app/api/auth/signer/signed_key/route.ts | 91 -------------- src/app/app/api/auth/signers/route.ts | 38 ------ src/app/app/api/auth/validate/route.ts | 52 -------- src/app/app/api/best-friends/route.ts | 46 ------- src/app/app/api/opengraph-image/route.tsx | 30 ----- src/app/app/api/send-notification/route.ts | 57 --------- src/app/app/api/users/route.ts | 39 ------ src/app/app/api/webhook/route.ts | 91 -------------- src/app/app/app.tsx | 15 --- src/app/app/favicon.ico | Bin 5887 -> 0 bytes src/app/app/globals.css | 118 ------------------ src/app/app/layout.tsx | 24 ---- src/app/app/page.tsx | 24 ---- src/app/app/providers.tsx | 34 ----- src/app/app/share/[fid]/page.tsx | 34 ----- 20 files changed, 1 insertion(+), 807 deletions(-) delete mode 100644 src/app/app/.well-known/farcaster.json/route.ts delete mode 100644 src/app/app/api/auth/nonce/route.ts delete mode 100644 src/app/app/api/auth/session-signers/route.ts delete mode 100644 src/app/app/api/auth/signer/route.ts delete mode 100644 src/app/app/api/auth/signer/signed_key/route.ts delete mode 100644 src/app/app/api/auth/signers/route.ts delete mode 100644 src/app/app/api/auth/validate/route.ts delete mode 100644 src/app/app/api/best-friends/route.ts delete mode 100644 src/app/app/api/opengraph-image/route.tsx delete mode 100644 src/app/app/api/send-notification/route.ts delete mode 100644 src/app/app/api/users/route.ts delete mode 100644 src/app/app/api/webhook/route.ts delete mode 100644 src/app/app/app.tsx delete mode 100644 src/app/app/favicon.ico delete mode 100644 src/app/app/globals.css delete mode 100644 src/app/app/layout.tsx delete mode 100644 src/app/app/page.tsx delete mode 100644 src/app/app/providers.tsx delete mode 100644 src/app/app/share/[fid]/page.tsx diff --git a/package.json b/package.json index 972720d..6e3ffdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.7.0", + "version": "1.7.1", "type": "module", "private": false, "access": "public", diff --git a/src/app/app/.well-known/farcaster.json/route.ts b/src/app/app/.well-known/farcaster.json/route.ts deleted file mode 100644 index d116c4f..0000000 --- a/src/app/app/.well-known/farcaster.json/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getFarcasterDomainManifest } from '~/lib/utils'; - -export async function GET() { - try { - const config = await getFarcasterDomainManifest(); - return NextResponse.json(config); - } catch (error) { - console.error('Error generating metadata:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} diff --git a/src/app/app/api/auth/nonce/route.ts b/src/app/app/api/auth/nonce/route.ts deleted file mode 100644 index a1f25ea..0000000 --- a/src/app/app/api/auth/nonce/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getNeynarClient } from '~/lib/neynar'; - -export async function GET() { - try { - const client = getNeynarClient(); - const response = await client.fetchNonce(); - return NextResponse.json(response); - } catch (error) { - console.error('Error fetching nonce:', error); - return NextResponse.json( - { error: 'Failed to fetch nonce' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/auth/session-signers/route.ts b/src/app/app/api/auth/session-signers/route.ts deleted file mode 100644 index 630ef3b..0000000 --- a/src/app/app/api/auth/session-signers/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getNeynarClient } from '~/lib/neynar'; - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const message = searchParams.get('message'); - const signature = searchParams.get('signature'); - - if (!message || !signature) { - return NextResponse.json( - { error: 'Message and signature are required' }, - { status: 400 } - ); - } - - const client = getNeynarClient(); - const data = await client.fetchSigners({ message, signature }); - const signers = data.signers; - - // Fetch user data if signers exist - let user = null; - if (signers && signers.length > 0 && signers[0].fid) { - const { - users: [fetchedUser], - } = await client.fetchBulkUsers({ - fids: [signers[0].fid], - }); - user = fetchedUser; - } - - return NextResponse.json({ - signers, - user, - }); - } catch (error) { - console.error('Error in session-signers API:', error); - return NextResponse.json( - { error: 'Failed to fetch signers' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/auth/signer/route.ts b/src/app/app/api/auth/signer/route.ts deleted file mode 100644 index f793d0e..0000000 --- a/src/app/app/api/auth/signer/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getNeynarClient } from '~/lib/neynar'; - -export async function POST() { - try { - const neynarClient = getNeynarClient(); - const signer = await neynarClient.createSigner(); - return NextResponse.json(signer); - } catch (error) { - console.error('Error fetching signer:', error); - return NextResponse.json( - { error: 'Failed to fetch signer' }, - { status: 500 } - ); - } -} - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const signerUuid = searchParams.get('signerUuid'); - - if (!signerUuid) { - return NextResponse.json( - { error: 'signerUuid is required' }, - { status: 400 } - ); - } - - try { - const neynarClient = getNeynarClient(); - const signer = await neynarClient.lookupSigner({ - signerUuid, - }); - return NextResponse.json(signer); - } catch (error) { - console.error('Error fetching signed key:', error); - return NextResponse.json( - { error: 'Failed to fetch signed key' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/auth/signer/signed_key/route.ts b/src/app/app/api/auth/signer/signed_key/route.ts deleted file mode 100644 index d7a3df8..0000000 --- a/src/app/app/api/auth/signer/signed_key/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getNeynarClient } from '~/lib/neynar'; -import { mnemonicToAccount } from 'viem/accounts'; -import { - SIGNED_KEY_REQUEST_TYPE, - SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, -} from '~/lib/constants'; - -const postRequiredFields = ['signerUuid', 'publicKey']; - -export async function POST(request: Request) { - const body = await request.json(); - - // Validate required fields - for (const field of postRequiredFields) { - if (!body[field]) { - return NextResponse.json( - { error: `${field} is required` }, - { status: 400 } - ); - } - } - - const { signerUuid, publicKey, redirectUrl } = body; - - if (redirectUrl && typeof redirectUrl !== 'string') { - return NextResponse.json( - { error: 'redirectUrl must be a string' }, - { status: 400 } - ); - } - - try { - // Get the app's account from seed phrase - const seedPhrase = process.env.SEED_PHRASE; - const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; - - if (!seedPhrase) { - return NextResponse.json( - { error: 'App configuration missing (SEED_PHRASE or FID)' }, - { status: 500 } - ); - } - - const neynarClient = getNeynarClient(); - - const account = mnemonicToAccount(seedPhrase); - - const { - user: { fid }, - } = await neynarClient.lookupUserByCustodyAddress({ - custodyAddress: account.address, - }); - - const appFid = fid; - - // Generate deadline (24 hours from now) - const deadline = Math.floor(Date.now() / 1000) + 86400; - - // Generate EIP-712 signature - const signature = await account.signTypedData({ - domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, - types: { - SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, - }, - primaryType: 'SignedKeyRequest', - message: { - requestFid: BigInt(appFid), - key: publicKey, - deadline: BigInt(deadline), - }, - }); - - const signer = await neynarClient.registerSignedKey({ - appFid, - deadline, - signature, - signerUuid, - ...(redirectUrl && { redirectUrl }), - ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), - }); - - return NextResponse.json(signer); - } catch (error) { - console.error('Error registering signed key:', error); - return NextResponse.json( - { error: 'Failed to register signed key' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/auth/signers/route.ts b/src/app/app/api/auth/signers/route.ts deleted file mode 100644 index 1c89acf..0000000 --- a/src/app/app/api/auth/signers/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getNeynarClient } from '~/lib/neynar'; - -const requiredParams = ['message', 'signature']; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const params: Record = {}; - for (const param of requiredParams) { - params[param] = searchParams.get(param); - if (!params[param]) { - return NextResponse.json( - { - error: `${param} parameter is required`, - }, - { status: 400 } - ); - } - } - - const message = params.message as string; - const signature = params.signature as string; - - try { - const client = getNeynarClient(); - const data = await client.fetchSigners({ message, signature }); - const signers = data.signers; - return NextResponse.json({ - signers, - }); - } catch (error) { - console.error('Error fetching signers:', error); - return NextResponse.json( - { error: 'Failed to fetch signers' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/auth/validate/route.ts b/src/app/app/api/auth/validate/route.ts deleted file mode 100644 index 70256f8..0000000 --- a/src/app/app/api/auth/validate/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createClient, Errors } from '@farcaster/quick-auth'; - -const client = createClient(); - -export async function POST(request: Request) { - try { - const { token } = await request.json(); - - if (!token) { - return NextResponse.json( - { error: 'Token is required' }, - { status: 400 } - ); - } - - // Get domain from environment or request - const domain = process.env.NEXT_PUBLIC_URL - ? new URL(process.env.NEXT_PUBLIC_URL).hostname - : request.headers.get('host') || 'localhost'; - - try { - // Use the official QuickAuth library to verify the JWT - const payload = await client.verifyJwt({ - token, - domain, - }); - - return NextResponse.json({ - success: true, - user: { - fid: payload.sub, - }, - }); - } catch (e) { - if (e instanceof Errors.InvalidTokenError) { - console.info('Invalid token:', e.message); - return NextResponse.json( - { error: 'Invalid token' }, - { status: 401 } - ); - } - throw e; - } - } catch (error) { - console.error('Token validation error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/app/api/best-friends/route.ts b/src/app/app/api/best-friends/route.ts deleted file mode 100644 index 925724f..0000000 --- a/src/app/app/api/best-friends/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextResponse } from 'next/server'; - -export async function GET(request: Request) { - const apiKey = process.env.NEYNAR_API_KEY; - const { searchParams } = new URL(request.url); - const fid = searchParams.get('fid'); - - if (!apiKey) { - return NextResponse.json( - { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, - { status: 500 } - ); - } - - if (!fid) { - return NextResponse.json( - { error: 'FID parameter is required' }, - { status: 400 } - ); - } - - try { - const response = await fetch( - `https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`, - { - headers: { - "x-api-key": apiKey, - }, - } - ); - - if (!response.ok) { - throw new Error(`Neynar API error: ${response.statusText}`); - } - - const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] }; - - return NextResponse.json({ bestFriends: users }); - } catch (error) { - console.error('Failed to fetch best friends:', error); - return NextResponse.json( - { error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/app/api/opengraph-image/route.tsx b/src/app/app/api/opengraph-image/route.tsx deleted file mode 100644 index b14415f..0000000 --- a/src/app/app/api/opengraph-image/route.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ImageResponse } from "next/og"; -import { NextRequest } from "next/server"; -import { getNeynarUser } from "~/lib/neynar"; - -export const dynamic = 'force-dynamic'; - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const fid = searchParams.get('fid'); - - const user = fid ? await getNeynarUser(Number(fid)) : null; - - return new ImageResponse( - ( -
- {user?.pfp_url && ( -
- Profile -
- )} -

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

-

Powered by Neynar đŸĒ

-
- ), - { - width: 1200, - height: 800, - } - ); -} \ No newline at end of file diff --git a/src/app/app/api/send-notification/route.ts b/src/app/app/api/send-notification/route.ts deleted file mode 100644 index 7563723..0000000 --- a/src/app/app/api/send-notification/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { notificationDetailsSchema } from "@farcaster/miniapp-sdk"; -import { NextRequest } from "next/server"; -import { z } from "zod"; -import { setUserNotificationDetails } from "~/lib/kv"; -import { sendMiniAppNotification } from "~/lib/notifs"; -import { sendNeynarMiniAppNotification } from "~/lib/neynar"; - -const requestSchema = z.object({ - fid: z.number(), - notificationDetails: notificationDetailsSchema, -}); - -export async function POST(request: NextRequest) { - // If Neynar is enabled, we don't need to store notification details - // as they will be managed by Neynar's system - const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; - - const requestJson = await request.json(); - const requestBody = requestSchema.safeParse(requestJson); - - if (requestBody.success === false) { - return Response.json( - { success: false, errors: requestBody.error.errors }, - { status: 400 } - ); - } - - // Only store notification details if not using Neynar - if (!neynarEnabled) { - await setUserNotificationDetails( - Number(requestBody.data.fid), - requestBody.data.notificationDetails - ); - } - - // Use appropriate notification function based on Neynar status - const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification; - const sendResult = await sendNotification({ - fid: Number(requestBody.data.fid), - title: "Test notification", - body: "Sent at " + new Date().toISOString(), - }); - - if (sendResult.state === "error") { - return Response.json( - { success: false, error: sendResult.error }, - { status: 500 } - ); - } else if (sendResult.state === "rate_limit") { - return Response.json( - { success: false, error: "Rate limited" }, - { status: 429 } - ); - } - - return Response.json({ success: true }); -} diff --git a/src/app/app/api/users/route.ts b/src/app/app/api/users/route.ts deleted file mode 100644 index cca1f37..0000000 --- a/src/app/app/api/users/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NeynarAPIClient } from '@neynar/nodejs-sdk'; -import { NextResponse } from 'next/server'; - -export async function GET(request: Request) { - const apiKey = process.env.NEYNAR_API_KEY; - const { searchParams } = new URL(request.url); - const fids = searchParams.get('fids'); - - if (!apiKey) { - return NextResponse.json( - { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' }, - { status: 500 } - ); - } - - if (!fids) { - return NextResponse.json( - { error: 'FIDs parameter is required' }, - { status: 400 } - ); - } - - try { - const neynar = new NeynarAPIClient({ apiKey }); - const fidsArray = fids.split(',').map(fid => parseInt(fid.trim())); - - const { users } = await neynar.fetchBulkUsers({ - fids: fidsArray, - }); - - return NextResponse.json({ users }); - } catch (error) { - console.error('Failed to fetch users:', error); - return NextResponse.json( - { error: 'Failed to fetch users. Please check your Neynar API key and try again.' }, - { status: 500 } - ); - } -} diff --git a/src/app/app/api/webhook/route.ts b/src/app/app/api/webhook/route.ts deleted file mode 100644 index cc49676..0000000 --- a/src/app/app/api/webhook/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - ParseWebhookEvent, - parseWebhookEvent, - verifyAppKeyWithNeynar, -} from "@farcaster/miniapp-node"; -import { NextRequest } from "next/server"; -import { APP_NAME } from "~/lib/constants"; -import { - deleteUserNotificationDetails, - setUserNotificationDetails, -} from "~/lib/kv"; -import { sendMiniAppNotification } from "~/lib/notifs"; - -export async function POST(request: NextRequest) { - // If Neynar is enabled, we don't need to handle webhooks here - // as they will be handled by Neynar's webhook endpoint - const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID; - if (neynarEnabled) { - return Response.json({ success: true }); - } - - const requestJson = await request.json(); - - let data; - try { - data = await parseWebhookEvent(requestJson, verifyAppKeyWithNeynar); - } catch (e: unknown) { - const error = e as ParseWebhookEvent.ErrorType; - - switch (error.name) { - case "VerifyJsonFarcasterSignature.InvalidDataError": - case "VerifyJsonFarcasterSignature.InvalidEventDataError": - // The request data is invalid - return Response.json( - { success: false, error: error.message }, - { status: 400 } - ); - case "VerifyJsonFarcasterSignature.InvalidAppKeyError": - // The app key is invalid - return Response.json( - { success: false, error: error.message }, - { status: 401 } - ); - case "VerifyJsonFarcasterSignature.VerifyAppKeyError": - // Internal error verifying the app key (caller may want to try again) - return Response.json( - { success: false, error: error.message }, - { status: 500 } - ); - } - } - - const fid = data.fid; - const event = data.event; - - // Only handle notifications if Neynar is not enabled - // When Neynar is enabled, notifications are handled through their webhook - switch (event.event) { - case "frame_added": - if (event.notificationDetails) { - await setUserNotificationDetails(fid, event.notificationDetails); - await sendMiniAppNotification({ - fid, - title: `Welcome to ${APP_NAME}`, - body: "Mini app is now added to your client", - }); - } else { - await deleteUserNotificationDetails(fid); - } - break; - - case "frame_removed": - await deleteUserNotificationDetails(fid); - break; - - case "notifications_enabled": - await setUserNotificationDetails(fid, event.notificationDetails); - await sendMiniAppNotification({ - fid, - title: `Welcome to ${APP_NAME}`, - body: "Notifications are now enabled", - }); - break; - - case "notifications_disabled": - await deleteUserNotificationDetails(fid); - break; - } - - return Response.json({ success: true }); -} diff --git a/src/app/app/app.tsx b/src/app/app/app.tsx deleted file mode 100644 index c9d7d23..0000000 --- a/src/app/app/app.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { APP_NAME } from "~/lib/constants"; - -// note: dynamic import is required for components that use the Frame SDK -const AppComponent = dynamic(() => import("~/components/App"), { - ssr: false, -}); - -export default function App( - { title }: { title?: string } = { title: APP_NAME } -) { - return ; -} diff --git a/src/app/app/favicon.ico b/src/app/app/favicon.ico deleted file mode 100644 index afa21c9b9d12e1fc608444a36d35e773ee977e07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5887 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 diff --git a/src/app/app/globals.css b/src/app/app/globals.css deleted file mode 100644 index 77147d0..0000000 --- a/src/app/app/globals.css +++ /dev/null @@ -1,118 +0,0 @@ -/** - * DESIGN SYSTEM - DO NOT EDIT UNLESS NECESSARY - * - * This file contains the centralized design system for the mini app. - * These component classes establish the visual consistency across all components. - * - * âš ī¸ AI SHOULD NOT NORMALLY EDIT THIS FILE âš ī¸ - * - * Instead of modifying these classes, AI should: - * 1. Use existing component classes (e.g., .btn, .card, .input) - * 2. Use Tailwind utilities for one-off styling - * 3. Create new React components rather than new CSS classes - * 4. Only edit this file for specific bug fixes or accessibility improvements - * - * When AI needs to style something: - * ✅ Good: - * ✅ Good:
Custom
- * ❌ Bad: Adding new CSS classes here for component-specific styling - * - * This design system is intentionally minimal to prevent bloat and maintain consistency. - */ - -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - color: var(--foreground); - background: var(--background); - font-family: 'Inter', Helvetica, Arial, sans-serif; -} - -* { - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ -} - -*::-webkit-scrollbar { - display: none; -} - -@layer base { - :root { - --radius: 0.5rem; - } -} - -@layer components { - /* Global container styles for consistent layout */ - .container { - @apply mx-auto max-w-md px-4; - } - - .container-wide { - @apply mx-auto max-w-lg px-4; - } - - .container-narrow { - @apply mx-auto max-w-sm px-4; - } - - /* Global card styles */ - .card { - @apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm; - } - - .card-primary { - @apply bg-primary/10 border-primary/20; - } - - /* Global button styles */ - .btn { - @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; - } - - .btn-primary { - @apply bg-primary text-white hover:bg-primary-dark focus:ring-primary; - } - - .btn-secondary { - @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; - } - - .btn-outline { - @apply border border-gray-300 bg-transparent hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800; - } - - /* Global input styles */ - .input { - @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; - } - - /* Global loading spinner */ - .spinner { - @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary; - } - - .spinner-primary { - @apply animate-spin rounded-full border-2 border-white border-t-transparent; - } - - /* Global focus styles */ - .focus-ring { - @apply focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2; - } -} diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx deleted file mode 100644 index acf3b41..0000000 --- a/src/app/app/layout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Metadata } from "next"; - -import "~/app/globals.css"; -import { Providers } from "~/app/providers"; -import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; - -export const metadata: Metadata = { - title: APP_NAME, - description: APP_DESCRIPTION, -}; - -export default async function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx deleted file mode 100644 index 4e11816..0000000 --- a/src/app/app/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Metadata } from "next"; -import App from "./app"; -import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants"; -import { getMiniAppEmbedMetadata } from "~/lib/utils"; - -export const revalidate = 300; - -export async function generateMetadata(): Promise { - return { - title: APP_NAME, - openGraph: { - title: APP_NAME, - description: APP_DESCRIPTION, - images: [APP_OG_IMAGE_URL], - }, - other: { - "fc:frame": JSON.stringify(getMiniAppEmbedMetadata()), - }, - }; -} - -export default function Home() { - return (); -} diff --git a/src/app/app/providers.tsx b/src/app/app/providers.tsx deleted file mode 100644 index b5884a5..0000000 --- a/src/app/app/providers.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; - -import dynamic from 'next/dynamic'; -import { MiniAppProvider } from '@neynar/react'; -import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; -import { ANALYTICS_ENABLED } from '~/lib/constants'; - -const WagmiProvider = dynamic( - () => import('~/components/providers/WagmiProvider'), - { - ssr: false, - } -); - -export function Providers({ - children, -}: { - children: React.ReactNode; -}) { - const solanaEndpoint = - process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; - return ( - - - - {children} - - - - ); -} diff --git a/src/app/app/share/[fid]/page.tsx b/src/app/app/share/[fid]/page.tsx deleted file mode 100644 index 861c3cf..0000000 --- a/src/app/app/share/[fid]/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { APP_URL, APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; -import { getMiniAppEmbedMetadata } from "~/lib/utils"; -export const revalidate = 300; - -// This is an example of how to generate a dynamically generated share page based on fid: -// Sharing this route e.g. exmaple.com/share/123 will generate a share page for fid 123, -// with the image dynamically generated by the opengraph-image API route. -export async function generateMetadata({ - params, -}: { - params: Promise<{ fid: string }>; -}): Promise { - const { fid } = await params; - const imageUrl = `${APP_URL}/api/opengraph-image?fid=${fid}`; - - return { - title: `${APP_NAME} - Share`, - openGraph: { - title: APP_NAME, - description: APP_DESCRIPTION, - images: [imageUrl], - }, - other: { - "fc:frame": JSON.stringify(getMiniAppEmbedMetadata(imageUrl)), - }, - }; -} - -export default function SharePage() { - // redirect to home page - redirect("/"); -}