mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-16 08:08:56 -05:00
NeynarAuthNutton
This commit is contained in:
parent
56517dc41a
commit
1fe4d30134
180
package-lock.json
generated
180
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"inquirer": "^12.4.3",
|
||||
"siwe": "^3.0.0",
|
||||
"viem": "^2.23.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
10
src/app/api/auth/nonce/route.ts
Normal file
10
src/app/api/auth/nonce/route.ts
Normal file
@ -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);
|
||||
}
|
||||
39
src/app/api/auth/signer/route.ts
Normal file
39
src/app/api/auth/signer/route.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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 (
|
||||
<SessionProvider session={session}>
|
||||
<WagmiProvider>
|
||||
<MiniAppProvider analyticsEnabled={ANALYTICS_ENABLED} backButtonEnabled={true}>
|
||||
<MiniAppProvider
|
||||
analyticsEnabled={ANALYTICS_ENABLED}
|
||||
backButtonEnabled={true}
|
||||
>
|
||||
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||
{children}
|
||||
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
|
||||
</SafeFarcasterSolanaProvider>
|
||||
</MiniAppProvider>
|
||||
</WagmiProvider>
|
||||
|
||||
526
src/components/ui/NeynarAuthButton.tsx
Normal file
526
src/components/ui/NeynarAuthButton.tsx
Normal file
@ -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<T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>,
|
||||
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 (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-xl p-6 max-w-sm mx-4 shadow-2xl border border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{isError ? 'Error' : 'Sign in with Farcaster'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='w-6 h-6'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<div className='text-center'>
|
||||
<div className='text-red-600 dark:text-red-400 mb-4'>
|
||||
{error?.message || 'Unknown error, please try again.'}
|
||||
</div>
|
||||
<button onClick={onClose} className='btn btn-primary'>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center'>
|
||||
<p className='text-gray-600 dark:text-gray-400 mb-6'>
|
||||
To sign in with Farcaster, scan the code below with your
|
||||
phone's camera.
|
||||
</p>
|
||||
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<div className='p-4 bg-white rounded-lg'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
|
||||
url
|
||||
)}`}
|
||||
alt='QR Code for Farcaster sign in'
|
||||
className='w-48 h-48'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
className='btn btn-outline flex items-center justify-center gap-2 w-full'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={12}
|
||||
height={18}
|
||||
fill='none'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
fillRule='evenodd'
|
||||
d='M0 3a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3Zm4-1.5v.75c0 .414.336.75.75.75h2.5A.75.75 0 0 0 8 2.25V1.5h1A1.5 1.5 0 0 1 10.5 3v12A1.5 1.5 0 0 1 9 16.5H3A1.5 1.5 0 0 1 1.5 15V3A1.5 1.5 0 0 1 3 1.5h1Z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
I'm using my phone →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile Button Component
|
||||
function ProfileButton({
|
||||
userData,
|
||||
onSignOut,
|
||||
}: {
|
||||
userData?: { fid?: number; pfpUrl?: string; username?: string };
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useDetectClickOutside(ref, () => setShowDropdown(false));
|
||||
|
||||
const name = userData?.username ?? `!${userData?.fid}`;
|
||||
const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png';
|
||||
|
||||
return (
|
||||
<div className='relative' ref={ref}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2 min-w-0 rounded-lg',
|
||||
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||
'focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={pfpUrl}
|
||||
alt='Profile'
|
||||
className='w-6 h-6 rounded-full object-cover flex-shrink-0'
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://farcaster.xyz/avatar.png';
|
||||
}}
|
||||
/>
|
||||
<span className='text-sm font-medium truncate max-w-[120px]'>
|
||||
{name ? name : '...'}
|
||||
</span>
|
||||
<svg
|
||||
className={cn(
|
||||
'w-4 h-4 transition-transform flex-shrink-0',
|
||||
showDropdown && 'rotate-180'
|
||||
)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M19 9l-7 7-7-7'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className='absolute top-full right-0 left-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50'>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSignOut();
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
className='w-full px-4 py-3 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 rounded-lg transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={1.5}
|
||||
d='M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1'
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main Custom SignInButton Component
|
||||
export function NeynarAuthButton() {
|
||||
const [nonce, setNonce] = useState<string | null>(null);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(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 (
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='flex items-center gap-3 px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg'>
|
||||
<div className='spinner w-4 h-4' />
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{authenticated ? (
|
||||
<ProfileButton userData={userData} onSignOut={handleSignOut} />
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSignIn}
|
||||
disabled={!url}
|
||||
className={cn(
|
||||
'btn btn-primary flex items-center gap-3',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'transform transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]',
|
||||
!url && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{!url ? (
|
||||
<>
|
||||
<div className='spinner-primary w-5 h-5' />
|
||||
<span>Initializing...</span>
|
||||
</>
|
||||
) : (
|
||||
/* The above code is a conditional rendering block in a TypeScript React component. It checks
|
||||
if the environment variable `NODE_ENV` is set to "development", and if so, it renders a
|
||||
debug info section displaying various boolean values related to the application state.
|
||||
This debug info includes values such as `authenticated`, `isSuccess`, `validSignature`,
|
||||
`hasData`, `isPolling`, `isError`, `hasStoredAuth`, `hasUrl`, and `hasChannelToken`. These
|
||||
values are displayed in a formatted JSON string within a `<pre>` element for easy
|
||||
readability during development. */
|
||||
<>
|
||||
<span>Sign in with Neynar</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* QR Code Dialog for desktop */}
|
||||
{url && (
|
||||
<QRCodeDialog
|
||||
open={showDialog && !isMobile()}
|
||||
onClose={() => setShowDialog(false)}
|
||||
url={url}
|
||||
isError={isError}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Debug panel (optional - can be removed in production) */}
|
||||
{/* {process.env.NODE_ENV === "development" && (
|
||||
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg text-xs font-mono">
|
||||
<div className="font-semibold mb-2">Debug Info:</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
{JSON.stringify(
|
||||
{
|
||||
authenticated,
|
||||
isSuccess,
|
||||
validSignature,
|
||||
hasData: !!data,
|
||||
isPolling,
|
||||
isError,
|
||||
hasStoredAuth: !!storedAuth?.isAuthenticated,
|
||||
hasUrl: !!url,
|
||||
hasChannelToken: !!channelToken,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
* <ActionsTab />
|
||||
@ -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<Haptics.ImpactOccurredType>('medium');
|
||||
const [selectedHapticIntensity, setSelectedHapticIntensity] =
|
||||
useState<Haptics.ImpactOccurredType>('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 (
|
||||
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
|
||||
<div className='space-y-3 px-6 w-full max-w-md mx-auto'>
|
||||
{/* Share functionality */}
|
||||
<ShareButton
|
||||
buttonText="Share Mini App"
|
||||
<ShareButton
|
||||
buttonText='Share Mini App'
|
||||
cast={{
|
||||
text: "Check out this awesome frame @1 @2 @3! 🚀🪐",
|
||||
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
|
||||
bestFriends: true,
|
||||
embeds: [`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`]
|
||||
embeds: [
|
||||
`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`,
|
||||
],
|
||||
}}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
{/* Authentication */}
|
||||
<SignIn />
|
||||
|
||||
{/* Mini app actions */}
|
||||
<Button onClick={() => actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link</Button>
|
||||
{/* Neynar Authentication */}
|
||||
<NeynarAuthButton />
|
||||
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
|
||||
{/* Mini app actions */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
}
|
||||
className='w-full'
|
||||
>
|
||||
Open Link
|
||||
</Button>
|
||||
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'>
|
||||
Add Mini App to Client
|
||||
</Button>
|
||||
|
||||
{/* Notification functionality */}
|
||||
{notificationState.sendStatus && (
|
||||
<div className="text-sm w-full">
|
||||
<div className='text-sm w-full'>
|
||||
Send notification result: {notificationState.sendStatus}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={sendFarcasterNotification} disabled={!notificationDetails} className="w-full">
|
||||
<Button
|
||||
onClick={sendFarcasterNotification}
|
||||
disabled={!notificationDetails}
|
||||
className='w-full'
|
||||
>
|
||||
Send notification
|
||||
</Button>
|
||||
|
||||
{/* Share URL copying */}
|
||||
<Button
|
||||
<Button
|
||||
onClick={copyUserShareUrl}
|
||||
disabled={!context?.user?.fid}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
>
|
||||
{notificationState.shareUrlCopied ? "Copied!" : "Copy share URL"}
|
||||
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
|
||||
</Button>
|
||||
|
||||
{/* Haptic feedback controls */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Haptic Intensity
|
||||
</label>
|
||||
<select
|
||||
value={selectedHapticIntensity}
|
||||
onChange={(e) => 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"
|
||||
onChange={(e) =>
|
||||
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'
|
||||
>
|
||||
<option value={'light'}>Light</option>
|
||||
<option value={'medium'}>Medium</option>
|
||||
@ -170,13 +200,10 @@ export function ActionsTab() {
|
||||
<option value={'soft'}>Soft</option>
|
||||
<option value={'rigid'}>Rigid</option>
|
||||
</select>
|
||||
<Button
|
||||
onClick={triggerHapticFeedback}
|
||||
className="w-full"
|
||||
>
|
||||
<Button onClick={triggerHapticFeedback} className='w-full'>
|
||||
Trigger Haptic Feedback
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user