diff --git a/package-lock.json b/package-lock.json index e6d8af4..21e3f9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.5.2", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neynar/create-farcaster-mini-app", - "version": "1.5.2", + "version": "1.5.3", "dependencies": { "dotenv": "^16.4.7", "inquirer": "^12.4.3", + "siwe": "^3.0.0", "viem": "^2.23.6" }, "bin": { @@ -624,6 +625,47 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@spruceid/siwe-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@spruceid/siwe-parser/-/siwe-parser-3.0.0.tgz", + "integrity": "sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/hashes": "^1.1.2", + "apg-js": "^4.4.0" + } + }, + "node_modules/@stablelib/binary": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", + "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", + "license": "MIT", + "dependencies": { + "@stablelib/int": "^1.0.1" + } + }, + "node_modules/@stablelib/int": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", + "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", + "license": "MIT" + }, + "node_modules/@stablelib/random": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@stablelib/random/-/random-1.0.2.tgz", + "integrity": "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==", + "license": "MIT", + "dependencies": { + "@stablelib/binary": "^1.0.1", + "@stablelib/wipe": "^1.0.1" + } + }, + "node_modules/@stablelib/wipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", + "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", + "license": "MIT" + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -662,6 +704,13 @@ } } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT", + "peer": true + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -711,6 +760,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apg-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/apg-js/-/apg-js-4.4.0.tgz", + "integrity": "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==", + "license": "BSD-2-Clause" + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -1318,6 +1373,114 @@ "node": ">=0.10.0" } }, + "node_modules/ethers": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", + "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT", + "peer": true + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD", + "peer": true + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT", + "peer": true + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -2230,6 +2393,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/siwe": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/siwe/-/siwe-3.0.0.tgz", + "integrity": "sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==", + "license": "Apache-2.0", + "dependencies": { + "@spruceid/siwe-parser": "^3.0.0", + "@stablelib/random": "^1.0.1" + }, + "peerDependencies": { + "ethers": "^5.6.8 || ^6.0.8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", diff --git a/package.json b/package.json index 3666086..0709c25 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "dotenv": "^16.4.7", "inquirer": "^12.4.3", + "siwe": "^3.0.0", "viem": "^2.23.6" }, "devDependencies": { diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..0d4bb6a --- /dev/null +++ b/src/app/api/auth/nonce/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET() { + const client = getNeynarClient(); + + const response = await client.fetchNonce(); + + return NextResponse.json(response); +} diff --git a/src/app/api/auth/signer/route.ts b/src/app/api/auth/signer/route.ts new file mode 100644 index 0000000..9a1edd1 --- /dev/null +++ b/src/app/api/auth/signer/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message) { + return NextResponse.json( + { error: 'Message parameter is required' }, + { status: 400 } + ); + } + + if (!signature) { + return NextResponse.json( + { error: 'Signature parameter is required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + + let signers; + + try { + const data = await client.fetchSigners({ message, signature }); + signers = data.signers; + } catch (error) { + console.error('Error fetching signers:', error?.response?.data); + throw new Error('Failed to fetch signers'); + } + console.log('signers =>', signers); + + return NextResponse.json({ + signers, + }); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 959cf90..90584eb 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,27 +1,38 @@ -"use client"; +'use client'; -import dynamic from "next/dynamic"; -import type { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; -import { MiniAppProvider } from "@neynar/react"; -import { SafeFarcasterSolanaProvider } from "~/components/providers/SafeFarcasterSolanaProvider"; -import { ANALYTICS_ENABLED } from "~/lib/constants"; +import dynamic from 'next/dynamic'; +import type { Session } from 'next-auth'; +import { SessionProvider } from 'next-auth/react'; +import { MiniAppProvider } from '@neynar/react'; +import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; +import { ANALYTICS_ENABLED } from '~/lib/constants'; +import { AuthKitProvider } from '@farcaster/auth-kit'; const WagmiProvider = dynamic( - () => import("~/components/providers/WagmiProvider"), + () => import('~/components/providers/WagmiProvider'), { ssr: false, } ); -export function Providers({ session, children }: { session: Session | null, children: React.ReactNode }) { - const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || "https://solana-rpc.publicnode.com"; +export function Providers({ + session, + children, +}: { + session: Session | null; + children: React.ReactNode; +}) { + const solanaEndpoint = + process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; return ( - + - {children} + {children} diff --git a/src/components/ui/NeynarAuthButton.tsx b/src/components/ui/NeynarAuthButton.tsx new file mode 100644 index 0000000..fbd3900 --- /dev/null +++ b/src/components/ui/NeynarAuthButton.tsx @@ -0,0 +1,526 @@ +'use client'; + +import '@farcaster/auth-kit/styles.css'; +import { useSignIn } from '@farcaster/auth-kit'; +import { useCallback, useEffect, useState, useRef } from 'react'; +import { cn } from '../../lib/utils'; +import { Button } from './Button'; + +// Utility functions for device detection +function isAndroid(): boolean { + return ( + typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent) + ); +} + +function isSmallIOS(): boolean { + return ( + typeof navigator !== 'undefined' && /iPhone|iPod/.test(navigator.userAgent) + ); +} + +function isLargeIOS(): boolean { + return ( + typeof navigator !== 'undefined' && + (/iPad/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) + ); +} + +function isIOS(): boolean { + return isSmallIOS() || isLargeIOS(); +} + +function isMobile(): boolean { + return isAndroid() || isIOS(); +} + +// Hook for detecting clicks outside an element +function useDetectClickOutside( + ref: React.RefObject, + callback: () => void +) { + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [ref, callback]); +} + +// Storage utilities for persistence +const STORAGE_KEY = 'farcaster_auth_state'; + +interface StoredAuthState { + isAuthenticated: boolean; + userData?: { + fid?: number; + pfpUrl?: string; + username?: string; + }; + lastSignInTime?: number; +} + +function saveAuthState(state: StoredAuthState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn('Failed to save auth state:', error); + } +} + +function loadAuthState(): StoredAuthState | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.warn('Failed to load auth state:', error); + return null; + } +} + +function clearAuthState() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.warn('Failed to clear auth state:', error); + } +} + +// QR Code Dialog Component +function QRCodeDialog({ + open, + onClose, + url, + isError, + error, +}: { + open: boolean; + onClose: () => void; + url: string; + isError: boolean; + error?: Error | null; +}) { + if (!open) return null; + + return ( +
+
+
+

+ {isError ? 'Error' : 'Sign in with Farcaster'} +

+ +
+ + {isError ? ( +
+
+ {error?.message || 'Unknown error, please try again.'} +
+ +
+ ) : ( +
+

+ To sign in with Farcaster, scan the code below with your + phone's camera. +

+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code for Farcaster sign in +
+
+ + +
+ )} +
+
+ ); +} + +// Profile Button Component +function ProfileButton({ + userData, + onSignOut, +}: { + userData?: { fid?: number; pfpUrl?: string; username?: string }; + onSignOut: () => void; +}) { + const [showDropdown, setShowDropdown] = useState(false); + const ref = useRef(null); + + useDetectClickOutside(ref, () => setShowDropdown(false)); + + const name = userData?.username ?? `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + + return ( +
+ + + {showDropdown && ( +
+ +
+ )} +
+ ); +} + +// Main Custom SignInButton Component +export function NeynarAuthButton() { + const [nonce, setNonce] = useState(null); + const [showDialog, setShowDialog] = useState(false); + const [storedAuth, setStoredAuth] = useState(null); + + // Generate nonce + useEffect(() => { + const generateNonce = async () => { + try { + const response = await fetch('/api/auth/nonce'); + if (response.ok) { + const data = await response.json(); + setNonce(data.nonce); + } else { + console.error('Failed to fetch nonce'); + } + } catch (error) { + console.error('Error generating nonce:', error); + } + }; + + generateNonce(); + }, []); + + // Load stored auth state on mount + useEffect(() => { + const stored = loadAuthState(); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } + }, []); + + // Success callback - this is critical! + const onSuccessCallback = useCallback((res: unknown) => { + console.log('🎉 Sign in successful!', res); + const authState: StoredAuthState = { + isAuthenticated: true, + userData: res as StoredAuthState['userData'], + lastSignInTime: Date.now(), + }; + saveAuthState(authState); + setStoredAuth(authState); + setShowDialog(false); + }, []); + + // Status response callback + const onStatusCallback = useCallback((statusData: unknown) => { + console.log('📊 Status response:', statusData); + }, []); + + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('❌ Sign in error:', error); + }, []); + + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onStatusResponse: onStatusCallback, + onError: onErrorCallback, + }); + + const { + signIn, + signOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + isPolling, + } = signInState; + + // Connect when component mounts and we have a nonce + useEffect(() => { + if (nonce && !channelToken) { + console.log('🔌 Connecting with nonce:', nonce); + connect(); + } + }, [nonce, channelToken, connect]); + + // Debug logging + useEffect(() => { + console.log('🔍 Auth state:', { + isSuccess, + validSignature, + hasData: !!data, + isPolling, + isError, + storedAuth: !!storedAuth?.isAuthenticated, + }); + }, [isSuccess, validSignature, data, isPolling, isError, storedAuth]); + + // Handle fetching signers after successful authentication + useEffect(() => { + if (data?.message && data?.signature) { + console.log('📝 Got message and signature:', { + message: data.message, + signature: data.signature, + }); + + const fetchSigners = async () => { + try { + const response = await fetch( + `/api/auth/signer?message=${encodeURIComponent( + data.message || '' + )}&signature=${data.signature}` + ); + + const signerData = await response.json(); + console.log('🔐 Signer response:', signerData); + + if (response.ok) { + console.log('✅ Signers fetched successfully:', signerData.signers); + } else { + console.error('❌ Failed to fetch signers'); + } + } catch (error) { + console.error('❌ Error fetching signers:', error); + } + }; + + fetchSigners(); + } + }, [data?.message, data?.signature]); + + const handleSignIn = useCallback(() => { + console.log('🚀 Starting sign in flow...'); + if (isError) { + console.log('🔄 Reconnecting due to error...'); + reconnect(); + } + setShowDialog(true); + signIn(); + + // Open mobile app if on mobile and URL is available + if (url && isMobile()) { + console.log('📱 Opening mobile app:', url); + window.open(url, '_blank'); + } + }, [isError, reconnect, signIn, url]); + + const handleSignOut = useCallback(() => { + console.log('👋 Signing out...'); + setShowDialog(false); + signOut(); + clearAuthState(); + setStoredAuth(null); + }, [signOut]); + + // The key fix: match the original library's authentication logic exactly + const authenticated = + (isSuccess && validSignature) || storedAuth?.isAuthenticated; + const userData = data || storedAuth?.userData; + + // Show loading state while nonce is being fetched + if (!nonce) { + return ( +
+
+
+ + Loading... + +
+
+ ); + } + + return ( + <> + {authenticated ? ( + + ) : ( + + )} + + {/* QR Code Dialog for desktop */} + {url && ( + setShowDialog(false)} + url={url} + isError={isError} + error={error} + /> + )} + + {/* Debug panel (optional - can be removed in production) */} + {/* {process.env.NODE_ENV === "development" && ( +
+
Debug Info:
+
+            {JSON.stringify(
+              {
+                authenticated,
+                isSuccess,
+                validSignature,
+                hasData: !!data,
+                isPolling,
+                isError,
+                hasStoredAuth: !!storedAuth?.isAuthenticated,
+                hasUrl: !!url,
+                hasChannelToken: !!channelToken,
+              },
+              null,
+              2
+            )}
+          
+
+ )} */} + + ); +} diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index 392929c..e9c0fb7 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -1,15 +1,16 @@ -"use client"; +'use client'; -import { useCallback, useState } from "react"; -import { useMiniApp } from "@neynar/react"; -import { ShareButton } from "../Share"; -import { Button } from "../Button"; -import { SignIn } from "../wallet/SignIn"; -import { type Haptics } from "@farcaster/frame-sdk"; +import { useCallback, useState } from 'react'; +import { useMiniApp } from '@neynar/react'; +import { ShareButton } from '../Share'; +import { Button } from '../Button'; +import { SignIn } from '../wallet/SignIn'; +import { type Haptics } from '@farcaster/frame-sdk'; +import { NeynarAuthButton } from '../NeynarAuthButton'; /** * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. - * + * * This component provides the main interaction interface for users to: * - Share the mini app with others * - Sign in with Farcaster @@ -17,10 +18,10 @@ import { type Haptics } from "@farcaster/frame-sdk"; * - Trigger haptic feedback * - Add the mini app to their client * - Copy share URLs - * + * * The component uses the useMiniApp hook to access Farcaster context and actions. * All state is managed locally within this component. - * + * * @example * ```tsx * @@ -28,63 +29,68 @@ import { type Haptics } from "@farcaster/frame-sdk"; */ export function ActionsTab() { // --- Hooks --- - const { - actions, - added, - notificationDetails, - haptics, - context, - } = useMiniApp(); - + const { actions, added, notificationDetails, haptics, context } = + useMiniApp(); + // --- State --- const [notificationState, setNotificationState] = useState({ - sendStatus: "", + sendStatus: '', shareUrlCopied: false, }); - const [selectedHapticIntensity, setSelectedHapticIntensity] = useState('medium'); + 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: "" })); + 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" }, + 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" })); + setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); return; } else if (response.status === 429) { - setNotificationState((prev) => ({ ...prev, sendStatus: "Rate limited" })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: 'Rate limited', + })); return; } const responseText = await response.text(); - setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}` })); + setNotificationState((prev) => ({ + ...prev, + sendStatus: `Error: ${responseText}`, + })); } catch (error) { - setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${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. */ @@ -93,13 +99,17 @@ export function ActionsTab() { 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); + 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. */ @@ -113,56 +123,76 @@ export function ActionsTab() { // --- Render --- return ( -
+
{/* Share functionality */} - {/* Authentication */} - {/* Mini app actions */} - + {/* Neynar Authentication */} + - + + {/* Notification functionality */} {notificationState.sendStatus && ( -
+
Send notification result: {notificationState.sendStatus}
)} - {/* Share URL copying */} - {/* Haptic feedback controls */} -
-
); -} \ No newline at end of file +}