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:
Click me
+ * ✅ 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 (
+
+ );
+ }
+
+ // --- 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' && (
-
-
-
-
-
-
actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link
-
-
- Add Mini App to Client
-
-
- {sendNotificationResult && (
-
- Send notification result: {sendNotificationResult}
-
- )}
-
- Send notification
-
-
-
{
- if (context?.user?.fid) {
- const shareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`;
- await navigator.clipboard.writeText(shareUrl);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- }}
- disabled={!context?.user?.fid}
- className="w-full"
- >
- {copied ? "Copied!" : "Copy share URL"}
-
-
-
-
- Haptic Intensity
-
- setHapticIntensity(e.target.value as typeof hapticIntensity)}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-purple-500"
- >
- Light
- Medium
- Heavy
- Soft
- Rigid
-
- {
- try {
- await haptics.impactOccurred(hapticIntensity);
- } catch (error) {
- console.error('Haptic feedback failed:', error);
- }
- }}
- className="w-full"
- >
- Trigger Haptic Feedback
-
-
-
- )}
-
- {currentTab === 'context' && (
-
-
Context
-
-
- {JSON.stringify(context, null, 2)}
-
-
-
- )}
-
- {currentTab === 'wallet' && USE_WALLET && (
-
- {address && (
-
- Address:
{truncateAddress(address)}
-
- )}
-
- {chainId && (
-
- )}
-
- {isConnected ? (
-
disconnect()}
- className="w-full"
- >
- Disconnect
-
- ) : context ? (
-
connect({ connector: connectors[0] })}
- className="w-full"
- >
- Connect
-
- ) : (
-
- connect({ connector: connectors[1] })}
- className="w-full"
- >
- Connect Coinbase Wallet
-
- connect({ connector: connectors[2] })}
- className="w-full"
- >
- Connect MetaMask
-
-
- )}
-
-
-
- {isConnected && (
- <>
-
-
- Send Transaction (contract)
-
- {isSendTxError && renderError(sendTxError)}
- {txHash && (
-
-
Hash: {truncateAddress(txHash)}
-
- Status:{" "}
- {isConfirming
- ? "Confirming..."
- : isConfirmed
- ? "Confirmed!"
- : "Pending"}
-
-
- )}
-
- Sign Typed Data
-
- {isSignTypedError && renderError(signTypedError)}
-
- Switch to {nextChain.name}
-
- {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 (
- <>
-
- Sign Message
-
- {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 (
- <>
-
- Send Transaction (sol)
-
- {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 (
- <>
-
- Sign Message
-
- {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 (
- <>
-
- Send Transaction (eth)
-
- {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" && (
-
- Sign In with Farcaster
-
- )}
- {status === "authenticated" && (
-
- Sign out
-
- )}
- {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 (
{isLoading ? (
) : (
children
diff --git a/src/components/ui/Footer.tsx b/src/components/ui/Footer.tsx
index 6493cb8..948f4f9 100644
--- a/src/components/ui/Footer.tsx
+++ b/src/components/ui/Footer.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import type { Tab } from "~/components/Demo";
+import { Tab } from "~/components/App";
interface FooterProps {
activeTab: Tab;
@@ -8,30 +8,30 @@ interface FooterProps {
}
export const Footer: React.FC = ({ activeTab, setActiveTab, showWallet = false }) => (
-
+
setActiveTab('home')}
+ onClick={() => setActiveTab(Tab.Home)}
className={`flex flex-col items-center justify-center w-full h-full ${
- activeTab === 'home' ? 'text-purple-500 dark:text-purple-400' : 'text-gray-500 dark:text-gray-400'
+ activeTab === Tab.Home ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
🏠
Home
setActiveTab('actions')}
+ onClick={() => setActiveTab(Tab.Actions)}
className={`flex flex-col items-center justify-center w-full h-full ${
- activeTab === 'actions' ? 'text-purple-500 dark:text-purple-400' : 'text-gray-500 dark:text-gray-400'
+ activeTab === Tab.Actions ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
⚡
Actions
setActiveTab('context')}
+ onClick={() => setActiveTab(Tab.Context)}
className={`flex flex-col items-center justify-center w-full h-full ${
- activeTab === 'context' ? 'text-purple-500 dark:text-purple-400' : 'text-gray-500 dark:text-gray-400'
+ activeTab === Tab.Context ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
📋
@@ -39,9 +39,9 @@ export const Footer: React.FC = ({ activeTab, setActiveTab, showWal
{showWallet && (
setActiveTab('wallet')}
+ onClick={() => setActiveTab(Tab.Wallet)}
className={`flex flex-col items-center justify-center w-full h-full ${
- activeTab === 'wallet' ? 'text-purple-500 dark:text-purple-400' : 'text-gray-500 dark:text-gray-400'
+ activeTab === Tab.Wallet ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
👛
diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx
index 77b7068..9740001 100644
--- a/src/components/ui/Header.tsx
+++ b/src/components/ui/Header.tsx
@@ -15,12 +15,11 @@ type HeaderProps = {
export function Header({ neynarUser }: HeaderProps) {
const { context } = useMiniApp();
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
- const [hasClickedPfp, setHasClickedPfp] = useState(false);
return (
Welcome to {APP_NAME}!
@@ -30,29 +29,22 @@ export function Header({ neynarUser }: HeaderProps) {
className="cursor-pointer"
onClick={() => {
setIsUserDropdownOpen(!isUserDropdownOpen);
- setHasClickedPfp(true);
}}
>
{context.user.pfpUrl && (
)}
)}
{context?.user && (
- <>
- {!hasClickedPfp && (
-
- ↑ Click PFP! ↑
-
- )}
-
+ <>
{isUserDropdownOpen && (
-
+
+ * ```
+ */
+export function ActionsTab() {
+ // --- Hooks ---
+ const {
+ actions,
+ added,
+ notificationDetails,
+ haptics,
+ context,
+ } = useMiniApp();
+
+ // --- State ---
+ const [notificationState, setNotificationState] = useState({
+ sendStatus: "",
+ shareUrlCopied: false,
+ });
+ const [selectedHapticIntensity, setSelectedHapticIntensity] = useState
('medium');
+
+ // --- Handlers ---
+ /**
+ * Sends a notification to the current user's Farcaster account.
+ *
+ * This function makes a POST request to the /api/send-notification endpoint
+ * with the user's FID and notification details. It handles different response
+ * statuses including success (200), rate limiting (429), and errors.
+ *
+ * @returns Promise that resolves when the notification is sent or fails
+ */
+ const sendFarcasterNotification = useCallback(async () => {
+ setNotificationState((prev) => ({ ...prev, sendStatus: "" }));
+ 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) {
+ setNotificationState((prev) => ({ ...prev, sendStatus: "Success" }));
+ return;
+ } else if (response.status === 429) {
+ setNotificationState((prev) => ({ ...prev, sendStatus: "Rate limited" }));
+ return;
+ }
+ const responseText = await response.text();
+ setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}` }));
+ } catch (error) {
+ setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${error}` }));
+ }
+ }, [context, notificationDetails]);
+
+ /**
+ * Copies the share URL for the current user to the clipboard.
+ *
+ * This function generates a share URL using the user's FID and copies it
+ * to the clipboard. It shows a temporary "Copied!" message for 2 seconds.
+ */
+ const copyUserShareUrl = useCallback(async () => {
+ if (context?.user?.fid) {
+ const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`;
+ await navigator.clipboard.writeText(userShareUrl);
+ setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
+ setTimeout(() => setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 2000);
+ }
+ }, [context?.user?.fid]);
+
+ /**
+ * Triggers haptic feedback with the selected intensity.
+ *
+ * This function calls the haptics.impactOccurred method with the current
+ * selectedHapticIntensity setting. It handles errors gracefully by logging them.
+ */
+ const triggerHapticFeedback = useCallback(async () => {
+ try {
+ await haptics.impactOccurred(selectedHapticIntensity);
+ } catch (error) {
+ console.error('Haptic feedback failed:', error);
+ }
+ }, [haptics, selectedHapticIntensity]);
+
+ // --- Render ---
+ return (
+
+ {/* Share functionality */}
+
+
+ {/* Authentication */}
+
+
+ {/* Mini app actions */}
+
actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link
+
+
+ Add Mini App to Client
+
+
+ {/* Notification functionality */}
+ {notificationState.sendStatus && (
+
+ Send notification result: {notificationState.sendStatus}
+
+ )}
+
+ Send notification
+
+
+ {/* Share URL copying */}
+
+ {notificationState.shareUrlCopied ? "Copied!" : "Copy share URL"}
+
+
+ {/* Haptic feedback controls */}
+
+
+ Haptic Intensity
+
+ setSelectedHapticIntensity(e.target.value as Haptics.ImpactOccurredType)}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
+ >
+ Light
+ Medium
+ Heavy
+ Soft
+ Rigid
+
+
+ Trigger Haptic Feedback
+
+
+
+ );
+}
\ 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 && (
+
+ )}
+ >
+ );
+}
+
+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 (
+ disconnect()} className="w-full">
+ Disconnect
+
+ );
+ }
+ if (context) {
+ return (
+
+ connect({ connector: connectors[0] })} className="w-full">
+ Connect (Auto)
+
+ {
+ console.log("Manual Farcaster connection attempt");
+ console.log("Connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
+ connect({ connector: connectors[0] });
+ }}
+ className="w-full"
+ >
+ Connect Farcaster (Manual)
+
+
+ );
+ }
+ return (
+
+ connect({ connector: connectors[1] })} className="w-full">
+ Connect Coinbase Wallet
+
+ connect({ connector: connectors[2] })} className="w-full">
+ Connect MetaMask
+
+
+ );
+}
+
+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 && (
+ <>
+
+
+ Send Transaction (contract)
+
+ {isEvmTransactionError && renderError(evmTransactionError)}
+ {evmContractTransactionHash && (
+
+
Hash: {truncateAddress(evmContractTransactionHash)}
+
+ Status:{" "}
+ {isEvmTransactionConfirming
+ ? "Confirming..."
+ : isEvmTransactionConfirmed
+ ? "Confirmed!"
+ : "Pending"}
+
+
+ )}
+
+ Sign Typed Data
+
+ {isEvmSignTypedDataError && renderError(evmSignTypedDataError)}
+
+ Switch to {nextChain.name}
+
+ {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 (
+ <>
+
+ Send Transaction (eth)
+
+ {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 (
+ <>
+
+ Send Transaction (sol)
+
+ {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 (
+ <>
+
+ Sign Message
+
+ {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" && (
+
+ Sign In with Farcaster
+
+ )}
+ {status === "authenticated" && (
+
+ Sign out
+
+ )}
+
+ {/* 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 (
+ <>
+
+ Sign Message
+
+ {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',
}
}
},