From 7263cdef0e46a8ed91eba86a381f90a59daaba28 Mon Sep 17 00:00:00 2001 From: veganbeef Date: Fri, 13 Jun 2025 14:13:59 -0700 Subject: [PATCH] feat: clean up UI --- bin/init.js | 19 +++ src/app/api/best-friends/route.ts | 20 ++- src/app/api/users/route.ts | 39 ++++++ src/components/Demo.tsx | 225 +++++++++++++----------------- src/components/ui/Footer.tsx | 53 +++++++ src/components/ui/Header.tsx | 82 +++++++++++ src/lib/constants.ts | 1 + 7 files changed, 308 insertions(+), 131 deletions(-) create mode 100644 src/app/api/users/route.ts create mode 100644 src/components/ui/Footer.tsx create mode 100644 src/components/ui/Header.tsx diff --git a/bin/init.js b/bin/init.js index 0f80586..34d862e 100644 --- a/bin/init.js +++ b/bin/init.js @@ -239,6 +239,24 @@ export async function init() { } ]); + // Ask about wallet and transaction tooling + const walletAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'useWallet', + message: 'Would you like to include wallet and transaction tooling in your mini app?\n' + + 'This includes:\n' + + '- EVM wallet connection\n' + + '- Transaction signing\n' + + '- Message signing\n' + + '- Chain switching\n' + + '- Solana support\n\n' + + 'Include wallet and transaction features?', + default: true + } + ]); + answers.useWallet = walletAnswer.useWallet; + // Ask about localhost vs tunnel const hostingAnswer = await inquirer.prompt([ { @@ -388,6 +406,7 @@ export async function init() { fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_TAGS="${answers.tags.join(',')}"`); fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_BUTTON_TEXT="${answers.buttonText}"`); fs.appendFileSync(envPath, `\nNEXT_PUBLIC_ANALYTICS_ENABLED="${answers.enableAnalytics}"`); + fs.appendFileSync(envPath, `\nNEXT_PUBLIC_USE_WALLET="${answers.useWallet}"`); fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`); if (useNeynar && neynarApiKey && neynarClientId) { diff --git a/src/app/api/best-friends/route.ts b/src/app/api/best-friends/route.ts index b223684..3a17115 100644 --- a/src/app/api/best-friends/route.ts +++ b/src/app/api/best-friends/route.ts @@ -22,11 +22,21 @@ export async function GET(request: Request) { try { const neynar = new NeynarAPIClient({ apiKey }); - const { users } = await neynar.fetchUserFollowers({ - fid: parseInt(fid), - limit: 3, - viewerFid: parseInt(fid), - }); + // TODO: update to use best friends endpoint once SDK merged in + 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 } }[] }; const bestFriends = users.map(user => ({ fid: user.user?.fid, diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..cca1f37 --- /dev/null +++ b/src/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/components/Demo.tsx b/src/components/Demo.tsx index 5b1d0e6..4b4a8e6 100644 --- a/src/components/Demo.tsx +++ b/src/components/Demo.tsx @@ -32,6 +32,11 @@ import { useSession } from "next-auth/react"; import { useMiniApp } from "@neynar/react"; import { Label } from "~/components/ui/label"; import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; +import { Header } from "~/components/ui/Header"; +import { Footer } from "~/components/ui/Footer"; +import { USE_WALLET } from "~/lib/constants"; + +export type Tab = 'home' | 'actions' | 'context' | 'wallet'; export default function Demo( { title }: { title?: string } = { title: "Frames v2 Demo" } @@ -41,13 +46,14 @@ export default function Demo( context, added, notificationDetails, - lastEvent, actions, } = useMiniApp(); const [isContextOpen, setIsContextOpen] = useState(false); + const [activeTab, setActiveTab] = useState('home'); const [txHash, setTxHash] = useState(null); const [sendNotificationResult, setSendNotificationResult] = useState(""); const [copied, setCopied] = useState(false); + const [neynarUser, setNeynarUser] = useState(null); const { address, isConnected } = useAccount(); const chainId = useChainId(); @@ -67,6 +73,25 @@ export default function Demo( console.log("chainId", chainId); }, [context, address, isConnected, chainId, isSDKLoaded]); + // Fetch Neynar user object when context is available + useEffect(() => { + const fetchNeynarUserObject = async () => { + if (context?.user?.fid) { + try { + const response = await fetch(`/api/users?fids=${context.user.fid}`); + const data = await response.json(); + if (data.users?.[0]) { + setNeynarUser(data.users[0]); + } + } catch (error) { + console.error('Failed to fetch Neynar user object:', error); + } + } + }; + + fetchNeynarUserObject(); + }, [context?.user?.fid]); + const { sendTransaction, error: sendTxError, @@ -195,118 +220,76 @@ export default function Demo( paddingRight: context?.client.safeAreaInsets?.right ?? 0, }} > -
+
+
+

{title}

-
-

Context

- - - {isContextOpen && ( -
-
-                {JSON.stringify(context, null, 2)}
-              
+ {activeTab === 'home' && ( +
+
+

Put your content here!

+

Powered by Neynar 🪐

- )} -
+
+ )} -
-

Actions

- -
-
-
+        {activeTab === 'actions' && (
+          
+
+
                 sdk.actions.signIn
               
-
-
-
-
+            
+
                 sdk.actions.openUrl
               
- -
+ -
-
-
+            
+
                 sdk.actions.viewProfile
               
-
-
-
-
+            
+
                 sdk.actions.close
               
- -
-
+ -
-

Last event

+
+ Client fid {context?.client.clientFid}, + {added ? " frame added to client," : " frame not added to client,"} + {notificationDetails + ? " notifications enabled" + : " notifications disabled"} +
-
-
-              {lastEvent || "none"}
-            
-
-
- -
-

Add to client & notifications

- -
- Client fid {context?.client.clientFid}, - {added ? " frame added to client," : " frame not added to client,"} - {notificationDetails - ? " notifications enabled" - : " notifications disabled"} -
- -
-
-
+            
+
                 sdk.actions.addMiniApp
               
- -
- {sendNotificationResult && ( -
- Send notification result: {sendNotificationResult} -
- )} -
- -
-
-
+ )} -
-

Wallet

- - {address && ( -
- Address:
{truncateAddress(address)}
+ {activeTab === 'context' && ( +
+

Context

+
+
+                {JSON.stringify(context, null, 2)}
+              
- )} +
+ )} - {chainId && ( -
- Chain ID:
{chainId}
-
- )} + {activeTab === 'wallet' && USE_WALLET && ( +
+ {address && ( +
+ Address:
{truncateAddress(address)}
+
+ )} + + {chainId && ( +
+ Chain ID:
{chainId}
+
+ )} -
{isConnected ? ( ) : context ? ( - /* if context is not null, mini app is running in frame client */ ) : ( - /* if context is null, mini app is running in browser */ -
+
)} -
-
-
- {isConnected && ( - <> -
+ {isConnected && ( + <> -
-
{isSendTxError && renderError(sendTxError)} {txHash && ( -
+
Hash: {truncateAddress(txHash)}
Status:{" "} @@ -404,43 +390,30 @@ export default function Demo(
)} -
-
{isSignTypedError && renderError(signTypedError)} -
-
{isSwitchChainError && renderError(switchChainError)} -
- - )} -
- - {solanaAddress && ( -
-

Solana

-
- Address:
{truncateAddress(solanaAddress)}
-
- -
- -
+ + )}
)} + +
); diff --git a/src/components/ui/Footer.tsx b/src/components/ui/Footer.tsx new file mode 100644 index 0000000..3847d00 --- /dev/null +++ b/src/components/ui/Footer.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import type { Tab } from "~/components/Demo"; + +interface FooterProps { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; + showWallet?: boolean; +} + +export const Footer: React.FC = ({ activeTab, setActiveTab, showWallet = false }) => ( +
+
+ + + + {showWallet && ( + + )} +
+
+); diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx new file mode 100644 index 0000000..0feded8 --- /dev/null +++ b/src/components/ui/Header.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { APP_NAME } from "~/lib/constants"; +import sdk from "@farcaster/frame-sdk"; +import { useMiniApp } from "@neynar/react"; + +type HeaderProps = { + neynarUser: any; +}; + +export function Header({ neynarUser }: HeaderProps) { + const { context } = useMiniApp(); + const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); + const [hasClickedPfp, setHasClickedPfp] = useState(false); + + return ( +
+
+
+ Welcome to {APP_NAME}! +
+ {context?.user && ( +
{ + setIsUserDropdownOpen(!isUserDropdownOpen); + setHasClickedPfp(true); + }} + > + {context.user.pfpUrl && ( + Profile + )} +
+ )} +
+ {context?.user && ( + <> + {!hasClickedPfp && ( +
+ Click PFP! +
+ )} + + {isUserDropdownOpen && ( +
+
+
+

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

+

+ @{context.user.username} +

+

+ FID: {context.user.fid} +

+ {neynarUser && ( + <> +

+ Neynar Score: {neynarUser.score} +

+ + )} +
+
+
+ )} + + )} +
+ ); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fb7324a..b603f63 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -11,3 +11,4 @@ export const APP_BUTTON_TEXT = process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT; export const APP_WEBHOOK_URL = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` : `${APP_URL}/api/webhook`; +export const USE_WALLET = process.env.NEXT_PUBLIC_USE_WALLET === 'true';