From bef42eddd47ea8d5e408781ae691766e8272e08d Mon Sep 17 00:00:00 2001 From: veganbeef Date: Tue, 1 Jul 2025 09:38:59 -0700 Subject: [PATCH 1/3] refactor: restructure for better ai comprehension --- .env.example | 5 + src/app/api/opengraph-image/route.tsx | 2 +- src/app/app.tsx | 4 +- src/app/globals.css | 82 ++ src/components/App.tsx | 123 +++ src/components/Demo.tsx | 759 ------------------ src/components/ui/Button.tsx | 39 +- src/components/ui/Footer.tsx | 20 +- src/components/ui/Header.tsx | 16 +- src/components/ui/tabs/ActionsTab.tsx | 182 +++++ src/components/ui/tabs/ContextTab.tsx | 35 + src/components/ui/tabs/HomeTab.tsx | 24 + src/components/ui/tabs/WalletTab.tsx | 360 +++++++++ src/components/ui/tabs/index.ts | 4 + src/components/ui/wallet/SendEth.tsx | 102 +++ src/components/ui/wallet/SendSolana.tsx | 111 +++ src/components/ui/wallet/SignEvmMessage.tsx | 81 ++ src/components/ui/wallet/SignIn.tsx | 158 ++++ .../ui/wallet/SignSolanaMessage.tsx | 87 ++ src/components/ui/wallet/index.ts | 5 + src/hooks/useNeynarUser.ts | 38 + src/lib/constants.ts | 90 ++- src/lib/errorUtils.tsx | 66 ++ tailwind.config.ts | 36 + 24 files changed, 1636 insertions(+), 793 deletions(-) create mode 100644 src/components/App.tsx delete mode 100644 src/components/Demo.tsx create mode 100644 src/components/ui/tabs/ActionsTab.tsx create mode 100644 src/components/ui/tabs/ContextTab.tsx create mode 100644 src/components/ui/tabs/HomeTab.tsx create mode 100644 src/components/ui/tabs/WalletTab.tsx create mode 100644 src/components/ui/tabs/index.ts create mode 100644 src/components/ui/wallet/SendEth.tsx create mode 100644 src/components/ui/wallet/SendSolana.tsx create mode 100644 src/components/ui/wallet/SignEvmMessage.tsx create mode 100644 src/components/ui/wallet/SignIn.tsx create mode 100644 src/components/ui/wallet/SignSolanaMessage.tsx create mode 100644 src/components/ui/wallet/index.ts create mode 100644 src/hooks/useNeynarUser.ts create mode 100644 src/lib/errorUtils.tsx diff --git a/.env.example b/.env.example index 9b0f870..07d7425 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,8 @@ KV_REST_API_TOKEN='' KV_REST_API_URL='' NEXT_PUBLIC_URL='http://localhost:3000' NEXTAUTH_URL='http://localhost:3000' + +NEXTAUTH_SECRET="" +NEYNAR_API_KEY="" +NEYNAR_CLIENT_ID="" +USE_TUNNEL="false" diff --git a/src/app/api/opengraph-image/route.tsx b/src/app/api/opengraph-image/route.tsx index 87d59a6..b14415f 100644 --- a/src/app/api/opengraph-image/route.tsx +++ b/src/app/api/opengraph-image/route.tsx @@ -12,7 +12,7 @@ export async function GET(request: NextRequest) { return new ImageResponse( ( -
+
{user?.pfp_url && (
Profile diff --git a/src/app/app.tsx b/src/app/app.tsx index e58eeaa..c9d7d23 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -4,12 +4,12 @@ import dynamic from "next/dynamic"; import { APP_NAME } from "~/lib/constants"; // note: dynamic import is required for components that use the Frame SDK -const Demo = dynamic(() => import("~/components/Demo"), { +const AppComponent = dynamic(() => import("~/components/App"), { ssr: false, }); export default function App( { title }: { title?: string } = { title: APP_NAME } ) { - return ; + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index ccb716b..77147d0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,25 @@ +/** + * 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; @@ -34,3 +56,63 @@ body { --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/components/App.tsx b/src/components/App.tsx new file mode 100644 index 0000000..fa88b86 --- /dev/null +++ b/src/components/App.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect } from "react"; +import { useMiniApp } from "@neynar/react"; +import { Header } from "~/components/ui/Header"; +import { Footer } from "~/components/ui/Footer"; +import { HomeTab, ActionsTab, ContextTab, WalletTab } from "~/components/ui/tabs"; +import { USE_WALLET } from "~/lib/constants"; +import { useNeynarUser } from "../hooks/useNeynarUser"; + +// --- Types --- +export enum Tab { + Home = "home", + Actions = "actions", + Context = "context", + Wallet = "wallet", +} + +export interface AppProps { + title?: string; +} + +/** + * App component serves as the main container for the mini app interface. + * + * This component orchestrates the overall mini app experience by: + * - Managing tab navigation and state + * - Handling Farcaster mini app initialization + * - Coordinating wallet and context state + * - Providing error handling and loading states + * - Rendering the appropriate tab content based on user selection + * + * The component integrates with the Neynar SDK for Farcaster functionality + * and Wagmi for wallet management. It provides a complete mini app + * experience with multiple tabs for different functionality areas. + * + * Features: + * - Tab-based navigation (Home, Actions, Context, Wallet) + * - Farcaster mini app integration + * - Wallet connection management + * - Error handling and display + * - Loading states for async operations + * + * @param props - Component props + * @param props.title - Optional title for the mini app (defaults to "Neynar Starter Kit") + * + * @example + * ```tsx + * + * ``` + */ +export default function App( + { title }: AppProps = { title: "Neynar Starter Kit" } +) { + // --- Hooks --- + const { + isSDKLoaded, + context, + setInitialTab, + setActiveTab, + currentTab, + } = useMiniApp(); + + // --- Neynar user hook --- + const { user: neynarUser } = useNeynarUser(context || undefined); + + // --- Effects --- + /** + * Sets the initial tab to "home" when the SDK is loaded. + * + * This effect ensures that users start on the home tab when they first + * load the mini app. It only runs when the SDK is fully loaded to + * prevent errors during initialization. + */ + useEffect(() => { + if (isSDKLoaded) { + setInitialTab(Tab.Home); + } + }, [isSDKLoaded, setInitialTab]); + + // --- Early Returns --- + if (!isSDKLoaded) { + return ( +
+
+
+

Loading SDK...

+
+
+ ); + } + + // --- Render --- + return ( +
+ {/* Header should be full width */} +
+ + {/* Main content and footer should be centered */} +
+ {/* Main title */} +

{title}

+ + {/* Tab content rendering */} + {currentTab === Tab.Home && } + {currentTab === Tab.Actions && } + {currentTab === Tab.Context && } + {currentTab === Tab.Wallet && } + + {/* Footer with navigation */} +
+
+
+ ); +} + diff --git a/src/components/Demo.tsx b/src/components/Demo.tsx deleted file mode 100644 index 16216e7..0000000 --- a/src/components/Demo.tsx +++ /dev/null @@ -1,759 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { signIn, signOut, getCsrfToken } from "next-auth/react"; -import sdk, { - SignIn as SignInCore, - type Haptics, -} from "@farcaster/frame-sdk"; -import { - useAccount, - useSendTransaction, - useSignMessage, - useSignTypedData, - useWaitForTransactionReceipt, - useDisconnect, - useConnect, - useSwitchChain, - useChainId, -} from "wagmi"; -import { - useConnection as useSolanaConnection, - useWallet as useSolanaWallet, -} from '@solana/wallet-adapter-react'; -import { useHasSolanaProvider } from "./providers/SafeFarcasterSolanaProvider"; -import { ShareButton } from "./ui/Share"; - -import { config } from "~/components/providers/WagmiProvider"; -import { Button } from "~/components/ui/Button"; -import { truncateAddress } from "~/lib/truncateAddress"; -import { base, degen, mainnet, optimism, unichain } from "wagmi/chains"; -import { BaseError, UserRejectedRequestError } from "viem"; -import { useSession } from "next-auth/react"; -import { useMiniApp } from "@neynar/react"; -import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; -import { Header } from "~/components/ui/Header"; -import { Footer } from "~/components/ui/Footer"; -import { USE_WALLET, APP_NAME } from "~/lib/constants"; - -export type Tab = 'home' | 'actions' | 'context' | 'wallet'; - -interface NeynarUser { - fid: number; - score: number; -} - -export default function Demo( - { title }: { title?: string } = { title: "Neynar Starter Kit" } -) { - const { - isSDKLoaded, - context, - added, - notificationDetails, - actions, - setInitialTab, - setActiveTab, - currentTab, - haptics, - } = useMiniApp(); - const [isContextOpen, setIsContextOpen] = useState(false); - const [txHash, setTxHash] = useState(null); - const [sendNotificationResult, setSendNotificationResult] = useState(""); - const [copied, setCopied] = useState(false); - const [neynarUser, setNeynarUser] = useState(null); - const [hapticIntensity, setHapticIntensity] = useState('medium'); - - const { address, isConnected } = useAccount(); - const chainId = useChainId(); - const hasSolanaProvider = useHasSolanaProvider(); - const solanaWallet = useSolanaWallet(); - const { publicKey: solanaPublicKey } = solanaWallet; - - // Set initial tab to home on page load - useEffect(() => { - if (isSDKLoaded) { - setInitialTab('home'); - } - }, [isSDKLoaded, setInitialTab]); - - useEffect(() => { - console.log("isSDKLoaded", isSDKLoaded); - console.log("context", context); - console.log("address", address); - console.log("isConnected", isConnected); - 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, - isError: isSendTxError, - isPending: isSendTxPending, - } = useSendTransaction(); - - const { isLoading: isConfirming, isSuccess: isConfirmed } = - useWaitForTransactionReceipt({ - hash: txHash as `0x${string}`, - }); - - const { - signTypedData, - error: signTypedError, - isError: isSignTypedError, - isPending: isSignTypedPending, - } = useSignTypedData(); - - const { disconnect } = useDisconnect(); - const { connect, connectors } = useConnect(); - - const { - switchChain, - error: switchChainError, - isError: isSwitchChainError, - isPending: isSwitchChainPending, - } = useSwitchChain(); - - const nextChain = useMemo(() => { - if (chainId === base.id) { - return optimism; - } else if (chainId === optimism.id) { - return degen; - } else if (chainId === degen.id) { - return mainnet; - } else if (chainId === mainnet.id) { - return unichain; - } else { - return base; - } - }, [chainId]); - - const handleSwitchChain = useCallback(() => { - switchChain({ chainId: nextChain.id }); - }, [switchChain, nextChain.id]); - - const sendNotification = useCallback(async () => { - setSendNotificationResult(""); - if (!notificationDetails || !context) { - return; - } - - try { - const response = await fetch("/api/send-notification", { - method: "POST", - mode: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - fid: context.user.fid, - notificationDetails, - }), - }); - - if (response.status === 200) { - setSendNotificationResult("Success"); - return; - } else if (response.status === 429) { - setSendNotificationResult("Rate limited"); - return; - } - - const data = await response.text(); - setSendNotificationResult(`Error: ${data}`); - } catch (error) { - setSendNotificationResult(`Error: ${error}`); - } - }, [context, notificationDetails]); - - const sendTx = useCallback(() => { - sendTransaction( - { - // call yoink() on Yoink contract - to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878", - data: "0x9846cd9efc000023c0", - }, - { - onSuccess: (hash) => { - setTxHash(hash); - }, - } - ); - }, [sendTransaction]); - - const signTyped = useCallback(() => { - signTypedData({ - domain: { - name: APP_NAME, - version: "1", - chainId, - }, - types: { - Message: [{ name: "content", type: "string" }], - }, - message: { - content: `Hello from ${APP_NAME}!`, - }, - primaryType: "Message", - }); - }, [chainId, signTypedData]); - - const toggleContext = useCallback(() => { - setIsContextOpen((prev) => !prev); - }, []); - - if (!isSDKLoaded) { - return
Loading...
; - } - - return ( -
-
-
- -

{title}

- - {currentTab === 'home' && ( -
-
-

Put your content here!

-

Powered by Neynar 🪐

-
-
- )} - - {currentTab === 'actions' && ( -
- - - - - - - - - {sendNotificationResult && ( -
- Send notification result: {sendNotificationResult} -
- )} - - - - -
- - - -
-
- )} - - {currentTab === 'context' && ( -
-

Context

-
-
-                {JSON.stringify(context, null, 2)}
-              
-
-
- )} - - {currentTab === 'wallet' && USE_WALLET && ( -
- {address && ( -
- Address:
{truncateAddress(address)}
-
- )} - - {chainId && ( -
- Chain ID:
{chainId}
-
- )} - - {isConnected ? ( - - ) : context ? ( - - ) : ( -
- - -
- )} - - - - {isConnected && ( - <> - - - {isSendTxError && renderError(sendTxError)} - {txHash && ( -
-
Hash: {truncateAddress(txHash)}
-
- Status:{" "} - {isConfirming - ? "Confirming..." - : isConfirmed - ? "Confirmed!" - : "Pending"} -
-
- )} - - {isSignTypedError && renderError(signTypedError)} - - {isSwitchChainError && renderError(switchChainError)} - - )} -
- )} - -
-
-
- ); -} - -// Solana functions inspired by farcaster demo -// https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx -function SignSolanaMessage({ signMessage }: { signMessage?: (message: Uint8Array) => Promise }) { - const [signature, setSignature] = useState(); - const [signError, setSignError] = useState(); - const [signPending, setSignPending] = useState(false); - - const handleSignMessage = useCallback(async () => { - setSignPending(true); - try { - if (!signMessage) { - throw new Error('no Solana signMessage'); - } - const input = new TextEncoder().encode("Hello from Solana!"); - const signatureBytes = await signMessage(input); - const signature = btoa(String.fromCharCode(...signatureBytes)); - setSignature(signature); - setSignError(undefined); - } catch (e) { - if (e instanceof Error) { - setSignError(e); - } - } finally { - setSignPending(false); - } - }, [signMessage]); - - return ( - <> - - {signError && renderError(signError)} - {signature && ( -
-
Signature: {signature}
-
- )} - - ); -} - -function SendSolana() { - const [state, setState] = useState< - | { status: 'none' } - | { status: 'pending' } - | { status: 'error'; error: Error } - | { status: 'success'; signature: string } - >({ status: 'none' }); - - const { connection: solanaConnection } = useSolanaConnection(); - const { sendTransaction, publicKey } = useSolanaWallet(); - - // This should be replaced but including it from the original demo - // https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx#L718 - const ashoatsPhantomSolanaWallet = 'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1'; - - const handleSend = useCallback(async () => { - setState({ status: 'pending' }); - try { - if (!publicKey) { - throw new Error('no Solana publicKey'); - } - - const { blockhash } = await solanaConnection.getLatestBlockhash(); - if (!blockhash) { - throw new Error('failed to fetch latest Solana blockhash'); - } - - const fromPubkeyStr = publicKey.toBase58(); - const toPubkeyStr = ashoatsPhantomSolanaWallet; - const transaction = new Transaction(); - transaction.add( - SystemProgram.transfer({ - fromPubkey: new PublicKey(fromPubkeyStr), - toPubkey: new PublicKey(toPubkeyStr), - lamports: 0n, - }), - ); - transaction.recentBlockhash = blockhash; - transaction.feePayer = new PublicKey(fromPubkeyStr); - - const simulation = await solanaConnection.simulateTransaction(transaction); - if (simulation.value.err) { - // Gather logs and error details for debugging - const logs = simulation.value.logs?.join('\n') ?? 'No logs'; - const errDetail = JSON.stringify(simulation.value.err); - throw new Error(`Simulation failed: ${errDetail}\nLogs:\n${logs}`); - } - const signature = await sendTransaction(transaction, solanaConnection); - setState({ status: 'success', signature }); - } catch (e) { - if (e instanceof Error) { - setState({ status: 'error', error: e }); - } else { - setState({ status: 'none' }); - } - } - }, [sendTransaction, publicKey, solanaConnection]); - - return ( - <> - - {state.status === 'error' && renderError(state.error)} - {state.status === 'success' && ( -
-
Hash: {truncateAddress(state.signature)}
-
- )} - - ); -} - -function SignEvmMessage() { - const { isConnected } = useAccount(); - const { connectAsync } = useConnect(); - const { - signMessage, - data: signature, - error: signError, - isError: isSignError, - isPending: isSignPending, - } = useSignMessage(); - - const handleSignMessage = useCallback(async () => { - if (!isConnected) { - await connectAsync({ - chainId: base.id, - connector: config.connectors[0], - }); - } - - signMessage({ message: `Hello from ${APP_NAME}!` }); - }, [connectAsync, isConnected, signMessage]); - - return ( - <> - - {isSignError && renderError(signError)} - {signature && ( -
-
Signature: {signature}
-
- )} - - ); -} - -function SendEth() { - const { isConnected, chainId } = useAccount(); - const { - sendTransaction, - data, - error: sendTxError, - isError: isSendTxError, - isPending: isSendTxPending, - } = useSendTransaction(); - - const { isLoading: isConfirming, isSuccess: isConfirmed } = - useWaitForTransactionReceipt({ - hash: data, - }); - - const toAddr = useMemo(() => { - // Protocol guild address - return chainId === base.id - ? "0x32e3C7fD24e175701A35c224f2238d18439C7dBC" - : "0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830"; - }, [chainId]); - - const handleSend = useCallback(() => { - sendTransaction({ - to: toAddr, - value: 1n, - }); - }, [toAddr, sendTransaction]); - - return ( - <> - - {isSendTxError && renderError(sendTxError)} - {data && ( -
-
Hash: {truncateAddress(data)}
-
- Status:{" "} - {isConfirming - ? "Confirming..." - : isConfirmed - ? "Confirmed!" - : "Pending"} -
-
- )} - - ); -} - -function SignIn() { - const [signingIn, setSigningIn] = useState(false); - const [signingOut, setSigningOut] = useState(false); - const [signInResult, setSignInResult] = useState(); - const [signInFailure, setSignInFailure] = useState(); - const { data: session, status } = useSession(); - - const getNonce = useCallback(async () => { - const nonce = await getCsrfToken(); - if (!nonce) throw new Error("Unable to generate nonce"); - return nonce; - }, []); - - const handleSignIn = useCallback(async () => { - try { - setSigningIn(true); - setSignInFailure(undefined); - const nonce = await getNonce(); - const result = await sdk.actions.signIn({ nonce }); - setSignInResult(result); - - await signIn("credentials", { - message: result.message, - signature: result.signature, - redirect: false, - }); - } catch (e) { - if (e instanceof SignInCore.RejectedByUser) { - setSignInFailure("Rejected by user"); - return; - } - - setSignInFailure("Unknown error"); - } finally { - setSigningIn(false); - } - }, [getNonce]); - - const handleSignOut = useCallback(async () => { - try { - setSigningOut(true); - await signOut({ redirect: false }); - setSignInResult(undefined); - } finally { - setSigningOut(false); - } - }, []); - - return ( - <> - {status !== "authenticated" && ( - - )} - {status === "authenticated" && ( - - )} - {session && ( -
-
Session
-
- {JSON.stringify(session, null, 2)} -
-
- )} - {signInFailure && !signingIn && ( -
-
SIWF Result
-
{signInFailure}
-
- )} - {signInResult && !signingIn && ( -
-
SIWF Result
-
- {JSON.stringify(signInResult, null, 2)} -
-
- )} - - ); -} - -const renderError = (error: Error | null) => { - if (!error) return null; - if (error instanceof BaseError) { - const isUserRejection = error.walk( - (e) => e instanceof UserRejectedRequestError - ); - - if (isUserRejection) { - return
Rejected by user.
; - } - } - - return
{error.message}
; -}; - diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 51d1553..8f2782d 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,17 +1,50 @@ interface ButtonProps extends React.ButtonHTMLAttributes { children: React.ReactNode; isLoading?: boolean; + variant?: 'primary' | 'secondary' | 'outline'; + size?: 'sm' | 'md' | 'lg'; } -export function Button({ children, className = "", isLoading = false, ...props }: ButtonProps) { +export function Button({ + children, + className = "", + isLoading = false, + variant = 'primary', + size = 'md', + ...props +}: ButtonProps) { + const baseClasses = "btn"; + + const variantClasses = { + primary: "btn-primary", + secondary: "btn-secondary", + outline: "btn-outline" + }; + + const sizeClasses = { + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base" + }; + + const fullWidthClasses = "w-full max-w-xs mx-auto block"; + + const combinedClasses = [ + baseClasses, + variantClasses[variant], + sizeClasses[size], + fullWidthClasses, + className + ].join(' '); + return ( {showWallet && ( + + + + {/* Notification functionality */} + {notificationState.sendStatus && ( +
+ Send notification result: {notificationState.sendStatus} +
+ )} + + + {/* Share URL copying */} + + + {/* Haptic feedback controls */} +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/tabs/ContextTab.tsx b/src/components/ui/tabs/ContextTab.tsx new file mode 100644 index 0000000..761a529 --- /dev/null +++ b/src/components/ui/tabs/ContextTab.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMiniApp } from "@neynar/react"; + +/** + * ContextTab component displays the current mini app context in JSON format. + * + * This component provides a developer-friendly view of the Farcaster mini app context, + * including user information, client details, and other contextual data. It's useful + * for debugging and understanding what data is available to the mini app. + * + * The context includes: + * - User information (FID, username, display name, profile picture) + * - Client information (safe area insets, platform details) + * - Mini app configuration and state + * + * @example + * ```tsx + * + * ``` + */ +export function ContextTab() { + const { context } = useMiniApp(); + + return ( +
+

Context

+
+
+          {JSON.stringify(context, null, 2)}
+        
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/tabs/HomeTab.tsx b/src/components/ui/tabs/HomeTab.tsx new file mode 100644 index 0000000..aa7e37d --- /dev/null +++ b/src/components/ui/tabs/HomeTab.tsx @@ -0,0 +1,24 @@ +"use client"; + +/** + * HomeTab component displays the main landing content for the mini app. + * + * This is the default tab that users see when they first open the mini app. + * It provides a simple welcome message and placeholder content that can be + * customized for specific use cases. + * + * @example + * ```tsx + * + * ``` + */ +export function HomeTab() { + return ( +
+
+

Put your content here!

+

Powered by Neynar 🪐

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/tabs/WalletTab.tsx b/src/components/ui/tabs/WalletTab.tsx new file mode 100644 index 0000000..8acb8c4 --- /dev/null +++ b/src/components/ui/tabs/WalletTab.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useCallback, useMemo, useState, useEffect } from "react"; +import { useAccount, useSendTransaction, useSignTypedData, useWaitForTransactionReceipt, useDisconnect, useConnect, useSwitchChain, useChainId } from "wagmi"; +import { useWallet as useSolanaWallet } from '@solana/wallet-adapter-react'; +import { base, degen, mainnet, optimism, unichain } from "wagmi/chains"; +import { Button } from "../Button"; +import { truncateAddress } from "../../../lib/truncateAddress"; +import { renderError } from "../../../lib/errorUtils"; +import { SignEvmMessage } from "../wallet/SignEvmMessage"; +import { SendEth } from "../wallet/SendEth"; +import { SignSolanaMessage } from "../wallet/SignSolanaMessage"; +import { SendSolana } from "../wallet/SendSolana"; +import { USE_WALLET, APP_NAME } from "../../../lib/constants"; +import { useMiniApp } from "@neynar/react"; + +/** + * WalletTab component manages wallet-related UI for both EVM and Solana chains. + * + * This component provides a comprehensive wallet interface that supports: + * - EVM wallet connections (Farcaster Frame, Coinbase Wallet, MetaMask) + * - Solana wallet integration + * - Message signing for both chains + * - Transaction sending for both chains + * - Chain switching for EVM chains + * - Auto-connection in Farcaster clients + * + * The component automatically detects when running in a Farcaster client + * and attempts to auto-connect using the Farcaster Frame connector. + * + * @example + * ```tsx + * + * ``` + */ + +interface WalletStatusProps { + address?: string; + chainId?: number; +} + +/** + * Displays the current wallet address and chain ID. + */ +function WalletStatus({ address, chainId }: WalletStatusProps) { + return ( + <> + {address && ( +
+ Address:
{truncateAddress(address)}
+
+ )} + {chainId && ( +
+ Chain ID:
{chainId}
+
+ )} + + ); +} + +interface ConnectionControlsProps { + isConnected: boolean; + context: any; + connect: any; + connectors: readonly any[]; + disconnect: any; +} + +/** + * Renders wallet connection controls based on connection state and context. + */ +function ConnectionControls({ + isConnected, + context, + connect, + connectors, + disconnect, +}: ConnectionControlsProps) { + if (isConnected) { + return ( + + ); + } + if (context) { + return ( +
+ + +
+ ); + } + return ( +
+ + +
+ ); +} + +export function WalletTab() { + // --- State --- + const [evmContractTransactionHash, setEvmContractTransactionHash] = useState(null); + + // --- Hooks --- + const { context } = useMiniApp(); + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + const solanaWallet = useSolanaWallet(); + const { publicKey: solanaPublicKey } = solanaWallet; + + // --- Wagmi Hooks --- + const { + sendTransaction, + error: evmTransactionError, + isError: isEvmTransactionError, + isPending: isEvmTransactionPending, + } = useSendTransaction(); + + const { isLoading: isEvmTransactionConfirming, isSuccess: isEvmTransactionConfirmed } = + useWaitForTransactionReceipt({ + hash: evmContractTransactionHash as `0x${string}`, + }); + + const { + signTypedData, + error: evmSignTypedDataError, + isError: isEvmSignTypedDataError, + isPending: isEvmSignTypedDataPending, + } = useSignTypedData(); + + const { disconnect } = useDisconnect(); + const { connect, connectors } = useConnect(); + + const { + switchChain, + error: chainSwitchError, + isError: isChainSwitchError, + isPending: isChainSwitchPending, + } = useSwitchChain(); + + // --- Effects --- + /** + * Debug logging for wallet auto-connection and state changes. + * Logs context, connection status, address, and available connectors. + */ + useEffect(() => { + console.log("WalletTab Debug Info:"); + console.log("- context:", context); + console.log("- isConnected:", isConnected); + console.log("- address:", address); + console.log("- connectors:", connectors); + console.log("- context?.user:", context?.user); + }, [context, isConnected, address, connectors]); + + /** + * Auto-connect when Farcaster context is available. + * + * This effect detects when the app is running in a Farcaster client + * and automatically attempts to connect using the Farcaster Frame connector. + * It includes comprehensive logging for debugging connection issues. + */ + useEffect(() => { + // Check if we're in a Farcaster client environment + const isInFarcasterClient = typeof window !== 'undefined' && + (window.location.href.includes('warpcast.com') || + window.location.href.includes('farcaster') || + window.ethereum?.isFarcaster || + context?.client); + + if (context?.user?.fid && !isConnected && connectors.length > 0 && isInFarcasterClient) { + console.log("Attempting auto-connection with Farcaster context..."); + console.log("- User FID:", context.user.fid); + console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`)); + console.log("- Using connector:", connectors[0].name); + console.log("- In Farcaster client:", isInFarcasterClient); + + // Use the first connector (farcasterFrame) for auto-connection + try { + connect({ connector: connectors[0] }); + } catch (error) { + console.error("Auto-connection failed:", error); + } + } else { + console.log("Auto-connection conditions not met:"); + console.log("- Has context:", !!context?.user?.fid); + console.log("- Is connected:", isConnected); + console.log("- Has connectors:", connectors.length > 0); + console.log("- In Farcaster client:", isInFarcasterClient); + } + }, [context?.user?.fid, isConnected, connectors, connect, context?.client]); + + // --- Computed Values --- + /** + * Determines the next chain to switch to based on the current chain. + * Cycles through: Base → Optimism → Degen → Mainnet → Unichain → Base + */ + const nextChain = useMemo(() => { + if (chainId === base.id) { + return optimism; + } else if (chainId === optimism.id) { + return degen; + } else if (chainId === degen.id) { + return mainnet; + } else if (chainId === mainnet.id) { + return unichain; + } else { + return base; + } + }, [chainId]); + + // --- Handlers --- + /** + * Handles switching to the next chain in the rotation. + * Uses the switchChain function from wagmi to change the active chain. + */ + const handleSwitchChain = useCallback(() => { + switchChain({ chainId: nextChain.id }); + }, [switchChain, nextChain.id]); + + /** + * Sends a transaction to call the yoink() function on the Yoink contract. + * + * This function sends a transaction to a specific contract address with + * the encoded function call data for the yoink() function. + */ + const sendEvmContractTransaction = useCallback(() => { + sendTransaction( + { + // call yoink() on Yoink contract + to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878", + data: "0x9846cd9efc000023c0", + }, + { + onSuccess: (hash) => { + setEvmContractTransactionHash(hash); + }, + } + ); + }, [sendTransaction]); + + /** + * Signs typed data using EIP-712 standard. + * + * This function creates a typed data structure with the app name, version, + * and chain ID, then requests the user to sign it. + */ + const signTyped = useCallback(() => { + signTypedData({ + domain: { + name: APP_NAME, + version: "1", + chainId, + }, + types: { + Message: [{ name: "content", type: "string" }], + }, + message: { + content: `Hello from ${APP_NAME}!`, + }, + primaryType: "Message", + }); + }, [chainId, signTypedData]); + + // --- Early Return --- + if (!USE_WALLET) { + return null; + } + + // --- Render --- + return ( +
+ {/* Wallet Information Display */} + + + {/* Connection Controls */} + + + {/* EVM Wallet Components */} + + + {isConnected && ( + <> + + + {isEvmTransactionError && renderError(evmTransactionError)} + {evmContractTransactionHash && ( +
+
Hash: {truncateAddress(evmContractTransactionHash)}
+
+ Status:{" "} + {isEvmTransactionConfirming + ? "Confirming..." + : isEvmTransactionConfirmed + ? "Confirmed!" + : "Pending"} +
+
+ )} + + {isEvmSignTypedDataError && renderError(evmSignTypedDataError)} + + {isChainSwitchError && renderError(chainSwitchError)} + + )} + + {/* Solana Wallet Components */} + {solanaPublicKey && ( + <> + + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/tabs/index.ts b/src/components/ui/tabs/index.ts new file mode 100644 index 0000000..09492dd --- /dev/null +++ b/src/components/ui/tabs/index.ts @@ -0,0 +1,4 @@ +export { HomeTab } from './HomeTab'; +export { ActionsTab } from './ActionsTab'; +export { ContextTab } from './ContextTab'; +export { WalletTab } from './WalletTab'; \ No newline at end of file diff --git a/src/components/ui/wallet/SendEth.tsx b/src/components/ui/wallet/SendEth.tsx new file mode 100644 index 0000000..2d20da1 --- /dev/null +++ b/src/components/ui/wallet/SendEth.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useAccount, useSendTransaction, useWaitForTransactionReceipt } from "wagmi"; +import { base } from "wagmi/chains"; +import { Button } from "../Button"; +import { truncateAddress } from "../../../lib/truncateAddress"; +import { renderError } from "../../../lib/errorUtils"; + +/** + * SendEth component handles sending ETH transactions to protocol guild addresses. + * + * This component provides a simple interface for users to send small amounts + * of ETH to protocol guild addresses. It automatically selects the appropriate + * recipient address based on the current chain and displays transaction status. + * + * Features: + * - Chain-specific recipient addresses + * - Transaction status tracking + * - Error handling and display + * - Transaction hash display + * + * @example + * ```tsx + * + * ``` + */ +export function SendEth() { + // --- Hooks --- + const { isConnected, chainId } = useAccount(); + const { + sendTransaction, + data: ethTransactionHash, + error: ethTransactionError, + isError: isEthTransactionError, + isPending: isEthTransactionPending, + } = useSendTransaction(); + + const { isLoading: isEthTransactionConfirming, isSuccess: isEthTransactionConfirmed } = + useWaitForTransactionReceipt({ + hash: ethTransactionHash, + }); + + // --- Computed Values --- + /** + * Determines the recipient address based on the current chain. + * + * Uses different protocol guild addresses for different chains: + * - Base: 0x32e3C7fD24e175701A35c224f2238d18439C7dBC + * - Other chains: 0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830 + * + * @returns string - The recipient address for the current chain + */ + const protocolGuildRecipientAddress = useMemo(() => { + // Protocol guild address + return chainId === base.id + ? "0x32e3C7fD24e175701A35c224f2238d18439C7dBC" + : "0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830"; + }, [chainId]); + + // --- Handlers --- + /** + * Handles sending the ETH transaction. + * + * This function sends a small amount of ETH (1 wei) to the protocol guild + * address for the current chain. The transaction is sent using the wagmi + * sendTransaction hook. + */ + const sendEthTransaction = useCallback(() => { + sendTransaction({ + to: protocolGuildRecipientAddress, + value: 1n, + }); + }, [protocolGuildRecipientAddress, sendTransaction]); + + // --- Render --- + return ( + <> + + {isEthTransactionError && renderError(ethTransactionError)} + {ethTransactionHash && ( +
+
Hash: {truncateAddress(ethTransactionHash)}
+
+ Status:{" "} + {isEthTransactionConfirming + ? "Confirming..." + : isEthTransactionConfirmed + ? "Confirmed!" + : "Pending"} +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/wallet/SendSolana.tsx b/src/components/ui/wallet/SendSolana.tsx new file mode 100644 index 0000000..722e524 --- /dev/null +++ b/src/components/ui/wallet/SendSolana.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useConnection as useSolanaConnection, useWallet as useSolanaWallet } from '@solana/wallet-adapter-react'; +import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; +import { Button } from "../Button"; +import { truncateAddress } from "../../../lib/truncateAddress"; +import { renderError } from "../../../lib/errorUtils"; + +/** + * SendSolana component handles sending SOL transactions on Solana. + * + * This component provides a simple interface for users to send SOL transactions + * using their connected Solana wallet. It includes transaction status tracking + * and error handling. + * + * Features: + * - SOL transaction sending + * - Transaction status tracking + * - Error handling and display + * - Loading state management + * + * Note: This component is a placeholder implementation. In a real application, + * you would integrate with a Solana wallet adapter and transaction library + * like @solana/web3.js to handle actual transactions. + * + * @example + * ```tsx + * + * ``` + */ +export function SendSolana() { + const [solanaTransactionState, setSolanaTransactionState] = useState< + | { status: 'none' } + | { status: 'pending' } + | { status: 'error'; error: Error } + | { status: 'success'; signature: string } + >({ status: 'none' }); + + const { connection: solanaConnection } = useSolanaConnection(); + const { sendTransaction, publicKey } = useSolanaWallet(); + + // This should be replaced but including it from the original demo + // https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx#L718 + const ashoatsPhantomSolanaWallet = 'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1'; + + /** + * Handles sending the Solana transaction + */ + const sendSolanaTransaction = useCallback(async () => { + setSolanaTransactionState({ status: 'pending' }); + try { + if (!publicKey) { + throw new Error('no Solana publicKey'); + } + + const { blockhash } = await solanaConnection.getLatestBlockhash(); + if (!blockhash) { + throw new Error('failed to fetch latest Solana blockhash'); + } + + const fromPubkeyStr = publicKey.toBase58(); + const toPubkeyStr = ashoatsPhantomSolanaWallet; + const transaction = new Transaction(); + transaction.add( + SystemProgram.transfer({ + fromPubkey: new PublicKey(fromPubkeyStr), + toPubkey: new PublicKey(toPubkeyStr), + lamports: 0n, + }), + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = new PublicKey(fromPubkeyStr); + + const simulation = await solanaConnection.simulateTransaction(transaction); + if (simulation.value.err) { + // Gather logs and error details for debugging + const logs = simulation.value.logs?.join('\n') ?? 'No logs'; + const errDetail = JSON.stringify(simulation.value.err); + throw new Error(`Simulation failed: ${errDetail}\nLogs:\n${logs}`); + } + const signature = await sendTransaction(transaction, solanaConnection); + setSolanaTransactionState({ status: 'success', signature }); + } catch (e) { + if (e instanceof Error) { + setSolanaTransactionState({ status: 'error', error: e }); + } else { + setSolanaTransactionState({ status: 'none' }); + } + } + }, [sendTransaction, publicKey, solanaConnection]); + + return ( + <> + + {solanaTransactionState.status === 'error' && renderError(solanaTransactionState.error)} + {solanaTransactionState.status === 'success' && ( +
+
Hash: {truncateAddress(solanaTransactionState.signature)}
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/wallet/SignEvmMessage.tsx b/src/components/ui/wallet/SignEvmMessage.tsx new file mode 100644 index 0000000..5880742 --- /dev/null +++ b/src/components/ui/wallet/SignEvmMessage.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useCallback } from "react"; +import { useAccount, useConnect, useSignMessage } from "wagmi"; +import { base } from "wagmi/chains"; +import { Button } from "../Button"; +import { config } from "../../providers/WagmiProvider"; +import { APP_NAME } from "../../../lib/constants"; +import { renderError } from "../../../lib/errorUtils"; + +/** + * SignEvmMessage component handles signing messages on EVM-compatible chains. + * + * This component provides a simple interface for users to sign messages using + * their connected EVM wallet. It automatically handles wallet connection if + * the user is not already connected, and displays the signature result. + * + * Features: + * - Automatic wallet connection if needed + * - Message signing with app name + * - Error handling and display + * - Signature result display + * + * @example + * ```tsx + * + * ``` + */ +export function SignEvmMessage() { + // --- Hooks --- + const { isConnected } = useAccount(); + const { connectAsync } = useConnect(); + const { + signMessage, + data: evmMessageSignature, + error: evmSignMessageError, + isError: isEvmSignMessageError, + isPending: isEvmSignMessagePending, + } = useSignMessage(); + + // --- Handlers --- + /** + * Handles the message signing process. + * + * This function first ensures the user is connected to an EVM wallet, + * then requests them to sign a message containing the app name. + * If the user is not connected, it automatically connects using the + * Farcaster Frame connector. + * + * @returns Promise + */ + const signEvmMessage = useCallback(async () => { + if (!isConnected) { + await connectAsync({ + chainId: base.id, + connector: config.connectors[0], + }); + } + + signMessage({ message: `Hello from ${APP_NAME}!` }); + }, [connectAsync, isConnected, signMessage]); + + // --- Render --- + return ( + <> + + {isEvmSignMessageError && renderError(evmSignMessageError)} + {evmMessageSignature && ( +
+
Signature: {evmMessageSignature}
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/wallet/SignIn.tsx b/src/components/ui/wallet/SignIn.tsx new file mode 100644 index 0000000..2d049b0 --- /dev/null +++ b/src/components/ui/wallet/SignIn.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { signIn, signOut, getCsrfToken } from "next-auth/react"; +import sdk, { SignIn as SignInCore } from "@farcaster/frame-sdk"; +import { useSession } from "next-auth/react"; +import { Button } from "../Button"; + +/** + * SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF). + * + * This component provides a complete authentication flow for Farcaster users: + * - Generates nonces for secure authentication + * - Handles the SIWF flow using the Farcaster SDK + * - Manages NextAuth session state + * - Provides sign-out functionality + * - Displays authentication status and results + * + * The component integrates with both the Farcaster Frame SDK and NextAuth + * to provide seamless authentication within mini apps. + * + * @example + * ```tsx + * + * ``` + */ + +interface AuthState { + signingIn: boolean; + signingOut: boolean; +} + +export function SignIn() { + // --- State --- + const [authState, setAuthState] = useState({ + signingIn: false, + signingOut: false, + }); + const [signInResult, setSignInResult] = useState(); + const [signInFailure, setSignInFailure] = useState(); + + // --- Hooks --- + const { data: session, status } = useSession(); + + // --- Handlers --- + /** + * Generates a nonce for the sign-in process. + * + * This function retrieves a CSRF token from NextAuth to use as a nonce + * for the SIWF authentication flow. The nonce ensures the authentication + * request is fresh and prevents replay attacks. + * + * @returns Promise - The generated nonce token + * @throws Error if unable to generate nonce + */ + const getNonce = useCallback(async () => { + const nonce = await getCsrfToken(); + if (!nonce) throw new Error("Unable to generate nonce"); + return nonce; + }, []); + + /** + * Handles the sign-in process using Farcaster SDK. + * + * This function orchestrates the complete SIWF flow: + * 1. Generates a nonce for security + * 2. Calls the Farcaster SDK to initiate sign-in + * 3. Submits the result to NextAuth for session management + * 4. Handles various error conditions including user rejection + * + * @returns Promise + */ + const handleSignIn = useCallback(async () => { + try { + setAuthState((prev) => ({ ...prev, signingIn: true })); + setSignInFailure(undefined); + const nonce = await getNonce(); + const result = await sdk.actions.signIn({ nonce }); + setSignInResult(result); + await signIn("credentials", { + message: result.message, + signature: result.signature, + redirect: false, + }); + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + setSignInFailure("Rejected by user"); + return; + } + setSignInFailure("Unknown error"); + } finally { + setAuthState((prev) => ({ ...prev, signingIn: false })); + } + }, [getNonce]); + + /** + * Handles the sign-out process. + * + * This function clears the NextAuth session and resets the local + * sign-in result state to complete the sign-out flow. + * + * @returns Promise + */ + const handleSignOut = useCallback(async () => { + try { + setAuthState((prev) => ({ ...prev, signingOut: true })); + await signOut({ redirect: false }); + setSignInResult(undefined); + } finally { + setAuthState((prev) => ({ ...prev, signingOut: false })); + } + }, []); + + // --- Render --- + return ( + <> + {/* Authentication Buttons */} + {status !== "authenticated" && ( + + )} + {status === "authenticated" && ( + + )} + + {/* Session Information */} + {session && ( +
+
Session
+
+ {JSON.stringify(session, null, 2)} +
+
+ )} + + {/* Error Display */} + {signInFailure && !authState.signingIn && ( +
+
SIWF Result
+
{signInFailure}
+
+ )} + + {/* Success Result Display */} + {signInResult && !authState.signingIn && ( +
+
SIWF Result
+
+ {JSON.stringify(signInResult, null, 2)} +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/wallet/SignSolanaMessage.tsx b/src/components/ui/wallet/SignSolanaMessage.tsx new file mode 100644 index 0000000..46c198f --- /dev/null +++ b/src/components/ui/wallet/SignSolanaMessage.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Button } from "../Button"; +import { renderError } from "../../../lib/errorUtils"; + +interface SignSolanaMessageProps { + signMessage?: (message: Uint8Array) => Promise; +} + +/** + * SignSolanaMessage component handles signing messages on Solana. + * + * This component provides a simple interface for users to sign messages using + * their connected Solana wallet. It accepts a signMessage function as a prop + * and handles the complete signing flow including error handling. + * + * Features: + * - Message signing with Solana wallet + * - Error handling and display + * - Signature result display (base64 encoded) + * - Loading state management + * + * @param props - Component props + * @param props.signMessage - Function to sign messages with Solana wallet + * + * @example + * ```tsx + * + * ``` + */ +export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) { + // --- State --- + const [signature, setSignature] = useState(); + const [signError, setSignError] = useState(); + const [signPending, setSignPending] = useState(false); + + // --- Handlers --- + /** + * Handles the Solana message signing process. + * + * This function encodes a message as UTF-8 bytes, signs it using the provided + * signMessage function, and displays the base64-encoded signature result. + * It includes comprehensive error handling and loading state management. + * + * @returns Promise + */ + const handleSignMessage = useCallback(async () => { + setSignPending(true); + try { + if (!signMessage) { + throw new Error('no Solana signMessage'); + } + const input = new TextEncoder().encode("Hello from Solana!"); + const signatureBytes = await signMessage(input); + const signature = btoa(String.fromCharCode(...signatureBytes)); + setSignature(signature); + setSignError(undefined); + } catch (e) { + if (e instanceof Error) { + setSignError(e); + } + } finally { + setSignPending(false); + } + }, [signMessage]); + + // --- Render --- + return ( + <> + + {signError && renderError(signError)} + {signature && ( +
+
Signature: {signature}
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/ui/wallet/index.ts b/src/components/ui/wallet/index.ts new file mode 100644 index 0000000..1cacbd2 --- /dev/null +++ b/src/components/ui/wallet/index.ts @@ -0,0 +1,5 @@ +export { SignIn } from './SignIn'; +export { SignEvmMessage } from './SignEvmMessage'; +export { SendEth } from './SendEth'; +export { SignSolanaMessage } from './SignSolanaMessage'; +export { SendSolana } from './SendSolana'; \ No newline at end of file diff --git a/src/hooks/useNeynarUser.ts b/src/hooks/useNeynarUser.ts new file mode 100644 index 0000000..e89e569 --- /dev/null +++ b/src/hooks/useNeynarUser.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +export interface NeynarUser { + fid: number; + score: number; +} + +export function useNeynarUser(context?: { user?: { fid?: number } }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!context?.user?.fid) { + setUser(null); + setError(null); + return; + } + setLoading(true); + setError(null); + fetch(`/api/users?fids=${context.user.fid}`) + .then((response) => { + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response.json(); + }) + .then((data) => { + if (data.users?.[0]) { + setUser(data.users[0]); + } else { + setUser(null); + } + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [context?.user?.fid]); + + return { user, loading, error }; +} \ No newline at end of file diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c5c2c3e..60e5a8b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,14 +1,92 @@ +/** + * Application constants and configuration values. + * + * This file contains all the configuration constants used throughout the mini app. + * These values are either sourced from environment variables or hardcoded and provide + * configuration for the app's appearance, behavior, and integration settings. + */ + +// --- App Configuration --- +/** + * The base URL of the application. + * Used for generating absolute URLs for assets and API endpoints. + */ export const APP_URL = process.env.NEXT_PUBLIC_URL!; -export const APP_NAME = process.env.NEXT_PUBLIC_MINI_APP_NAME; -export const APP_DESCRIPTION = process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION; -export const APP_PRIMARY_CATEGORY = process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY; -export const APP_TAGS = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(','); + +/** + * The name of the mini app as displayed to users. + * Used in titles, headers, and app store listings. + */ +export const APP_NAME = 'Starter Kit'; + +/** + * A brief description of the mini app's functionality. + * Used in app store listings and metadata. + */ +export const APP_DESCRIPTION = 'A demo of the Neynar Starter Kit'; + +/** + * The primary category for the mini app. + * Used for app store categorization and discovery. + */ +export const APP_PRIMARY_CATEGORY = 'developer-tools'; + +/** + * Tags associated with the mini app. + * Used for search and discovery in app stores. + * Parsed from comma-separated environment variable. + */ +export const APP_TAGS = ['neynar', 'starter-kit', 'demo']; + +// --- Asset URLs --- +/** + * URL for the app's icon image. + * Used in app store listings and UI elements. + */ export const APP_ICON_URL = `${APP_URL}/icon.png`; + +/** + * URL for the app's Open Graph image. + * Used for social media sharing and previews. + */ export const APP_OG_IMAGE_URL = `${APP_URL}/api/opengraph-image`; + +/** + * URL for the app's splash screen image. + * Displayed during app loading. + */ export const APP_SPLASH_URL = `${APP_URL}/splash.png`; + +/** + * Background color for the splash screen. + * Used as fallback when splash image is loading. + */ export const APP_SPLASH_BACKGROUND_COLOR = "#f7f7f7"; -export const APP_BUTTON_TEXT = process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT; + +// --- UI Configuration --- +/** + * Text displayed on the main action button. + * Used for the primary call-to-action in the mini app. + */ +export const APP_BUTTON_TEXT = 'Launch NSK'; + +// --- Integration Configuration --- +/** + * Webhook URL for receiving events from Neynar. + * + * If Neynar API key and client ID are configured, uses the official + * Neynar webhook endpoint. Otherwise, falls back to a local webhook + * endpoint for development and testing. + */ 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'; + +/** + * Flag to enable/disable wallet functionality. + * + * When true, wallet-related components and features are rendered. + * When false, wallet functionality is completely hidden from the UI. + * Useful for mini apps that don't require wallet integration. + */ +export const USE_WALLET = true; diff --git a/src/lib/errorUtils.tsx b/src/lib/errorUtils.tsx new file mode 100644 index 0000000..c42b070 --- /dev/null +++ b/src/lib/errorUtils.tsx @@ -0,0 +1,66 @@ +import { type ReactElement } from "react"; +import { BaseError, UserRejectedRequestError } from "viem"; + +/** + * Renders an error object in a user-friendly format. + * + * This utility function takes an error object and renders it as a React element + * with consistent styling. It handles different types of errors including: + * - Error objects with message properties + * - Objects with error properties + * - String errors + * - Unknown error types + * - User rejection errors (special handling for wallet rejections) + * + * The rendered error is displayed in a gray container with monospace font + * for better readability of technical error details. User rejections are + * displayed with a simpler, more user-friendly message. + * + * @param error - The error object to render + * @returns ReactElement - A styled error display component, or null if no error + * + * @example + * ```tsx + * {isError && renderError(error)} + * ``` + */ +export function renderError(error: unknown): ReactElement | null { + // Handle null/undefined errors + if (!error) return null; + + // Special handling for user rejections in wallet operations + if (error instanceof BaseError) { + const isUserRejection = error.walk( + (e) => e instanceof UserRejectedRequestError + ); + + if (isUserRejection) { + return ( +
+
User Rejection
+
Transaction was rejected by user.
+
+ ); + } + } + + // Extract error message from different error types + let errorMessage: string; + + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'object' && error !== null && 'error' in error) { + errorMessage = String(error.error); + } else if (typeof error === 'string') { + errorMessage = error; + } else { + errorMessage = 'Unknown error occurred'; + } + + return ( +
+
Error
+
{errorMessage}
+
+ ); +} \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 3ee2214..7583b61 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,17 @@ import type { Config } from "tailwindcss"; +/** + * Tailwind CSS Configuration + * + * This configuration centralizes all theme colors for the mini app. + * To change the app's color scheme, simply update the 'primary' color value below. + * + * Example theme changes: + * - Blue theme: primary: "#3182CE" + * - Green theme: primary: "#059669" + * - Red theme: primary: "#DC2626" + * - Orange theme: primary: "#EA580C" + */ export default { darkMode: "media", content: [ @@ -10,6 +22,16 @@ export default { theme: { extend: { colors: { + // Main theme color - change this to update the entire app's color scheme + primary: "#8b5cf6", // Main brand color + "primary-light": "#a78bfa", // For hover states + "primary-dark": "#7c3aed", // For active states + + // Secondary colors for backgrounds and text + secondary: "#f8fafc", // Light backgrounds + "secondary-dark": "#334155", // Dark backgrounds + + // Legacy CSS variables for backward compatibility background: 'var(--background)', foreground: 'var(--foreground)' }, @@ -17,6 +39,20 @@ export default { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' + }, + // Custom spacing for consistent layout + spacing: { + '18': '4.5rem', + '88': '22rem', + }, + // Custom container sizes + maxWidth: { + 'xs': '20rem', + 'sm': '24rem', + 'md': '28rem', + 'lg': '32rem', + 'xl': '36rem', + '2xl': '42rem', } } }, From e20a2a397da645bcf5f5336b979bd79e953a8c42 Mon Sep 17 00:00:00 2001 From: veganbeef Date: Tue, 1 Jul 2025 09:46:56 -0700 Subject: [PATCH 2/3] feat: update npx script to write to constants file --- bin/init.js | 59 +++++++++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/bin/init.js b/bin/init.js index bb26b7b..eceba28 100644 --- a/bin/init.js +++ b/bin/init.js @@ -446,14 +446,57 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Write it to .env.local fs.writeFileSync(envPath, envExampleContent); - // Append all remaining environment variables - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_MINI_APP_NAME="${answers.projectName}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_MINI_APP_DESCRIPTION="${answers.description}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY="${answers.primaryCategory}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_MINI_APP_TAGS="${answers.tags.join(',')}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_MINI_APP_BUTTON_TEXT="${answers.buttonText}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_ANALYTICS_ENABLED="${answers.enableAnalytics}"`); - fs.appendFileSync(envPath, `\nNEXT_PUBLIC_USE_WALLET="${answers.useWallet}"`); + // Append remaining environment variables + // Update constants.ts file with user-provided values + console.log('\nUpdating constants.ts...'); + const constantsPath = path.join(projectPath, 'src', 'lib', 'constants.ts'); + if (fs.existsSync(constantsPath)) { + let constantsContent = fs.readFileSync(constantsPath, 'utf8'); + + // Update APP_NAME + constantsContent = constantsContent.replace( + /export const APP_NAME = ['"`][^'"`]*['"`];/, + `export const APP_NAME = '${answers.projectName}';` + ); + + // Update APP_DESCRIPTION + constantsContent = constantsContent.replace( + /export const APP_DESCRIPTION = ['"`][^'"`]*['"`];/, + `export const APP_DESCRIPTION = '${answers.description}';` + ); + + // Update APP_PRIMARY_CATEGORY + if (answers.primaryCategory) { + constantsContent = constantsContent.replace( + /export const APP_PRIMARY_CATEGORY = ['"`][^'"`]*['"`];/, + `export const APP_PRIMARY_CATEGORY = '${answers.primaryCategory}';` + ); + } + + // Update APP_TAGS + const tagsString = answers.tags.length > 0 ? `['${answers.tags.join("', '")}']` : "['neynar', 'starter-kit', 'demo']"; + constantsContent = constantsContent.replace( + /export const APP_TAGS = \[[^\]]*\];/, + `export const APP_TAGS = ${tagsString};` + ); + + // Update APP_BUTTON_TEXT + constantsContent = constantsContent.replace( + /export const APP_BUTTON_TEXT = ['"`][^'"`]*['"`];/, + `export const APP_BUTTON_TEXT = '${answers.buttonText}';` + ); + + // Update USE_WALLET + constantsContent = constantsContent.replace( + /export const USE_WALLET = (true|false);/, + `export const USE_WALLET = ${answers.useWallet};` + ); + + fs.writeFileSync(constantsPath, constantsContent); + console.log('Updated constants.ts with user configuration'); + } else { + console.log('⚠️ constants.ts not found, skipping constants update'); + } fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`); if (useNeynar && neynarApiKey && neynarClientId) { diff --git a/package.json b/package.json index 428eaef..72554a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.4.6", + "version": "1.5.0", "type": "module", "private": false, "access": "public", From 6f0300fd7c23fd4ff733bb7425e48da22b7250bd Mon Sep 17 00:00:00 2001 From: veganbeef Date: Tue, 1 Jul 2025 17:27:24 -0700 Subject: [PATCH 3/3] feat: update constant writes --- bin/init.js | 74 ++++++++++++++++++++-------- src/app/providers.tsx | 3 +- src/components/ui/tabs/WalletTab.tsx | 13 ----- src/lib/constants.ts | 9 ++++ 4 files changed, 65 insertions(+), 34 deletions(-) diff --git a/bin/init.js b/bin/init.js index eceba28..df33f7d 100644 --- a/bin/init.js +++ b/bin/init.js @@ -453,47 +453,81 @@ export async function init(projectName = null, autoAcceptDefaults = false) { if (fs.existsSync(constantsPath)) { let constantsContent = fs.readFileSync(constantsPath, 'utf8'); + // Helper function to escape single quotes in strings + const escapeString = (str) => str.replace(/'/g, "\\'"); + + // Helper function to safely replace constants with validation + const safeReplace = (content, pattern, replacement, constantName) => { + const newContent = content.replace(pattern, replacement); + if (newContent === content) { + console.log(`⚠️ Warning: Could not update ${constantName} in constants.ts`); + } + return newContent; + }; + // Update APP_NAME - constantsContent = constantsContent.replace( - /export const APP_NAME = ['"`][^'"`]*['"`];/, - `export const APP_NAME = '${answers.projectName}';` + constantsContent = safeReplace( + constantsContent, + /export const APP_NAME\s*=\s*['"`][^'"`]*['"`];/, + `export const APP_NAME = '${escapeString(answers.projectName)}';`, + 'APP_NAME' ); // Update APP_DESCRIPTION - constantsContent = constantsContent.replace( - /export const APP_DESCRIPTION = ['"`][^'"`]*['"`];/, - `export const APP_DESCRIPTION = '${answers.description}';` + constantsContent = safeReplace( + constantsContent, + /export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];/, + `export const APP_DESCRIPTION = '${escapeString(answers.description)}';`, + 'APP_DESCRIPTION' ); // Update APP_PRIMARY_CATEGORY if (answers.primaryCategory) { - constantsContent = constantsContent.replace( - /export const APP_PRIMARY_CATEGORY = ['"`][^'"`]*['"`];/, - `export const APP_PRIMARY_CATEGORY = '${answers.primaryCategory}';` + constantsContent = safeReplace( + constantsContent, + /export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];/, + `export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory)}';`, + 'APP_PRIMARY_CATEGORY' ); } // Update APP_TAGS - const tagsString = answers.tags.length > 0 ? `['${answers.tags.join("', '")}']` : "['neynar', 'starter-kit', 'demo']"; - constantsContent = constantsContent.replace( - /export const APP_TAGS = \[[^\]]*\];/, - `export const APP_TAGS = ${tagsString};` + const tagsString = answers.tags.length > 0 + ? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']` + : "['neynar', 'starter-kit', 'demo']"; + constantsContent = safeReplace( + constantsContent, + /export const APP_TAGS\s*=\s*\[[^\]]*\];/, + `export const APP_TAGS = ${tagsString};`, + 'APP_TAGS' ); // Update APP_BUTTON_TEXT - constantsContent = constantsContent.replace( - /export const APP_BUTTON_TEXT = ['"`][^'"`]*['"`];/, - `export const APP_BUTTON_TEXT = '${answers.buttonText}';` + constantsContent = safeReplace( + constantsContent, + /export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];/, + `export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText)}';`, + 'APP_BUTTON_TEXT' ); // Update USE_WALLET - constantsContent = constantsContent.replace( - /export const USE_WALLET = (true|false);/, - `export const USE_WALLET = ${answers.useWallet};` + constantsContent = safeReplace( + constantsContent, + /export const USE_WALLET\s*=\s*(true|false);/, + `export const USE_WALLET = ${answers.useWallet};`, + 'USE_WALLET' + ); + + // Update ANALYTICS_ENABLED + constantsContent = safeReplace( + constantsContent, + /export const ANALYTICS_ENABLED\s*=\s*(true|false);/, + `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, + 'ANALYTICS_ENABLED' ); fs.writeFileSync(constantsPath, constantsContent); - console.log('Updated constants.ts with user configuration'); + console.log('✅ Updated constants.ts with user configuration'); } else { console.log('⚠️ constants.ts not found, skipping constants update'); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 56c2f1f..959cf90 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,6 +5,7 @@ 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"; const WagmiProvider = dynamic( () => import("~/components/providers/WagmiProvider"), @@ -18,7 +19,7 @@ export function Providers({ session, children }: { session: Session | null, chil return ( - + {children} diff --git a/src/components/ui/tabs/WalletTab.tsx b/src/components/ui/tabs/WalletTab.tsx index 8acb8c4..0d2d08d 100644 --- a/src/components/ui/tabs/WalletTab.tsx +++ b/src/components/ui/tabs/WalletTab.tsx @@ -157,19 +157,6 @@ export function WalletTab() { } = useSwitchChain(); // --- Effects --- - /** - * Debug logging for wallet auto-connection and state changes. - * Logs context, connection status, address, and available connectors. - */ - useEffect(() => { - console.log("WalletTab Debug Info:"); - console.log("- context:", context); - console.log("- isConnected:", isConnected); - console.log("- address:", address); - console.log("- connectors:", connectors); - console.log("- context?.user:", context?.user); - }, [context, isConnected, address, connectors]); - /** * Auto-connect when Farcaster context is available. * diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 60e5a8b..1c54227 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -90,3 +90,12 @@ export const APP_WEBHOOK_URL = process.env.NEYNAR_API_KEY && process.env.NEYNAR_ * Useful for mini apps that don't require wallet integration. */ export const USE_WALLET = true; + +/** + * Flag to enable/disable analytics tracking. + * + * When true, usage analytics are collected and sent to Neynar. + * When false, analytics collection is disabled. + * Useful for privacy-conscious users or development environments. + */ +export const ANALYTICS_ENABLED = true;