diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ddc32ca --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to npm šŸš€ + +on: + push: + branches: + - main + paths: + - package.json + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Publish to npm + run: npm publish --access public \ No newline at end of file diff --git a/bin/index.js b/bin/index.js index a2aaad8..5c3b053 100755 --- a/bin/index.js +++ b/bin/index.js @@ -6,6 +6,7 @@ import { init } from './init.js'; const args = process.argv.slice(2); let projectName = null; let autoAcceptDefaults = false; +let apiKey = null; // Check for -y flag const yIndex = args.indexOf('-y'); @@ -14,18 +15,48 @@ if (yIndex !== -1) { args.splice(yIndex, 1); // Remove -y from args } -// If there's a remaining argument, it's the project name -if (args.length > 0) { - projectName = args[0]; -} + // Parse other arguments + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-p' || arg === '--project') { + if (i + 1 < args.length) { + projectName = args[i + 1]; + if (projectName.startsWith('-')) { + console.error('Error: Project name cannot start with a dash (-)'); + process.exit(1); + } + args.splice(i, 2); // Remove both the flag and its value + i--; // Adjust index since we removed 2 elements + } else { + console.error('Error: -p/--project requires a project name'); + process.exit(1); + } + } else if (arg === '-k' || arg === '--api-key') { + if (i + 1 < args.length) { + apiKey = args[i + 1]; + if (apiKey.startsWith('-')) { + console.error('Error: API key cannot start with a dash (-)'); + process.exit(1); + } + args.splice(i, 2); // Remove both the flag and its value + i--; // Adjust index since we removed 2 elements + } else { + console.error('Error: -k/--api-key requires an API key'); + process.exit(1); + } + } + } -// If -y is used without project name, we still need to ask for project name + + +// Validate that if -y is used, a project name must be provided if (autoAcceptDefaults && !projectName) { - // We'll handle this case in the init function by asking only for project name - autoAcceptDefaults = false; + console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.'); + process.exit(1); } -init(projectName, autoAcceptDefaults).catch((err) => { +init(projectName, autoAcceptDefaults, apiKey).catch((err) => { console.error('Error:', err); process.exit(1); }); diff --git a/bin/init.js b/bin/init.js index 0ab7101..59027ac 100644 --- a/bin/init.js +++ b/bin/init.js @@ -12,7 +12,9 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git'; -const SCRIPT_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; +const SCRIPT_VERSION = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') +).version; // ANSI color codes const purple = '\x1b[35m'; @@ -48,8 +50,8 @@ async function queryNeynarApp(apiKey) { `https://api.neynar.com/portal/app_by_api_key?starter_kit=true`, { headers: { - 'x-api-key': apiKey - } + 'x-api-key': apiKey, + }, } ); const data = await response.json(); @@ -61,7 +63,7 @@ async function queryNeynarApp(apiKey) { } // Export the main CLI function for programmatic use -export async function init(projectName = null, autoAcceptDefaults = false) { +export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) { printWelcomeMessage(); // Ask about Neynar usage @@ -80,16 +82,17 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useNeynar', - message: `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` + - 'Benefits of using Neynar in your mini app:\n' + - '- Pre-configured webhook handling (no setup required)\n' + - '- Automatic mini app analytics in your dev portal\n' + - '- Send manual notifications from dev.neynar.com\n' + - '- Built-in rate limiting and error handling\n\n' + - `${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` + - 'Would you like to use Neynar in your mini app?', - default: true - } + message: + `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` + + 'Benefits of using Neynar in your mini app:\n' + + '- Pre-configured webhook handling (no setup required)\n' + + '- Automatic mini app analytics in your dev portal\n' + + '- Send manual notifications from dev.neynar.com\n' + + '- Built-in rate limiting and error handling\n\n' + + `${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` + + 'Would you like to use Neynar in your mini app?', + default: true, + }, ]); } @@ -98,44 +101,59 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } - console.log('\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'); - - let neynarKeyAnswer; - if (autoAcceptDefaults) { - neynarKeyAnswer = { neynarApiKey: null }; + // Use provided API key if available, otherwise prompt for it + if (apiKey) { + neynarApiKey = apiKey; } else { - neynarKeyAnswer = await inquirer.prompt([ - { - type: 'password', - name: 'neynarApiKey', - message: 'Enter your Neynar API key (or press enter to skip):', - default: null - } - ]); - } + if (!autoAcceptDefaults) { + console.log( + '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n' + ); + } - if (neynarKeyAnswer.neynarApiKey) { - neynarApiKey = neynarKeyAnswer.neynarApiKey; - } else { - let useDemoKey; + let neynarKeyAnswer; if (autoAcceptDefaults) { - useDemoKey = { useDemo: true }; + neynarKeyAnswer = { neynarApiKey: null }; } else { - useDemoKey = await inquirer.prompt([ + neynarKeyAnswer = await inquirer.prompt([ { - type: 'confirm', - name: 'useDemo', - message: 'Would you like to try the demo Neynar API key?', - default: true - } + type: 'password', + name: 'neynarApiKey', + message: 'Enter your Neynar API key (or press enter to skip):', + default: null, + }, ]); } - if (useDemoKey.useDemo) { - console.warn('\nāš ļø Note: the demo key is for development purposes only and is aggressively rate limited.'); - console.log('For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.'); - console.log(`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`); - neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; + if (neynarKeyAnswer.neynarApiKey) { + neynarApiKey = neynarKeyAnswer.neynarApiKey; + } else { + let useDemoKey; + if (autoAcceptDefaults) { + useDemoKey = { useDemo: true }; + } else { + useDemoKey = await inquirer.prompt([ + { + type: 'confirm', + name: 'useDemo', + message: 'Would you like to try the demo Neynar API key?', + default: true, + }, + ]); + } + + if (useDemoKey.useDemo) { + console.warn( + '\nāš ļø Note: the demo key is for development purposes only and is aggressively rate limited.' + ); + console.log( + 'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.' + ); + console.log( + `\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}` + ); + neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; + } } } @@ -144,14 +162,16 @@ export async function init(projectName = null, autoAcceptDefaults = false) { useNeynar = false; break; } - console.log('\nāš ļø No valid API key provided. Would you like to try again?'); + console.log( + '\nāš ļø No valid API key provided. Would you like to try again?' + ); const { retry } = await inquirer.prompt([ { type: 'confirm', name: 'retry', message: 'Try configuring Neynar again?', - default: true - } + default: true, + }, ]); if (!retry) { useNeynar = false; @@ -176,9 +196,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'retry', - message: 'āš ļø Could not find a client ID for this API key. Would you like to try configuring Neynar again?', - default: true - } + message: + 'āš ļø Could not find a client ID for this API key. Would you like to try configuring Neynar again?', + default: true, + }, ]); if (!retry) { useNeynar = false; @@ -191,7 +212,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) { break; } - const defaultMiniAppName = (neynarAppName && !neynarAppName.toLowerCase().includes('demo')) ? neynarAppName : undefined; + const defaultMiniAppName = + neynarAppName && !neynarAppName.toLowerCase().includes('demo') + ? neynarAppName + : undefined; let answers; if (autoAcceptDefaults) { @@ -203,7 +227,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) { buttonText: 'Launch Mini App', useWallet: true, useTunnel: true, - enableAnalytics: true + enableAnalytics: true, + seedPhrase: null, + sponsorSigner: false, }; } else { // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options @@ -218,21 +244,22 @@ export async function init(projectName = null, autoAcceptDefaults = false) { return 'Project name cannot be empty'; } return true; - } - } + }, + }, ]); - + answers = await inquirer.prompt([ { type: 'input', name: 'description', message: 'Give a one-line description of your mini app (optional):', - default: 'A Farcaster mini app created with Neynar' + default: 'A Farcaster mini app created with Neynar', }, { type: 'list', name: 'primaryCategory', - message: 'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:', + message: + 'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:', choices: [ new inquirer.Separator(), { name: 'Skip (not recommended)', value: null }, @@ -249,23 +276,24 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { name: 'Education', value: 'education' }, { name: 'Developer Tools', value: 'developer-tools' }, { name: 'Entertainment', value: 'entertainment' }, - { name: 'Art & Creativity', value: 'art-creativity' } + { name: 'Art & Creativity', value: 'art-creativity' }, ], - default: null + default: null, }, { type: 'input', name: 'tags', - message: 'Enter tags for your mini app (separate with spaces or commas, optional):', + message: + 'Enter tags for your mini app (separate with spaces or commas, optional):', default: '', filter: (input) => { if (!input.trim()) return []; // Split by both spaces and commas, trim whitespace, and filter out empty strings return input .split(/[,\s]+/) - .map(tag => tag.trim()) - .filter(tag => tag.length > 0); - } + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + }, }, { type: 'input', @@ -277,8 +305,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { return 'Button text cannot be empty'; } return true; - } - } + }, + }, ]); // Merge project name from the first prompt @@ -289,7 +317,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useWallet', - message: 'Would you like to include wallet and transaction tooling in your mini app?\n' + + message: + 'Would you like to include wallet and transaction tooling in your mini app?\n' + 'This includes:\n' + '- EVM wallet connection\n' + '- Transaction signing\n' + @@ -297,8 +326,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) { '- Chain switching\n' + '- Solana support\n\n' + 'Include wallet and transaction features?', - default: true - } + default: true, + }, ]); answers.useWallet = walletAnswer.useWallet; @@ -307,22 +336,61 @@ export async function init(projectName = null, autoAcceptDefaults = false) { { type: 'confirm', name: 'useTunnel', - message: 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' + + message: + 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' + `āš ļø ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` + 'Configure a tunnel for mobile testing and/or Warpcast developer tools?', - default: true - } + default: true, + }, ]); answers.useTunnel = hostingAnswer.useTunnel; + // Ask about Neynar Sponsored Signers / SIWN + const sponsoredSignerAnswer = await inquirer.prompt([ + { + type: 'confirm', + name: 'useSponsoredSigner', + message: + 'Would you like to use Neynar Sponsored Signers and/or Sign In With Neynar (SIWN)?\n' + + 'This enables the simplest, most secure, and most user-friendly Farcaster authentication for your app.\n\n' + + 'Benefits of using Neynar Sponsored Signers/SIWN:\n' + + '- No auth buildout or signer management required for developers\n' + + '- Cost-effective for users (no gas for signers)\n' + + '- Users can revoke signers at any time\n' + + '- Plug-and-play for web and React Native\n' + + '- Recommended for most developers\n' + + '\nāš ļø A seed phrase is required for this option.\n', + default: false, + }, + ]); + answers.useSponsoredSigner = sponsoredSignerAnswer.useSponsoredSigner; + + if (answers.useSponsoredSigner) { + const { seedPhrase } = await inquirer.prompt([ + { + type: 'password', + name: 'seedPhrase', + message: 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):', + validate: (input) => { + if (!input || input.trim().split(' ').length < 12) { + return 'Seed phrase must be at least 12 words'; + } + return true; + }, + }, + ]); + answers.seedPhrase = seedPhrase; + } + // Ask about analytics opt-out const analyticsAnswer = await inquirer.prompt([ { type: 'confirm', name: 'enableAnalytics', - message: 'Would you like to help improve Neynar products by sharing usage data from your mini app?', - default: true - } + message: + 'Would you like to help improve Neynar products by sharing usage data from your mini app?', + default: true, + }, ]); answers.enableAnalytics = analyticsAnswer.enableAnalytics; } @@ -337,19 +405,19 @@ export async function init(projectName = null, autoAcceptDefaults = false) { try { console.log(`\nCloning repository from ${REPO_URL}...`); // Use separate commands for better cross-platform compatibility - execSync(`git clone ${REPO_URL} "${projectPath}"`, { + execSync(`git clone ${REPO_URL} "${projectPath}"`, { stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('git fetch origin main', { - cwd: projectPath, + execSync('git fetch origin main', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('git reset --hard origin/main', { - cwd: projectPath, + execSync('git reset --hard origin/main', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); } catch (error) { console.error('\nāŒ Error: Failed to create project directory.'); @@ -386,31 +454,32 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Add dependencies packageJson.dependencies = { - "@farcaster/auth-client": ">=0.3.0 <1.0.0", - "@farcaster/auth-kit": ">=0.6.0 <1.0.0", - "@farcaster/miniapp-node": ">=0.1.5 <1.0.0", - "@farcaster/miniapp-sdk": ">=0.1.6 <1.0.0", - "@farcaster/miniapp-wagmi-connector": "^1.0.0", - "@farcaster/mini-app-solana": ">=0.0.17 <1.0.0", - "@neynar/react": "^1.2.5", - "@radix-ui/react-label": "^2.1.1", - "@solana/wallet-adapter-react": "^0.15.38", - "@tanstack/react-query": "^5.61.0", - "@upstash/redis": "^1.34.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dotenv": "^16.4.7", - "lucide-react": "^0.469.0", - "mipd": "^0.0.7", - "next": "^15", - "next-auth": "^4.24.11", - "react": "^19", - "react-dom": "^19", - "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7", - "viem": "^2.23.6", - "wagmi": "^2.14.12", - "zod": "^3.24.2" + '@farcaster/auth-client': '>=0.3.0 <1.0.0', + '@farcaster/auth-kit': '>=0.6.0 <1.0.0', + '@farcaster/miniapp-node': '>=0.1.5 <1.0.0', + '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', + '@farcaster/miniapp-wagmi-connector': '^1.0.0', + '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', + '@neynar/react': '^1.2.5', + '@radix-ui/react-label': '^2.1.1', + '@solana/wallet-adapter-react': '^0.15.38', + '@tanstack/react-query': '^5.61.0', + '@upstash/redis': '^1.34.3', + 'class-variance-authority': '^0.7.1', + clsx: '^2.1.1', + dotenv: '^16.4.7', + 'lucide-react': '^0.469.0', + mipd: '^0.0.7', + next: '^15', + 'next-auth': '^4.24.11', + react: '^19', + 'react-dom': '^19', + 'tailwind-merge': '^2.6.0', + 'tailwindcss-animate': '^1.0.7', + viem: '^2.23.6', + wagmi: '^2.14.12', + zod: '^3.24.2', + siwe: '^3.0.0', }; packageJson.devDependencies = { @@ -452,35 +521,46 @@ export async function init(projectName = null, autoAcceptDefaults = false) { const constantsPath = path.join(projectPath, 'src', 'lib', 'constants.ts'); if (fs.existsSync(constantsPath)) { let constantsContent = fs.readFileSync(constantsPath, 'utf8'); - + // Helper function to escape single quotes in strings const escapeString = (str) => str.replace(/'/g, "\\'"); - + // Helper function to safely replace constants with validation const safeReplace = (content, pattern, replacement, constantName) => { const match = content.match(pattern); if (!match) { - console.log(`āš ļø Warning: Could not update ${constantName} in constants.ts. Pattern not found.`); + console.log( + `āš ļø Warning: Could not update ${constantName} in constants.ts. Pattern not found.` + ); console.log(`Pattern: ${pattern}`); - console.log(`Expected to match in: ${content.split('\n').find(line => line.includes(constantName)) || 'Not found'}`); + console.log( + `Expected to match in: ${ + content.split('\n').find((line) => line.includes(constantName)) || + 'Not found' + }` + ); } else { const newContent = content.replace(pattern, replacement); return newContent; } return content; }; - + // Regex patterns that match whole lines with export const const patterns = { APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m, - APP_DESCRIPTION: /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, - APP_PRIMARY_CATEGORY: /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, + APP_DESCRIPTION: + /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, + APP_PRIMARY_CATEGORY: + /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, APP_TAGS: /^export const APP_TAGS\s*=\s*\[[^\]]*\];$/m, - APP_BUTTON_TEXT: /^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m, + APP_BUTTON_TEXT: + /^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m, USE_WALLET: /^export const USE_WALLET\s*=\s*(true|false);$/m, - ANALYTICS_ENABLED: /^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m + ANALYTICS_ENABLED: + /^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m, }; - + // Update APP_NAME constantsContent = safeReplace( constantsContent, @@ -488,42 +568,49 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const APP_NAME = '${escapeString(answers.projectName)}';`, 'APP_NAME' ); - + // Update APP_DESCRIPTION constantsContent = safeReplace( constantsContent, patterns.APP_DESCRIPTION, - `export const APP_DESCRIPTION = '${escapeString(answers.description)}';`, + `export const APP_DESCRIPTION = '${escapeString( + answers.description + )}';`, 'APP_DESCRIPTION' ); - + // Update APP_PRIMARY_CATEGORY (always update, null becomes empty string) constantsContent = safeReplace( constantsContent, patterns.APP_PRIMARY_CATEGORY, - `export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory || '')}';`, + `export const APP_PRIMARY_CATEGORY = '${escapeString( + answers.primaryCategory || '' + )}';`, 'APP_PRIMARY_CATEGORY' ); - + // Update APP_TAGS - const tagsString = answers.tags.length > 0 - ? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']` - : "['neynar', 'starter-kit', 'demo']"; + const tagsString = + answers.tags.length > 0 + ? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']` + : "['neynar', 'starter-kit', 'demo']"; constantsContent = safeReplace( constantsContent, patterns.APP_TAGS, `export const APP_TAGS = ${tagsString};`, 'APP_TAGS' ); - + // Update APP_BUTTON_TEXT (always update, use answers value) constantsContent = safeReplace( constantsContent, patterns.APP_BUTTON_TEXT, - `export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText || '')}';`, + `export const APP_BUTTON_TEXT = '${escapeString( + answers.buttonText || '' + )}';`, 'APP_BUTTON_TEXT' ); - + // Update USE_WALLET constantsContent = safeReplace( constantsContent, @@ -531,7 +618,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const USE_WALLET = ${answers.useWallet};`, 'USE_WALLET' ); - + // Update ANALYTICS_ENABLED constantsContent = safeReplace( constantsContent, @@ -539,24 +626,34 @@ export async function init(projectName = null, autoAcceptDefaults = false) { `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, 'ANALYTICS_ENABLED' ); - + fs.writeFileSync(constantsPath, constantsContent); } else { console.log('āš ļø constants.ts not found, skipping constants update'); } - fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`); + fs.appendFileSync( + envPath, + `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` + ); if (useNeynar && neynarApiKey && neynarClientId) { fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); } else if (useNeynar) { - console.log('\nāš ļø Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID'); + console.log( + '\nāš ļø Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID' + ); + } + if (answers.seedPhrase) { + fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); } fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); - + fs.unlinkSync(envExamplePath); } else { - console.log('\n.env.example does not exist, skipping copy and remove operations'); + console.log( + '\n.env.example does not exist, skipping copy and remove operations' + ); } // Update README @@ -564,7 +661,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) { const readmePath = path.join(projectPath, 'README.md'); const prependText = `\n\n`; if (fs.existsSync(readmePath)) { - const originalReadmeContent = fs.readFileSync(readmePath, { encoding: 'utf8' }); + const originalReadmeContent = fs.readFileSync(readmePath, { + encoding: 'utf8', + }); const updatedReadmeContent = prependText + originalReadmeContent; fs.writeFileSync(readmePath, updatedReadmeContent); } else { @@ -574,15 +673,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) { // Install dependencies console.log('\nInstalling dependencies...'); - execSync('npm cache clean --force', { - cwd: projectPath, + execSync('npm cache clean --force', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); - execSync('npm install', { - cwd: projectPath, + execSync('npm install', { + cwd: projectPath, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', }); // Remove the bin directory @@ -596,12 +695,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) { console.log('\nInitializing git repository...'); execSync('git init', { cwd: projectPath }); execSync('git add .', { cwd: projectPath }); - execSync('git commit -m "initial commit from @neynar/create-farcaster-mini-app"', { cwd: projectPath }); + execSync( + 'git commit -m "initial commit from @neynar/create-farcaster-mini-app"', + { cwd: projectPath } + ); // Calculate border length based on message length const message = `✨🪐 Successfully created mini app ${finalProjectName} with git and dependencies installed! 🪐✨`; const borderLength = message.length; - const borderStars = '✨'.repeat((borderLength / 2) + 1); + const borderStars = '✨'.repeat(borderLength / 2 + 1); console.log(`\n${borderStars}`); console.log(`${message}`); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 0578e91..e64d4d2 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -6,9 +6,7 @@ import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; import dotenv from 'dotenv'; import crypto from 'crypto'; -// Add fallback type for Vercel if type is missing -// @ts-ignore -import { Vercel as VercelSDKType } from '@vercel/sdk'; +import { Vercel } from '@vercel/sdk'; import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -24,33 +22,37 @@ async function loadEnvLocal(): Promise { { type: 'confirm', name: 'loadLocal', - message: 'Found .env.local - would you like to load its values in addition to .env values? (except for SEED_PHRASE, values will be written to .env)', - default: true - } + message: + 'Found .env.local - would you like to load its values in addition to .env values?', + default: true, + }, ]); if (loadLocal) { console.log('Loading values from .env.local...'); const localEnv = dotenv.parse(fs.readFileSync('.env.local')); - + const allowedVars = [ 'SEED_PHRASE', 'NEYNAR_API_KEY', - 'NEYNAR_CLIENT_ID' + 'NEYNAR_CLIENT_ID', + 'SPONSOR_SIGNER', ]; - - const envContent = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') + '\n' : ''; + + const envContent = fs.existsSync('.env') + ? fs.readFileSync('.env', 'utf8') + '\n' + : ''; let newEnvContent = envContent; - + for (const [key, value] of Object.entries(localEnv)) { if (allowedVars.includes(key)) { process.env[key] = value; - if (key !== 'SEED_PHRASE' && !envContent.includes(`${key}=`)) { + if (!envContent.includes(`${key}=`)) { newEnvContent += `${key}="${value}"\n`; } } } - + fs.writeFileSync('.env', newEnvContent); console.log('āœ… Values from .env.local have been written to .env'); } @@ -63,7 +65,7 @@ async function loadEnvLocal(): Promise { async function checkRequiredEnvVars(): Promise { console.log('\nšŸ“ Checking environment variables...'); console.log('Loading values from .env...'); - + await loadEnvLocal(); const requiredVars = [ @@ -81,10 +83,12 @@ async function checkRequiredEnvVars(): Promise { } ]; - const missingVars = requiredVars.filter(varConfig => !process.env[varConfig.name]); - + const missingVars = requiredVars.filter( + (varConfig) => !process.env[varConfig.name] + ); + if (missingVars.length > 0) { - console.log('\nāš ļø Some required information is missing. Let\'s set it up:'); + console.log("\nāš ļø Some required information is missing. Let's set it up:"); for (const varConfig of missingVars) { const { value } = await inquirer.prompt([ { @@ -92,27 +96,69 @@ async function checkRequiredEnvVars(): Promise { name: 'value', message: varConfig.message, default: varConfig.default, - validate: varConfig.validate - } + validate: varConfig.validate, + }, ]); - + process.env[varConfig.name] = value; - - const envContent = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : ''; - + + const envContent = fs.existsSync('.env') + ? fs.readFileSync('.env', 'utf8') + : ''; + if (!envContent.includes(`${varConfig.name}=`)) { const newLine = envContent ? '\n' : ''; - fs.appendFileSync('.env', `${newLine}${varConfig.name}="${value.trim()}"`); + fs.appendFileSync( + '.env', + `${newLine}${varConfig.name}="${value.trim()}"` + ); } + + // Ask about sponsor signer if SEED_PHRASE is provided + if (!process.env.SPONSOR_SIGNER) { + const { sponsorSigner } = await inquirer.prompt([ + { + type: 'confirm', + name: 'sponsorSigner', + message: + 'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' + + 'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' + + 'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers', + default: false, + }, + ]); + + process.env.SPONSOR_SIGNER = sponsorSigner.toString(); + + if (storeSeedPhrase) { + fs.appendFileSync( + '.env.local', + `\nSPONSOR_SIGNER="${sponsorSigner}"` + ); + console.log('āœ… Sponsor signer preference stored in .env.local'); + } + } + } + } + + // Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't + if ( + process.env.SEED_PHRASE && + !process.env.SPONSOR_SIGNER && + fs.existsSync('.env.local') + ) { + const localEnv = dotenv.parse(fs.readFileSync('.env.local')); + if (localEnv.SPONSOR_SIGNER) { + process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER; } } } async function getGitRemote(): Promise { try { - const remoteUrl = execSync('git remote get-url origin', { + const remoteUrl = execSync('git remote get-url origin', { cwd: projectRoot, - encoding: 'utf8' + encoding: 'utf8', }).trim(); return remoteUrl; } catch (error: unknown) { @@ -157,19 +203,19 @@ async function getVercelToken(): Promise { console.warn('Could not read Vercel token from config file'); } } - + // Try environment variable if (process.env.VERCEL_TOKEN) { return process.env.VERCEL_TOKEN; } - + // Try to extract from vercel whoami try { - const whoamiOutput = execSync('vercel whoami', { + const whoamiOutput = execSync('vercel whoami', { encoding: 'utf8', - stdio: 'pipe' + stdio: 'pipe', }); - + // If we can get whoami, we're logged in, but we need the actual token // The token isn't directly exposed, so we'll need to use CLI for some operations console.log('āœ… Verified Vercel CLI authentication'); @@ -192,10 +238,12 @@ async function loginToVercel(): Promise { console.log('2. Authorize GitHub access'); console.log('3. Complete the Vercel account setup in your browser'); console.log('4. Return here once your Vercel account is created\n'); - console.log('\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account'); - + console.log( + '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account' + ); + const child = spawn('vercel', ['login'], { - stdio: 'inherit' + stdio: 'inherit', }); await new Promise((resolve, reject) => { @@ -205,8 +253,10 @@ async function loginToVercel(): Promise { }); console.log('\nšŸ“± Waiting for login to complete...'); - console.log('If you\'re creating a new account, please complete the Vercel account setup in your browser first.'); - + console.log( + "If you're creating a new account, please complete the Vercel account setup in your browser first." + ); + for (let i = 0; i < 150; i++) { try { execSync('vercel whoami', { stdio: 'ignore' }); @@ -216,7 +266,7 @@ async function loginToVercel(): Promise { if (error instanceof Error && error.message.includes('Account not found')) { console.log('ā„¹ļø Waiting for Vercel account setup to complete...'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); } } @@ -227,7 +277,7 @@ async function loginToVercel(): Promise { return false; } -async function setVercelEnvVarSDK(vercelClient: VercelSDKType, projectId: string, key: string, value: string | object): Promise { +async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: string, value: string | object): Promise { try { let processedValue: string; if (typeof value === 'object') { @@ -238,7 +288,7 @@ async function setVercelEnvVarSDK(vercelClient: VercelSDKType, projectId: string // Get existing environment variables const existingVars = await vercelClient.projects.getEnvironmentVariables({ - idOrName: projectId + idOrName: projectId, }); const existingVar = existingVars.envs?.find((env: any) => @@ -252,8 +302,8 @@ async function setVercelEnvVarSDK(vercelClient: VercelSDKType, projectId: string id: existingVar.id, requestBody: { value: processedValue, - target: ['production'] - } + target: ['production'], + }, }); console.log(`āœ… Updated environment variable: ${key}`); } else { @@ -264,12 +314,12 @@ async function setVercelEnvVarSDK(vercelClient: VercelSDKType, projectId: string key: key, value: processedValue, type: 'encrypted', - target: ['production'] - } + target: ['production'], + }, }); console.log(`āœ… Created environment variable: ${key}`); } - + return true; } catch (error: unknown) { if (error instanceof Error) { @@ -287,7 +337,7 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo execSync(`vercel env rm ${key} production -y`, { cwd: projectRoot, stdio: 'ignore', - env: process.env + env: process.env, }); } catch (error: unknown) { // Ignore errors from removal @@ -334,55 +384,57 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo } } -async function setEnvironmentVariables(vercelClient: VercelSDKType | null, projectId: string | null, envVars: Record, projectRoot: string): Promise> { +async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: string | null, envVars: Record, projectRoot: string): Promise> { console.log('\nšŸ“ Setting up environment variables...'); const results: Array<{ key: string; success: boolean }> = []; for (const [key, value] of Object.entries(envVars)) { if (!value) continue; - + let success = false; - + // Try SDK approach first if we have a Vercel client if (vercelClient && projectId) { success = await setVercelEnvVarSDK(vercelClient, projectId, key, value); } - + // Fallback to CLI approach if (!success) { success = await setVercelEnvVarCLI(key, value, projectRoot); } - + results.push({ key, success }); } - + // Report results - const failed = results.filter(r => !r.success); + const failed = results.filter((r) => !r.success); if (failed.length > 0) { console.warn(`\nāš ļø Failed to set ${failed.length} environment variables:`); - failed.forEach(r => console.warn(` - ${r.key}`)); - console.warn('\nYou may need to set these manually in the Vercel dashboard.'); + failed.forEach((r) => console.warn(` - ${r.key}`)); + console.warn( + '\nYou may need to set these manually in the Vercel dashboard.' + ); } - + return results; } -async function waitForDeployment(vercelClient: VercelSDKType | null, projectId: string, maxWaitTime = 300000): Promise { // 5 minutes +async function waitForDeployment(vercelClient: Vercel | null, projectId: string, maxWaitTime = 300000): Promise { // 5 minutes console.log('\nā³ Waiting for deployment to complete...'); const startTime = Date.now(); - + while (Date.now() - startTime < maxWaitTime) { try { const deployments = await vercelClient?.deployments.list({ projectId: projectId, - limit: 1 + limit: 1, }); if (deployments?.deployments?.[0]) { const deployment = deployments.deployments[0]; console.log(`šŸ“Š Deployment status: ${deployment.state}`); - + if (deployment.state === 'READY') { console.log('āœ… Deployment completed successfully!'); return deployment; @@ -391,12 +443,12 @@ async function waitForDeployment(vercelClient: VercelSDKType | null, projectId: } else if (deployment.state === 'CANCELED') { throw new Error('Deployment was canceled'); } - + // Still building, wait and check again - await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds + await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds } else { console.log('ā³ No deployment found yet, waiting...'); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise((resolve) => setTimeout(resolve, 5000)); } } catch (error: unknown) { if (error instanceof Error) { @@ -406,29 +458,40 @@ async function waitForDeployment(vercelClient: VercelSDKType | null, projectId: throw error; } } - + throw new Error('Deployment timed out after 5 minutes'); } async function deployToVercel(useGitHub = false): Promise { try { console.log('\nšŸš€ Deploying to Vercel...'); - + // Ensure vercel.json exists const vercelConfigPath = path.join(projectRoot, 'vercel.json'); if (!fs.existsSync(vercelConfigPath)) { console.log('šŸ“ Creating vercel.json configuration...'); - fs.writeFileSync(vercelConfigPath, JSON.stringify({ - buildCommand: "next build", - framework: "nextjs" - }, null, 2)); + fs.writeFileSync( + vercelConfigPath, + JSON.stringify( + { + buildCommand: 'next build', + framework: 'nextjs', + }, + null, + 2 + ) + ); } // Set up Vercel project console.log('\nšŸ“¦ Setting up Vercel project...'); - console.log('An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n'); - console.log('\nāš ļø Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n'); - + console.log( + 'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n' + ); + console.log( + '\nāš ļø Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n' + ); + // Use spawn instead of execSync for better error handling const { spawn } = await import('child_process'); const vercelSetup = spawn('vercel', [], { @@ -447,7 +510,7 @@ async function deployToVercel(useGitHub = false): Promise { resolve(); // Don't reject, as this is often expected } }); - + vercelSetup.on('error', (error) => { console.log('āš ļø Vercel setup command completed (this is normal)'); resolve(); // Don't reject, as this is often expected @@ -455,12 +518,14 @@ async function deployToVercel(useGitHub = false): Promise { }); // Wait a moment for project files to be written - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 2000)); // Load project info let projectId: string; try { - const projectJson = JSON.parse(fs.readFileSync('.vercel/project.json', 'utf8')); + const projectJson = JSON.parse( + fs.readFileSync('.vercel/project.json', 'utf8') + ); projectId = projectJson.projectId; } catch (error: unknown) { if (error instanceof Error) { @@ -470,11 +535,11 @@ async function deployToVercel(useGitHub = false): Promise { } // Get Vercel token and initialize SDK client - let vercelClient: VercelSDKType | null = null; + let vercelClient: Vercel | null = null; try { const token = await getVercelToken(); if (token) { - vercelClient = new VercelSDKType({ + vercelClient = new Vercel({ bearerToken: token }); console.log('āœ… Initialized Vercel SDK client'); @@ -494,7 +559,7 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient) { try { const project = await vercelClient.projects.get({ - idOrName: projectId + idOrName: projectId, }); projectName = project.name; domain = `${projectName}.vercel.app`; @@ -510,10 +575,13 @@ async function deployToVercel(useGitHub = false): Promise { // Fallback to CLI method if SDK failed if (!domain) { try { - const inspectOutput = execSync(`vercel project inspect ${projectId} 2>&1`, { - cwd: projectRoot, - encoding: 'utf8' - }); + const inspectOutput = execSync( + `vercel project inspect ${projectId} 2>&1`, + { + cwd: projectRoot, + encoding: 'utf8', + } + ); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); if (nameMatch) { @@ -527,7 +595,9 @@ async function deployToVercel(useGitHub = false): Promise { domain = `${projectName}.vercel.app`; console.log('🌐 Using project name for domain:', domain); } else { - console.warn('āš ļø Could not determine project name from inspection, using fallback'); + console.warn( + 'āš ļø Could not determine project name from inspection, using fallback' + ); // Use a fallback domain based on project ID domain = `project-${projectId.slice(-8)}.vercel.app`; console.log('🌐 Using fallback domain:', domain); @@ -545,7 +615,8 @@ async function deployToVercel(useGitHub = false): Promise { } // Prepare environment variables - const nextAuthSecret = process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); + const nextAuthSecret = + process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); const vercelEnv = { NEXTAUTH_SECRET: nextAuthSecret, AUTH_SECRET: nextAuthSecret, @@ -554,23 +625,30 @@ async function deployToVercel(useGitHub = false): Promise { ...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }), ...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID }), + ...(process.env.SPONSOR_SIGNER && { SPONSOR_SIGNER: process.env.SPONSOR_SIGNER }), ...Object.fromEntries( - Object.entries(process.env) - .filter(([key]) => key.startsWith('NEXT_PUBLIC_')) - ) + Object.entries(process.env).filter(([key]) => + key.startsWith('NEXT_PUBLIC_') + ) + ), }; // Set environment variables - await setEnvironmentVariables(vercelClient, projectId, vercelEnv, projectRoot); + await setEnvironmentVariables( + vercelClient, + projectId, + vercelEnv, + projectRoot + ); // Deploy the project if (useGitHub) { console.log('\nSetting up GitHub integration...'); - execSync('vercel link', { + execSync('vercel link', { cwd: projectRoot, stdio: 'inherit', - env: process.env + env: process.env, }); console.log('\nšŸ“¦ Deploying with GitHub integration...'); } else { @@ -578,10 +656,10 @@ async function deployToVercel(useGitHub = false): Promise { } // Use spawn for better control over the deployment process - const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { + const vercelDeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, stdio: 'inherit', - env: process.env + env: process.env, }); await new Promise((resolve, reject) => { @@ -594,7 +672,7 @@ async function deployToVercel(useGitHub = false): Promise { reject(new Error(`Vercel deployment failed with exit code: ${code}`)); } }); - + vercelDeploy.on('error', (error) => { console.error('āŒ Vercel deployment error:', error.message); reject(error); @@ -617,7 +695,7 @@ async function deployToVercel(useGitHub = false): Promise { // Verify actual domain after deployment console.log('\nšŸ” Verifying deployment domain...'); - + let actualDomain = domain; if (vercelClient && deployment) { try { @@ -637,16 +715,16 @@ async function deployToVercel(useGitHub = false): Promise { const updatedEnv: Record = { NEXTAUTH_URL: `https://${actualDomain}`, - NEXT_PUBLIC_URL: `https://${actualDomain}` + NEXT_PUBLIC_URL: `https://${actualDomain}`, }; await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot); console.log('\nšŸ“¦ Redeploying with correct domain...'); - const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { + const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { cwd: projectRoot, stdio: 'inherit', - env: process.env + env: process.env, }); await new Promise((resolve, reject) => { @@ -659,16 +737,16 @@ async function deployToVercel(useGitHub = false): Promise { reject(new Error(`Redeployment failed with exit code: ${code}`)); } }); - + vercelRedeploy.on('error', (error) => { console.error('āŒ Redeployment error:', error.message); reject(error); }); }); - + domain = actualDomain; } - + console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log(`🌐 https://${domain}`); console.log('\nšŸ“ You can manage your project at https://vercel.com/dashboard'); @@ -725,7 +803,9 @@ async function deployToVercel(useGitHub = false): Promise { async function main(): Promise { try { console.log('šŸš€ Vercel Mini App Deployment (SDK Edition)'); - console.log('This script will deploy your mini app to Vercel using the Vercel SDK.'); + console.log( + 'This script will deploy your mini app to Vercel using the Vercel SDK.' + ); console.log('\nThe script will:'); console.log('1. Check for required environment variables'); console.log('2. Set up a Vercel project (new or existing)'); @@ -759,8 +839,8 @@ async function main(): Promise { type: 'confirm', name: 'useGitHubDeploy', message: 'Would you like to deploy from the GitHub repository?', - default: true - } + default: true, + }, ]); useGitHub = useGitHubDeploy; } else { @@ -772,10 +852,10 @@ async function main(): Promise { message: 'What would you like to do?', choices: [ { name: 'Deploy local code directly', value: 'deploy' }, - { name: 'Set up GitHub repository first', value: 'setup' } + { name: 'Set up GitHub repository first', value: 'setup' }, ], - default: 'deploy' - } + default: 'deploy', + }, ]); if (action === 'setup') { @@ -789,12 +869,12 @@ async function main(): Promise { } } - if (!await checkVercelCLI()) { + if (!(await checkVercelCLI())) { console.log('Vercel CLI not found. Installing...'); await installVercelCLI(); } - if (!await loginToVercel()) { + if (!(await loginToVercel())) { console.error('\nāŒ Failed to log in to Vercel. Please try again.'); process.exit(1); } @@ -810,4 +890,4 @@ async function main(): Promise { } } -main(); \ No newline at end of file +main(); diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..a1f25ea --- /dev/null +++ b/src/app/api/auth/nonce/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET() { + try { + const client = getNeynarClient(); + const response = await client.fetchNonce(); + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching nonce:', error); + return NextResponse.json( + { error: 'Failed to fetch nonce' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/session-signers/route.ts b/src/app/api/auth/session-signers/route.ts new file mode 100644 index 0000000..630ef3b --- /dev/null +++ b/src/app/api/auth/session-signers/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message || !signature) { + return NextResponse.json( + { error: 'Message and signature are required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + + // Fetch user data if signers exist + let user = null; + if (signers && signers.length > 0 && signers[0].fid) { + const { + users: [fetchedUser], + } = await client.fetchBulkUsers({ + fids: [signers[0].fid], + }); + user = fetchedUser; + } + + return NextResponse.json({ + signers, + user, + }); + } catch (error) { + console.error('Error in session-signers API:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signer/route.ts b/src/app/api/auth/signer/route.ts new file mode 100644 index 0000000..f793d0e --- /dev/null +++ b/src/app/api/auth/signer/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function POST() { + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.createSigner(); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signer:', error); + return NextResponse.json( + { error: 'Failed to fetch signer' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const signerUuid = searchParams.get('signerUuid'); + + if (!signerUuid) { + return NextResponse.json( + { error: 'signerUuid is required' }, + { status: 400 } + ); + } + + try { + const neynarClient = getNeynarClient(); + const signer = await neynarClient.lookupSigner({ + signerUuid, + }); + return NextResponse.json(signer); + } catch (error) { + console.error('Error fetching signed key:', error); + return NextResponse.json( + { error: 'Failed to fetch signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signer/signed_key/route.ts b/src/app/api/auth/signer/signed_key/route.ts new file mode 100644 index 0000000..d7a3df8 --- /dev/null +++ b/src/app/api/auth/signer/signed_key/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; +import { mnemonicToAccount } from 'viem/accounts'; +import { + SIGNED_KEY_REQUEST_TYPE, + SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, +} from '~/lib/constants'; + +const postRequiredFields = ['signerUuid', 'publicKey']; + +export async function POST(request: Request) { + const body = await request.json(); + + // Validate required fields + for (const field of postRequiredFields) { + if (!body[field]) { + return NextResponse.json( + { error: `${field} is required` }, + { status: 400 } + ); + } + } + + const { signerUuid, publicKey, redirectUrl } = body; + + if (redirectUrl && typeof redirectUrl !== 'string') { + return NextResponse.json( + { error: 'redirectUrl must be a string' }, + { status: 400 } + ); + } + + try { + // Get the app's account from seed phrase + const seedPhrase = process.env.SEED_PHRASE; + const shouldSponsor = process.env.SPONSOR_SIGNER === 'true'; + + if (!seedPhrase) { + return NextResponse.json( + { error: 'App configuration missing (SEED_PHRASE or FID)' }, + { status: 500 } + ); + } + + const neynarClient = getNeynarClient(); + + const account = mnemonicToAccount(seedPhrase); + + const { + user: { fid }, + } = await neynarClient.lookupUserByCustodyAddress({ + custodyAddress: account.address, + }); + + const appFid = fid; + + // Generate deadline (24 hours from now) + const deadline = Math.floor(Date.now() / 1000) + 86400; + + // Generate EIP-712 signature + const signature = await account.signTypedData({ + domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN, + types: { + SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE, + }, + primaryType: 'SignedKeyRequest', + message: { + requestFid: BigInt(appFid), + key: publicKey, + deadline: BigInt(deadline), + }, + }); + + const signer = await neynarClient.registerSignedKey({ + appFid, + deadline, + signature, + signerUuid, + ...(redirectUrl && { redirectUrl }), + ...(shouldSponsor && { sponsor: { sponsored_by_neynar: true } }), + }); + + return NextResponse.json(signer); + } catch (error) { + console.error('Error registering signed key:', error); + return NextResponse.json( + { error: 'Failed to register signed key' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signers/route.ts b/src/app/api/auth/signers/route.ts new file mode 100644 index 0000000..1c89acf --- /dev/null +++ b/src/app/api/auth/signers/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getNeynarClient } from '~/lib/neynar'; + +const requiredParams = ['message', 'signature']; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const params: Record = {}; + for (const param of requiredParams) { + params[param] = searchParams.get(param); + if (!params[param]) { + return NextResponse.json( + { + error: `${param} parameter is required`, + }, + { status: 400 } + ); + } + } + + const message = params.message as string; + const signature = params.signature as string; + + try { + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + return NextResponse.json({ + signers, + }); + } catch (error) { + console.error('Error fetching signers:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/update-session/route.ts b/src/app/api/auth/update-session/route.ts new file mode 100644 index 0000000..db4b4fc --- /dev/null +++ b/src/app/api/auth/update-session/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '~/auth'; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.fid) { + return NextResponse.json( + { error: 'No authenticated session found' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { signers, user } = body; + + if (!signers || !user) { + return NextResponse.json( + { error: 'Signers and user are required' }, + { status: 400 } + ); + } + + // For NextAuth to update the session, we need to trigger the JWT callback + // This is typically done by calling the session endpoint with updated data + // However, we can't directly modify the session token from here + + // Instead, we'll store the data temporarily and let the client refresh the session + // The session will be updated when the JWT callback is triggered + + return NextResponse.json({ + success: true, + message: 'Session update prepared', + signers, + user, + }); + } catch (error) { + console.error('Error preparing session update:', error); + return NextResponse.json( + { error: 'Failed to prepare session update' }, + { status: 500 } + ); + } +} 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/auth.ts b/src/auth.ts index 8c39468..c3345fb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,11 +1,200 @@ -import { AuthOptions, getServerSession } from "next-auth" -import CredentialsProvider from "next-auth/providers/credentials"; -import { createAppClient, viemConnector } from "@farcaster/auth-client"; +import { AuthOptions, getServerSession } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { createAppClient, viemConnector } from '@farcaster/auth-client'; -declare module "next-auth" { +declare module 'next-auth' { interface Session { - user: { + provider?: string; + user?: { fid: number; + object?: 'user'; + username?: string; + display_name?: string; + pfp_url?: string; + custody_address?: string; + profile?: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count?: number; + following_count?: number; + verifications?: string[]; + verified_addresses?: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; + }; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; + } + + interface User { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + }; + } + + interface JWT { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; }; } } @@ -26,43 +215,49 @@ function getDomainFromUrl(urlString: string | undefined): string { } export const authOptions: AuthOptions = { - // Configure one or more authentication providers + // Configure one or more authentication providers providers: [ CredentialsProvider({ - name: "Sign in with Farcaster", + id: 'farcaster', + name: 'Sign in with Farcaster', credentials: { message: { - label: "Message", - type: "text", - placeholder: "0x0", + label: 'Message', + type: 'text', + placeholder: '0x0', }, signature: { - label: "Signature", - type: "text", - placeholder: "0x0", + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', }, // In a production app with a server, these should be fetched from // your Farcaster data indexer rather than have them accepted as part // of credentials. // question: should these natively use the Neynar API? name: { - label: "Name", - type: "text", - placeholder: "0x0", + label: 'Name', + type: 'text', + placeholder: '0x0', }, pfp: { - label: "Pfp", - type: "text", - placeholder: "0x0", + label: 'Pfp', + type: 'text', + placeholder: '0x0', }, }, async authorize(credentials, req) { - const csrfToken = req?.body?.csrfToken; - if (!csrfToken) { - console.error('CSRF token is missing from request'); + const nonce = req?.body?.csrfToken; + + if (!nonce) { + console.error('No nonce or CSRF token provided'); return null; } - const appClient = createAppClient({ ethereum: viemConnector(), }); @@ -73,8 +268,9 @@ export const authOptions: AuthOptions = { message: credentials?.message as string, signature: credentials?.signature as `0x${string}`, domain, - nonce: csrfToken, + nonce, }); + const { success, fid } = verifyResponse; if (!success) { @@ -83,47 +279,155 @@ export const authOptions: AuthOptions = { return { id: fid.toString(), + name: credentials?.name || `User ${fid}`, + image: credentials?.pfp || null, + provider: 'farcaster', }; }, }), + CredentialsProvider({ + id: 'neynar', + name: 'Sign in with Neynar', + credentials: { + message: { + label: 'Message', + type: 'text', + placeholder: '0x0', + }, + signature: { + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, + fid: { + label: 'FID', + type: 'text', + placeholder: '0', + }, + signers: { + label: 'Signers', + type: 'text', + placeholder: 'JSON string of signers', + }, + user: { + label: 'User Data', + type: 'text', + placeholder: 'JSON string of user data', + }, + }, + async authorize(credentials) { + const nonce = credentials?.nonce; + + if (!nonce) { + console.error('No nonce or CSRF token provided for Neynar auth'); + return null; + } + + // For Neynar, we can use a different validation approach + // This could involve validating against Neynar's API or using their SDK + try { + // Validate the signature using Farcaster's auth client (same as Farcaster provider) + const appClient = createAppClient({ + ethereum: viemConnector(), + }); + + const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); + + const verifyResponse = await appClient.verifySignInMessage({ + message: credentials?.message as string, + signature: credentials?.signature as `0x${string}`, + domain, + nonce, + }); + + const { success, fid } = verifyResponse; + + if (!success) { + return null; + } + + // Validate that the provided FID matches the verified FID + if (credentials?.fid && parseInt(credentials.fid) !== fid) { + console.error('FID mismatch in Neynar auth'); + return null; + } + + return { + id: fid.toString(), + provider: 'neynar', + signers: credentials?.signers + ? JSON.parse(credentials.signers) + : undefined, + user: credentials?.user ? JSON.parse(credentials.user) : undefined, + }; + } catch (error) { + console.error('Error in Neynar auth:', error); + return null; + } + }, + }), ], callbacks: { session: async ({ session, token }) => { - if (session?.user) { - session.user.fid = parseInt(token.sub ?? ''); + // Set provider at the root level + session.provider = token.provider as string; + + if (token.provider === 'farcaster') { + // For Farcaster, simple structure + session.user = { + fid: parseInt(token.sub ?? ''), + }; + } else if (token.provider === 'neynar') { + // For Neynar, use full user data structure from user + session.user = token.user as typeof session.user; + session.signers = token.signers as typeof session.signers; } + return session; }, + jwt: async ({ token, user }) => { + if (user) { + token.provider = user.provider; + token.signers = user.signers; + token.user = user.user; + } + return token; + }, }, cookies: { sessionToken: { name: `next-auth.session-token`, options: { httpOnly: true, - sameSite: "none", - path: "/", - secure: true - } + sameSite: 'none', + path: '/', + secure: true, + }, }, callbackUrl: { name: `next-auth.callback-url`, options: { - sameSite: "none", - path: "/", - secure: true - } + sameSite: 'none', + path: '/', + secure: true, + }, }, csrfToken: { name: `next-auth.csrf-token`, options: { httpOnly: true, - sameSite: "none", - path: "/", - secure: true - } - } - } -} + sameSite: 'none', + path: '/', + secure: true, + }, + }, + }, +}; export const getSession = async () => { try { @@ -132,4 +436,4 @@ export const getSession = async () => { console.error('Error getting server session:', error); return null; } -} +}; diff --git a/src/components/ui/NeynarAuthButton/AuthDialog.tsx b/src/components/ui/NeynarAuthButton/AuthDialog.tsx new file mode 100644 index 0000000..a458ab4 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/AuthDialog.tsx @@ -0,0 +1,221 @@ +'use client'; + +export function AuthDialog({ + open, + onClose, + url, + isError, + error, + step, + isLoading, + signerApprovalUrl, +}: { + open: boolean; + onClose: () => void; + url?: string; + isError: boolean; + error?: Error | null; + step: 'signin' | 'access' | 'loading'; + isLoading?: boolean; + signerApprovalUrl?: string | null; +}) { + if (!open) return null; + + const getStepContent = () => { + switch (step) { + case 'signin': + return { + title: 'Sign in', + description: + "To sign in, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + + case 'loading': + return { + title: 'Setting up access...', + description: + 'Checking your account permissions and setting up secure access.', + showQR: false, + qrUrl: '', + showOpenButton: false, + }; + + case 'access': + return { + title: 'Grant Access', + description: ( +
+

+ Allow this app to access your Farcaster account: +

+
+
+
+ + + +
+
+
+ Read Access +
+
+ View your profile and public information +
+
+
+
+
+ + + +
+
+
+ Write Access +
+
+ Post casts, likes, and update your profile +
+
+
+
+
+ ), + // Show QR code if we have signer approval URL, otherwise show loading + showQR: !!signerApprovalUrl, + qrUrl: signerApprovalUrl || '', + showOpenButton: !!signerApprovalUrl, + }; + + default: + return { + title: 'Sign in', + description: + "To signin, scan the code below with your phone's camera.", + showQR: true, + qrUrl: url, + showOpenButton: true, + }; + } + }; + + const content = getStepContent(); + + return ( +
+
+
+

+ {isError ? 'Error' : content.title} +

+ +
+ +
+ {isError ? ( +
+
+ {error?.message || 'Unknown error, please try again.'} +
+
+ ) : ( +
+
+ {typeof content.description === 'string' ? ( +

+ {content.description} +

+ ) : ( + content.description + )} +
+ +
+ {content.showQR && content.qrUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code +
+ ) : step === 'loading' || isLoading ? ( +
+
+
+ + {step === 'loading' + ? 'Setting up access...' + : 'Loading...'} + +
+
+ ) : null} +
+ + {content.showOpenButton && content.qrUrl && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx new file mode 100644 index 0000000..bcf1ca7 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useDetectClickOutside } from '~/hooks/useDetectClickOutside'; +import { cn } from '~/lib/utils'; + +export 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 && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx new file mode 100644 index 0000000..8d96711 --- /dev/null +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -0,0 +1,705 @@ +'use client'; + +import '@farcaster/auth-kit/styles.css'; +import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; +import { useCallback, useEffect, useState, useRef } from 'react'; +import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/Button'; +import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; +import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; +import { getItem, removeItem, setItem } from '~/lib/localStorage'; +import { useMiniApp } from '@neynar/react'; +import { + signIn as backendSignIn, + signOut as backendSignOut, + useSession, +} from 'next-auth/react'; +import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; + +type User = { + fid: number; + username: string; + display_name: string; + pfp_url: string; + // Add other user properties as needed +}; + +const STORAGE_KEY = 'neynar_authenticated_user'; +const FARCASTER_FID = 9152; + +interface StoredAuthState { + isAuthenticated: boolean; + user: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + } | null; + signers: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; +} + +// Main Custom SignInButton Component +export function NeynarAuthButton() { + const [nonce, setNonce] = useState(null); + const [storedAuth, setStoredAuth] = useState(null); + const [signersLoading, setSignersLoading] = useState(false); + const { context } = useMiniApp(); + const { data: session } = useSession(); + // New state for unified dialog flow + const [showDialog, setShowDialog] = useState(false); + const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( + 'loading' + ); + const [signerApprovalUrl, setSignerApprovalUrl] = useState( + null + ); + const [pollingInterval, setPollingInterval] = useState( + null + ); + const [message, setMessage] = useState(null); + const [signature, setSignature] = useState(null); + const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); + const signerFlowStartedRef = useRef(false); + + // Determine which flow to use based on context + const useBackendFlow = context !== undefined; + + // Helper function to create a signer + const createSigner = useCallback(async () => { + try { + const response = await fetch('/api/auth/signer', { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to create signer'); + } + + const signerData = await response.json(); + return signerData; + } catch (error) { + console.error('āŒ Error creating signer:', error); + // throw error; + } + }, []); + + // Helper function to update session with signers (backend flow only) + const updateSessionWithSigners = useCallback( + async ( + signers: StoredAuthState['signers'], + user: StoredAuthState['user'] + ) => { + if (!useBackendFlow) return; + + try { + // For backend flow, we need to sign in again with the additional data + if (message && signature) { + const signInData = { + message, + signature, + redirect: false, + nonce: nonce || '', + fid: user?.fid?.toString() || '', + signers: JSON.stringify(signers), + user: JSON.stringify(user), + }; + + await backendSignIn('neynar', signInData); + } + } catch (error) { + console.error('āŒ Error updating session with signers:', error); + } + }, + [useBackendFlow, message, signature, nonce] + ); + + // Helper function to fetch user data from Neynar API + const fetchUserData = useCallback( + async (fid: number): Promise => { + try { + const response = await fetch(`/api/users?fids=${fid}`); + if (response.ok) { + const data = await response.json(); + return data.users?.[0] || null; + } + return null; + } catch (error) { + console.error('Error fetching user data:', error); + return null; + } + }, + [] + ); + + // Helper function to generate signed key request + const generateSignedKeyRequest = useCallback( + async (signerUuid: string, publicKey: string) => { + try { + // Prepare request body + const requestBody: { + signerUuid: string; + publicKey: string; + sponsor?: { sponsored_by_neynar: boolean }; + } = { + signerUuid, + publicKey, + }; + + const response = await fetch('/api/auth/signer/signed_key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to generate signed key request: ${errorData.error}` + ); + } + + const data = await response.json(); + + return data; + } catch (error) { + console.error('āŒ Error generating signed key request:', error); + // throw error; + } + }, + [] + ); + + // Helper function to fetch all signers + const fetchAllSigners = useCallback( + async (message: string, signature: string) => { + try { + setSignersLoading(true); + + const endpoint = useBackendFlow + ? `/api/auth/session-signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + : `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}`; + + const response = await fetch(endpoint); + const signerData = await response.json(); + + if (response.ok) { + if (useBackendFlow) { + // For backend flow, update session with signers + if (signerData.signers && signerData.signers.length > 0) { + const user = + signerData.user || + (await fetchUserData(signerData.signers[0].fid)); + await updateSessionWithSigners(signerData.signers, user); + } + return signerData.signers; + } else { + // For frontend flow, store in localStorage + let user: StoredAuthState['user'] | null = null; + + if (signerData.signers && signerData.signers.length > 0) { + const fetchedUser = (await fetchUserData( + signerData.signers[0].fid + )) as StoredAuthState['user']; + user = fetchedUser; + } + + // Store signers in localStorage, preserving existing auth data + const updatedState: StoredAuthState = { + isAuthenticated: !!user, + signers: signerData.signers || [], + user, + }; + setItem(STORAGE_KEY, updatedState); + setStoredAuth(updatedState); + + return signerData.signers; + } + } else { + console.error('āŒ Failed to fetch signers'); + // throw new Error('Failed to fetch signers'); + } + } catch (error) { + console.error('āŒ Error fetching signers:', error); + // throw error; + } finally { + setSignersLoading(false); + } + }, + [useBackendFlow, fetchUserData, updateSessionWithSigners] + ); + + // Helper function to poll signer status + const startPolling = useCallback( + (signerUuid: string, message: string, signature: string) => { + // Clear any existing polling interval before starting a new one + if (pollingInterval) { + clearInterval(pollingInterval); + } + + let retryCount = 0; + const maxRetries = 10; // Maximum 10 retries (20 seconds total) + const maxPollingTime = 60000; // Maximum 60 seconds of polling + const startTime = Date.now(); + + const interval = setInterval(async () => { + // Check if we've been polling too long + if (Date.now() - startTime > maxPollingTime) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + try { + const response = await fetch( + `/api/auth/signer?signerUuid=${signerUuid}` + ); + + if (!response.ok) { + // Check if it's a rate limit error + if (response.status === 429) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + // Increment retry count for other errors + retryCount++; + if (retryCount >= maxRetries) { + clearInterval(interval); + setPollingInterval(null); + return; + } + + throw new Error(`Failed to poll signer status: ${response.status}`); + } + + const signerData = await response.json(); + + if (signerData.status === 'approved') { + clearInterval(interval); + setPollingInterval(null); + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + + // Refetch all signers + await fetchAllSigners(message, signature); + } + } catch (error) { + console.error('āŒ Error polling signer:', error); + } + }, 2000); // Poll every 2 second + + setPollingInterval(interval); + }, + [fetchAllSigners, pollingInterval] + ); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } + signerFlowStartedRef.current = false; + }; + }, [pollingInterval]); + + // 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 (only for frontend flow) + useEffect(() => { + if (!useBackendFlow) { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } + } + }, [useBackendFlow]); + + // Success callback - this is critical! + const onSuccessCallback = useCallback( + async (res: UseSignInData) => { + if (!useBackendFlow) { + // Only handle localStorage for frontend flow + const existingAuth = getItem(STORAGE_KEY); + const user = res.fid ? await fetchUserData(res.fid) : null; + const authState: StoredAuthState = { + ...existingAuth, + isAuthenticated: true, + user: user as StoredAuthState['user'], + signers: existingAuth?.signers || [], // Preserve existing signers + }; + setItem(STORAGE_KEY, authState); + setStoredAuth(authState); + } + // For backend flow, the session will be handled by NextAuth + }, + [useBackendFlow, fetchUserData] + ); + + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('āŒ Sign in error:', error); + }, []); + + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onError: onErrorCallback, + }); + + const { + signIn: frontendSignIn, + signOut: frontendSignOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + } = signInState; + + useEffect(() => { + setMessage(data?.message || null); + setSignature(data?.signature || null); + + // Reset the signer flow flag when message/signature change + if (data?.message && data?.signature) { + signerFlowStartedRef.current = false; + } + }, [data?.message, data?.signature]); + + // Connect for frontend flow when nonce is available + useEffect(() => { + if (!useBackendFlow && nonce && !channelToken) { + connect(); + } + }, [useBackendFlow, nonce, channelToken, connect]); + + // Handle fetching signers after successful authentication + useEffect(() => { + if (message && signature && !isSignerFlowRunning && !signerFlowStartedRef.current) { + signerFlowStartedRef.current = true; + + const handleSignerFlow = async () => { + setIsSignerFlowRunning(true); + try { + const clientContext = context?.client as Record; + const isMobileContext = + clientContext?.platformType === 'mobile' && + clientContext?.clientFid === FARCASTER_FID; + + // Step 1: Change to loading state + setDialogStep('loading'); + + // Show dialog if not using backend flow or in browser farcaster + if ((useBackendFlow && !isMobileContext) || !useBackendFlow) + setShowDialog(true); + + // First, fetch existing signers + const signers = await fetchAllSigners(message, signature); + + if (useBackendFlow && isMobileContext) setSignersLoading(true); + + // Check if no signers exist or if we have empty signers + if (!signers || signers.length === 0) { + // Step 1: Create a signer + const newSigner = await createSigner(); + + // Step 2: Generate signed key request + const signedKeyData = await generateSignedKeyRequest( + newSigner.signer_uuid, + newSigner.public_key + ); + + // Step 3: Show QR code in access dialog for signer approval + setSignerApprovalUrl(signedKeyData.signer_approval_url); + + if (isMobileContext) { + setShowDialog(false); + await sdk.actions.openUrl( + signedKeyData.signer_approval_url.replace( + 'https://client.farcaster.xyz/deeplinks/signed-key-request', + 'https://farcaster.xyz/~/connect' + ) + ); + } else { + setShowDialog(true); // Ensure dialog is shown during loading + setDialogStep('access'); + } + + // Step 4: Start polling for signer approval + startPolling(newSigner.signer_uuid, message, signature); + } else { + // If signers exist, close the dialog + setSignersLoading(false); + setShowDialog(false); + setDialogStep('signin'); + } + } catch (error) { + console.error('āŒ Error in signer flow:', error); + // On error, reset to signin step and hide dialog + setDialogStep('signin'); + setSignersLoading(false); + setShowDialog(false); + setSignerApprovalUrl(null); + } finally { + setIsSignerFlowRunning(false); + } + }; + + handleSignerFlow(); + } + }, [message, signature]); // Simplified dependencies + + // Backend flow using NextAuth + const handleBackendSignIn = useCallback(async () => { + if (!nonce) { + console.error('āŒ No nonce available for backend sign-in'); + return; + } + + try { + setSignersLoading(true); + const result = await sdk.actions.signIn({ nonce }); + + const signInData = { + message: result.message, + signature: result.signature, + redirect: false, + nonce: nonce, + }; + + const nextAuthResult = await backendSignIn('neynar', signInData); + if (nextAuthResult?.ok) { + setMessage(result.message); + setSignature(result.signature); + } else { + console.error('āŒ NextAuth sign-in failed:', nextAuthResult); + } + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('ā„¹ļø Sign-in rejected by user'); + } else { + console.error('āŒ Backend sign-in error:', e); + } + } + }, [nonce]); + + const handleFrontEndSignIn = useCallback(() => { + if (isError) { + reconnect(); + } + setDialogStep('signin'); + setShowDialog(true); + frontendSignIn(); + }, [isError, reconnect, frontendSignIn]); + + const handleSignOut = useCallback(async () => { + try { + setSignersLoading(true); + + if (useBackendFlow) { + // Only sign out from NextAuth if the current session is from Neynar provider + if (session?.provider === 'neynar') { + await backendSignOut({ redirect: false }); + } + } else { + // Frontend flow sign out + frontendSignOut(); + removeItem(STORAGE_KEY); + setStoredAuth(null); + } + + // Common cleanup for both flows + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + setMessage(null); + setSignature(null); + + // Reset polling interval + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + + // Reset signer flow flag + signerFlowStartedRef.current = false; + } catch (error) { + console.error('āŒ Error during sign out:', error); + // Optionally handle error state + } finally { + setSignersLoading(false); + } + }, [useBackendFlow, frontendSignOut, pollingInterval, session]); + + const authenticated = useBackendFlow + ? !!( + session?.provider === 'neynar' && + session?.user?.fid && + session?.signers && + session.signers.length > 0 + ) + : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); + + const userData = useBackendFlow + ? { + fid: session?.user?.fid, + username: session?.user?.username || '', + pfpUrl: session?.user?.pfp_url || '', + } + : { + fid: storedAuth?.user?.fid, + username: storedAuth?.user?.username || '', + pfpUrl: storedAuth?.user?.pfp_url || '', + }; + + // Show loading state while nonce is being fetched or signers are loading + if (!nonce || signersLoading) { + return ( +
+
+
+ + Loading... + +
+
+ ); + } + + return ( + <> + {authenticated ? ( + + ) : ( + + )} + + {/* Unified Auth Dialog */} + { + { + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + }} + url={url} + isError={isError} + error={error} + step={dialogStep} + isLoading={signersLoading} + signerApprovalUrl={signerApprovalUrl} + /> + } + + ); +} diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index e7bd99f..4c345cc 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useCallback, useState } from "react"; import { useMiniApp } from "@neynar/react"; @@ -7,10 +7,11 @@ import { Button } from "../Button"; import { SignIn } from "../wallet/SignIn"; import { type Haptics } from "@farcaster/miniapp-sdk"; import { APP_URL } from "~/lib/constants"; +import { NeynarAuthButton } from '../NeynarAuthButton/index'; /** * 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 @@ -18,10 +19,10 @@ import { APP_URL } from "~/lib/constants"; * - 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 * @@ -29,63 +30,68 @@ import { APP_URL } from "~/lib/constants"; */ 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. */ @@ -94,13 +100,17 @@ export function ActionsTab() { const userShareUrl = `${APP_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. */ @@ -114,56 +124,74 @@ 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 +} diff --git a/src/components/ui/wallet/SignIn.tsx b/src/components/ui/wallet/SignIn.tsx index 0f2dda5..9ca825c 100644 --- a/src/components/ui/wallet/SignIn.tsx +++ b/src/components/ui/wallet/SignIn.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useCallback, useState } from "react"; import { signIn, signOut, getCsrfToken } from "next-auth/react"; @@ -8,17 +8,17 @@ 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 * @@ -45,29 +45,29 @@ export function SignIn() { // --- 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"); + 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 () => { @@ -77,17 +77,17 @@ export function SignIn() { const nonce = await getNonce(); const result = await sdk.actions.signIn({ nonce }); setSignInResult(result); - await signIn("credentials", { + await signIn('farcaster', { message: result.message, signature: result.signature, redirect: false, }); } catch (e) { if (e instanceof SignInCore.RejectedByUser) { - setSignInFailure("Rejected by user"); + setSignInFailure('Rejected by user'); return; } - setSignInFailure("Unknown error"); + setSignInFailure('Unknown error'); } finally { setAuthState((prev) => ({ ...prev, signingIn: false })); } @@ -95,32 +95,35 @@ export function SignIn() { /** * 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. - * + * + * This function clears the NextAuth session only if the current session + * is using the Farcaster provider, and resets the local sign-in result state. + * * @returns Promise */ const handleSignOut = useCallback(async () => { try { setAuthState((prev) => ({ ...prev, signingOut: true })); - await signOut({ redirect: false }); + // Only sign out if the current session is from Farcaster provider + if (session?.provider === 'farcaster') { + await signOut({ redirect: false }); + } setSignInResult(undefined); } finally { setAuthState((prev) => ({ ...prev, signingOut: false })); } - }, []); + }, [session]); // --- Render --- return ( <> {/* Authentication Buttons */} - {status !== "authenticated" && ( + {(status !== 'authenticated' || session?.provider !== 'farcaster') && ( )} - {status === "authenticated" && ( + {status === 'authenticated' && session?.provider === 'farcaster' && ( @@ -155,4 +158,4 @@ export function SignIn() { )} ); -} \ No newline at end of file +} diff --git a/src/hooks/useDetectClickOutside.ts b/src/hooks/useDetectClickOutside.ts new file mode 100644 index 0000000..e6b1533 --- /dev/null +++ b/src/hooks/useDetectClickOutside.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export 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]); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ddd5536..dc05838 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,11 +2,11 @@ import { type AccountAssociation } from '@farcaster/miniapp-node'; /** * 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. - * + * * NOTE: This file is automatically updated by the init script. * Manual changes may be overwritten during project initialization. */ @@ -84,7 +84,7 @@ export const APP_BUTTON_TEXT: string = '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. @@ -95,7 +95,7 @@ export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env /** * 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. @@ -104,9 +104,25 @@ export const USE_WALLET: boolean = true; /** * Flag to enable/disable analytics tracking. - * + * * When true, usage analytics are collected and sent to Neynar. * When false, analytics collection is disabled. * Useful for privacy-conscious users or development environments. */ export const ANALYTICS_ENABLED: boolean = true; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { + name: 'Farcaster SignedKeyRequestValidator', + version: '1', + chainId: 10, + verifyingContract: + '0x00000000fc700472606ed4fa22623acf62c60553' as `0x${string}`, +}; + +// PLEASE DO NOT UPDATE THIS +export const SIGNED_KEY_REQUEST_TYPE = [ + { name: 'requestFid', type: 'uint256' }, + { name: 'key', type: 'bytes' }, + { name: 'deadline', type: 'uint256' }, +]; diff --git a/src/lib/devices.ts b/src/lib/devices.ts new file mode 100644 index 0000000..f6757ec --- /dev/null +++ b/src/lib/devices.ts @@ -0,0 +1,27 @@ +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(); +} + +export function isMobile(): boolean { + return isAndroid() || isIOS(); +} diff --git a/src/lib/localStorage.ts b/src/lib/localStorage.ts new file mode 100644 index 0000000..0d86b65 --- /dev/null +++ b/src/lib/localStorage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, value: T) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn('Failed to save item:', error); + } +} + +export function getItem(key: string): T | null { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : null; + } catch (error) { + console.warn('Failed to load item:', error); + return null; + } +} + +export function removeItem(key: string) { + try { + localStorage.removeItem(key); + } catch (error) { + console.warn('Failed to remove item:', error); + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f473387..59430e8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -19,17 +19,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export function getSecretEnvVars() { - const seedPhrase = process.env.SEED_PHRASE; - const fid = process.env.FID; - - if (!seedPhrase || !fid) { - return null; - } - - return { seedPhrase, fid }; -} - export function getMiniAppEmbedMetadata(ogImageUrl?: string) { return { version: "next",