diff --git a/bin/init.js b/bin/init.js index 6f1b11a..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([ { @@ -328,7 +346,7 @@ export async function init() { "@farcaster/frame-node": ">=0.0.18 <1.0.0", "@farcaster/frame-sdk": ">=0.0.31 <1.0.0", "@farcaster/frame-wagmi-connector": ">=0.0.19 <1.0.0", - "@farcaster/mini-app-solana": "^0.0.5", + "@farcaster/mini-app-solana": ">=0.0.17 <1.0.0", "@neynar/react": "^1.2.2", "@radix-ui/react-label": "^2.1.1", "@solana/wallet-adapter-react": "^0.15.38", @@ -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/package.json b/package.json index 968e0dd..146f279 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.3.1", + "version": "1.3.2", "type": "module", "private": false, "access": "public", diff --git a/src/app/api/best-friends/route.ts b/src/app/api/best-friends/route.ts new file mode 100644 index 0000000..3a17115 --- /dev/null +++ b/src/app/api/best-friends/route.ts @@ -0,0 +1,54 @@ +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 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 neynar = new NeynarAPIClient({ apiKey }); + // 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, + username: user.user?.username, + })); + + return NextResponse.json({ bestFriends }); + } 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/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 4c46d81..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,16 +46,14 @@ export default function Demo( context, added, notificationDetails, - lastEvent, - addFrame, - addFrameResult, - openUrl, - close, + 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(); @@ -70,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, @@ -198,123 +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.addFrame
+            
+
+                sdk.actions.addMiniApp
               
- {addFrameResult && ( -
- Add frame result: {addFrameResult} -
- )} - -
- {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:{" "} @@ -412,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/components/ui/Share.tsx b/src/components/ui/Share.tsx new file mode 100644 index 0000000..625c166 --- /dev/null +++ b/src/components/ui/Share.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useCallback, useState, useEffect } from 'react'; +import { Button } from './Button'; +import { useMiniApp } from '@neynar/react'; +import { type ComposeCast } from "@farcaster/frame-sdk"; + +interface CastConfig extends ComposeCast.Options { + bestFriends?: boolean; +} + +interface ShareButtonProps { + buttonText: string; + cast: CastConfig; + className?: string; + isLoading?: boolean; +} + +export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) { + const [isProcessing, setIsProcessing] = useState(false); + const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null); + const { context, actions } = useMiniApp(); + + // Fetch best friends if needed + useEffect(() => { + if (cast.bestFriends && context?.user?.fid) { + setIsProcessing(true); + fetch(`/api/best-friends?fid=${context.user.fid}`) + .then(res => res.json()) + .then(data => setBestFriends(data.bestFriends)) + .catch(err => console.error('Failed to fetch best friends:', err)) + .finally(() => setIsProcessing(false)); + } + }, [cast.bestFriends, context?.user?.fid]); + + const handleShare = useCallback(async () => { + try { + setIsProcessing(true); + + let finalText = cast.text || ''; + + // Process best friends if enabled and data is loaded + if (cast.bestFriends && bestFriends) { + // Replace @N with usernames + finalText = finalText.replace(/@\d+/g, (match) => { + const friendIndex = parseInt(match.slice(1)) - 1; + const friend = bestFriends[friendIndex]; + if (friend) { + return `@${friend.username}`; + } + return match; + }); + } + + // Process embeds + const processedEmbeds = await Promise.all( + (cast.embeds || []).map(async (embed) => { + if (typeof embed === 'string') { + return embed; + } + if (embed.path) { + const baseUrl = process.env.NEXT_PUBLIC_URL || window.location.origin; + const url = new URL(`${baseUrl}${embed.path}`); + + // Add UTM parameters + url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`); + + // If custom image generator is provided, use it + if (embed.imageUrl) { + const imageUrl = await embed.imageUrl(); + url.searchParams.set('share_image_url', imageUrl); + } + + return url.toString(); + } + return embed.url || ''; + }) + ); + + // Open cast composer with all supported intents + await actions.composeCast({ + text: finalText, + embeds: processedEmbeds as [string] | [string, string] | undefined, + parent: cast.parent, + channel: cast.channelKey, + close: cast.close, + }, 'share-button'); + } catch (error) { + console.error('Failed to share:', error); + } finally { + setIsProcessing(false); + } + }, [cast, bestFriends, context?.user?.fid, actions]); + + const isButtonDisabled = cast.bestFriends && !bestFriends; + + return ( + + ); +} 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';