Merge pull request #20 from neynarxyz/sc/revert-to-5fbd9

revert to 5fbd9
This commit is contained in:
Lucas Myers 2025-07-16 08:43:43 -07:00 committed by GitHub
commit f7392dc3cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 1650 additions and 6542 deletions

View File

@ -1,29 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# Matches multiple files with brace expansion notation
[*.{js,jsx,ts,tsx,json,css,scss,md,mdx,yml,yaml}]
indent_style = space
indent_size = 2
# Tab indentation (no size specified)
[Makefile]
indent_style = tab
# Matches the exact files
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false

View File

@ -1,95 +0,0 @@
# Dependencies
node_modules/
.pnp/
.pnp.*
# Build outputs
.next/
out/
build/
dist/
# Environment files
.env*
!.env.example
# Generated files
next-env.d.ts
*.tsbuildinfo
# Package manager lock files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Vercel
.vercel/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Git
.git/
# Temporary files
tmp/
temp/
# Coverage
coverage/
.nyc_output
# Cache directories
.cache/
.parcel-cache/
.eslintcache
# Storybook build outputs
storybook-static
# Database
*.db
*.sqlite
# Documentation
docs/
# Auto-generated files
*.d.ts
!src/**/*.d.ts
!types/**/*.d.ts
# Test coverage
*.lcov
# Compiled binary addons
build/Release
# Yarn
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
# Scripts that should be ignored by eslint
scripts/deploy.ts

View File

@ -1,113 +1,3 @@
{
"root": true,
"env": {
"browser": true,
"es2022": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"next/core-web-vitals",
"next/typescript"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
},
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/require-await": "warn",
"react/display-name": "off",
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"no-console": ["warn", { "allow": ["warn", "error"] }],
"no-var": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error",
"eol-last": "error",
"semi": ["error", "always"],
"quotes": ["error", "single", { "avoidEscape": true }],
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"pathGroups": [
{
"pattern": "react",
"group": "external",
"position": "before"
},
{
"pattern": "next/**",
"group": "external",
"position": "before"
},
{
"pattern": "~/**",
"group": "internal"
}
],
"pathGroupsExcludedImportTypes": ["react"],
"newlines-between": "never",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
},
"overrides": [
{
"files": ["*.js", "*.jsx"],
"parserOptions": {
"project": null
},
"rules": {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-console": "off"
}
},
{
"files": ["*.config.js", "*.config.ts", "next.config.*"],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off"
}
}
]
"extends": ["next/core-web-vitals", "next/typescript"]
}

View File

@ -27,4 +27,4 @@ jobs:
run: npm ci
- name: Publish to npm
run: npm publish --access public
run: npm publish --access public

22
.gitignore vendored
View File

@ -39,25 +39,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE and editor files
.vscode/settings.json
.idea/
*.swp
*.swo
*~
# Linting and formatting cache
.eslintcache
.prettierignore.bak
# OS generated files
Thumbs.db
# Package manager files
.pnpm-debug.log*
.yarn-integrity
# Temporary files
tmp/
temp/

View File

@ -1,4 +0,0 @@
{
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,scss,md,mdx,yml,yaml}": ["prettier --write"]
}

View File

@ -1,204 +0,0 @@
# Dependencies
node_modules/
.pnp/
.pnp.*
# Build outputs
.next/
out/
build/
dist/
# Environment files
.env*
!.env.example
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.cache
.parcel-cache
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Generated files
next-env.d.ts
# Package manager lock files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Vercel
.vercel/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
tmp/
temp/
# Database
*.db
*.sqlite
# Compiled binary addons
build/Release
# Documentation
docs/
# Git
.git/
.gitignore
# Editor files
*.swp
*.swo
*~
# Changelog
CHANGELOG.md
# Auto-generated documentation
api-docs/
# Dependency directories
node_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Vercel
.vercel
# Package manager lock files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Git
.git/

View File

@ -1,17 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto",
"singleAttributePerLine": false
}

View File

@ -1,13 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"editorconfig.editorconfig",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-json",
"formulahendry.auto-rename-tag",
"christian-kohler.npm-intellisense"
]
}

View File

@ -11,51 +11,24 @@ Check out [this Neynar docs page](https://docs.neynar.com/docs/create-farcaster-
## Getting Started
To create a new mini app project, run:
```{bash}
npx @neynar/create-farcaster-mini-app@latest
```
To run the project:
```{bash}
cd <PROJECT_NAME>
npm run dev
```
## Code Formatting & Linting
This template includes comprehensive formatting and linting tools to ensure consistent code quality:
- **Prettier**: Automatic code formatting
- **ESLint**: Code linting with Next.js and TypeScript support
- **EditorConfig**: Cross-editor consistency
### Available Scripts
```bash
npm run format # Format all files with Prettier
npm run format:check # Check if files are properly formatted
npm run lint # Run ESLint
npm run lint:fix # Fix ESLint issues automatically
npm run type-check # Run TypeScript type checking
npm run check # Run all checks (types, lint, format)
```
See [FORMATTING.md](./FORMATTING.md) for detailed configuration and setup information.
### Importing the CLI
To invoke the CLI directly in JavaScript, add the npm package to your project and use the following import statement:
```{javascript}
import { init } from '@neynar/create-farcaster-mini-app';
```
## Deploying to Vercel
For projects that have made minimal changes to the quickstart template, deploy to vercel by running:
```{bash}
npm run deploy:vercel
```
@ -63,7 +36,6 @@ npm run deploy:vercel
## Building for Production
To create a production build, run:
```{bash}
npm run build
```
@ -79,13 +51,12 @@ This section is only for working on the script and template. If you simply want
To iterate on the CLI and test changes in a generated app without publishing to npm:
1. In your installer/template repo (this repo), run:
```bash
npm link
```
This makes your local version globally available as a symlinked package.
1. Now, when you run:
```bash
npx @neynar/create-farcaster-mini-app
@ -105,3 +76,4 @@ However, this does not fully replicate the npx install flow and may not catch al
### Environment Variables and Scripts
If you update environment variable handling, remember to replicate any changes in the `dev`, `build`, and `deploy` scripts as needed. The `build` and `deploy` scripts may need further updates and are less critical for most development workflows.

View File

@ -15,48 +15,48 @@ if (yIndex !== -1) {
args.splice(yIndex, 1); // Remove -y from args
}
// 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 (-)');
// 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);
}
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 (-)');
} 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);
}
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);
}
}
}
// Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) {
console.error(
'Error: -y flag requires a project name. Use -p/--project to specify the project name.',
);
console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.');
process.exit(1);
}
init(projectName, autoAcceptDefaults, apiKey).catch(err => {
init(projectName, autoAcceptDefaults, apiKey).catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@ -1,19 +1,19 @@
#!/usr/bin/env node
import { execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import { dirname } from 'path';
import path from 'path';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
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'),
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
).version;
// ANSI color codes
@ -47,12 +47,12 @@ async function queryNeynarApp(apiKey) {
}
try {
const response = await fetch(
'https://api.neynar.com/portal/app_by_api_key?starter_kit=true',
`https://api.neynar.com/portal/app_by_api_key?starter_kit=true`,
{
headers: {
'x-api-key': apiKey,
},
},
}
);
const data = await response.json();
return data;
@ -63,11 +63,7 @@ async function queryNeynarApp(apiKey) {
}
// Export the main CLI function for programmatic use
export async function init(
projectName = null,
autoAcceptDefaults = false,
apiKey = null,
) {
export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) {
printWelcomeMessage();
// Ask about Neynar usage
@ -111,7 +107,7 @@ export async function init(
} else {
if (!autoAcceptDefaults) {
console.log(
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n',
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'
);
}
@ -148,13 +144,13 @@ export async function init(
if (useDemoKey.useDemo) {
console.warn(
'\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.',
'\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.',
'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}`,
`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`
);
neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
}
@ -167,7 +163,7 @@ export async function init(
break;
}
console.log(
'\n⚠ No valid API key provided. Would you like to try again?',
'\n⚠ No valid API key provided. Would you like to try again?'
);
const { retry } = await inquirer.prompt([
{
@ -243,7 +239,7 @@ export async function init(
name: 'projectName',
message: 'What is the name of your mini app?',
default: projectName || defaultMiniAppName,
validate: input => {
validate: (input) => {
if (input.trim() === '') {
return 'Project name cannot be empty';
}
@ -290,13 +286,13 @@ export async function init(
message:
'Enter tags for your mini app (separate with spaces or commas, optional):',
default: '',
filter: input => {
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);
},
},
{
@ -304,7 +300,7 @@ export async function init(
name: 'buttonText',
message: 'Enter the button text for your mini app:',
default: 'Launch Mini App',
validate: input => {
validate: (input) => {
if (input.trim() === '') {
return 'Button text cannot be empty';
}
@ -374,9 +370,8 @@ export async function init(
{
type: 'password',
name: 'seedPhrase',
message:
'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):',
validate: input => {
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';
}
@ -444,7 +439,7 @@ export async function init(
// Update package.json
console.log('\nUpdating package.json...');
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.name = finalProjectName;
packageJson.version = '0.1.0';
@ -460,11 +455,11 @@ export async function init(
// 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',
'@farcaster/quick-auth': '>=0.0.7 <1.0.0',
'@neynar/react': '^1.2.5',
'@radix-ui/react-label': '^2.1.1',
'@solana/wallet-adapter-react': '^0.15.38',
@ -476,6 +471,7 @@ export async function init(
'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',
@ -486,27 +482,20 @@ export async function init(
siwe: '^3.0.0',
};
// Add auth-kit and next-auth dependencies if useSponsoredSigner is true
if (answers.useSponsoredSigner) {
packageJson.dependencies['@farcaster/auth-kit'] = '>=0.6.0 <1.0.0';
packageJson.dependencies['next-auth'] = '^4.24.11';
}
packageJson.devDependencies = {
'@types/inquirer': '^9.0.8',
'@types/node': '^20',
'@types/react': '^19',
'@types/react-dom': '^19',
'@vercel/sdk': '^1.9.0',
crypto: '^1.0.1',
eslint: '^8',
'eslint-config-next': '15.0.3',
localtunnel: '^2.0.2',
'pino-pretty': '^13.0.0',
postcss: '^8',
tailwindcss: '^3.4.1',
'ts-node': '^10.9.2',
typescript: '^5',
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vercel/sdk": "^1.9.0",
"crypto": "^1.0.1",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"localtunnel": "^2.0.2",
"pino-pretty": "^13.0.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5",
"ts-node": "^10.9.2"
};
// Add Neynar SDK if selected
@ -534,21 +523,21 @@ export async function init(
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Helper function to escape single quotes in strings
const escapeString = str => str.replace(/'/g, "\\'");
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.`,
`⚠️ 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)) ||
content.split('\n').find((line) => line.includes(constantName)) ||
'Not found'
}`,
}`
);
} else {
const newContent = content.replace(pattern, replacement);
@ -559,8 +548,7 @@ export async function init(
// Regex patterns that match whole lines with export const (with TypeScript types)
const patterns = {
APP_NAME:
/^export const APP_NAME\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_NAME: /^export const APP_NAME\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION:
/^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY:
@ -568,8 +556,7 @@ export async function init(
APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
APP_BUTTON_TEXT:
/^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
USE_WALLET:
/^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
USE_WALLET: /^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
};
@ -579,7 +566,7 @@ export async function init(
constantsContent,
patterns.APP_NAME,
`export const APP_NAME = '${escapeString(answers.projectName)}';`,
'APP_NAME',
'APP_NAME'
);
// Update APP_DESCRIPTION
@ -587,9 +574,9 @@ export async function init(
constantsContent,
patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString(
answers.description,
answers.description
)}';`,
'APP_DESCRIPTION',
'APP_DESCRIPTION'
);
// Update APP_PRIMARY_CATEGORY (always update, null becomes empty string)
@ -597,21 +584,21 @@ export async function init(
constantsContent,
patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString(
answers.primaryCategory || '',
answers.primaryCategory || ''
)}';`,
'APP_PRIMARY_CATEGORY',
'APP_PRIMARY_CATEGORY'
);
// Update APP_TAGS
const tagsString =
answers.tags.length > 0
? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']`
? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']`
: "['neynar', 'starter-kit', 'demo']";
constantsContent = safeReplace(
constantsContent,
patterns.APP_TAGS,
`export const APP_TAGS = ${tagsString};`,
'APP_TAGS',
'APP_TAGS'
);
// Update APP_BUTTON_TEXT (always update, use answers value)
@ -619,9 +606,9 @@ export async function init(
constantsContent,
patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString(
answers.buttonText || '',
answers.buttonText || ''
)}';`,
'APP_BUTTON_TEXT',
'APP_BUTTON_TEXT'
);
// Update USE_WALLET
@ -629,7 +616,7 @@ export async function init(
constantsContent,
patterns.USE_WALLET,
`export const USE_WALLET = ${answers.useWallet};`,
'USE_WALLET',
'USE_WALLET'
);
// Update ANALYTICS_ENABLED
@ -637,7 +624,7 @@ export async function init(
constantsContent,
patterns.ANALYTICS_ENABLED,
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED',
'ANALYTICS_ENABLED'
);
fs.writeFileSync(constantsPath, constantsContent);
@ -647,28 +634,25 @@ export async function init(
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`,
`\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',
'\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}"`);
if (answers.useSponsoredSigner) {
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="${answers.useSponsoredSigner}"`);
}
fs.unlinkSync(envExamplePath);
} else {
console.log(
'\n.env.example does not exist, skipping copy and remove operations',
'\n.env.example does not exist, skipping copy and remove operations'
);
}
@ -707,22 +691,13 @@ export async function init(
fs.rmSync(binPath, { recursive: true, force: true });
}
// Remove NeynarAuthButton directory if useSponsoredSigner is false
if (!answers.useSponsoredSigner) {
console.log('\nRemoving NeynarAuthButton directory (useSponsoredSigner is false)...');
const neynarAuthButtonPath = path.join(projectPath, 'src', 'components', 'ui', 'NeynarAuthButton');
if (fs.existsSync(neynarAuthButtonPath)) {
fs.rmSync(neynarAuthButtonPath, { recursive: true, force: true });
}
}
// Initialize git repository
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 },
{ cwd: projectPath }
);
// Calculate border length based on message length

View File

@ -18,4 +18,4 @@
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}
}

2
index.d.ts vendored
View File

@ -2,4 +2,4 @@
* Initialize a new Farcaster mini app project
* @returns Promise<void>
*/
export function init(): Promise<void>;
export function init(): Promise<void>;

View File

@ -1,4 +1,4 @@
import type { NextConfig } from 'next';
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */

4974
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@neynar/create-farcaster-mini-app",
"version": "1.7.4",
"version": "1.6.2",
"type": "module",
"private": false,
"access": "public",
@ -35,9 +35,6 @@
"build:raw": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"format:check": "prettier --check .",
"format": "prettier --write . && eslint --fix . --max-warnings 50",
"deploy:vercel": "ts-node scripts/deploy.ts",
"deploy:raw": "vercel --prod",
"cleanup": "node scripts/cleanup.js"
@ -53,11 +50,6 @@
"devDependencies": {
"@neynar/nodejs-sdk": "^2.19.0",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
}

View File

@ -21,13 +21,13 @@ args.forEach((arg, index) => {
try {
console.log(`Checking for processes on port ${port}...`);
// Find processes using the port
const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim();
if (pids) {
console.log(`Found processes: ${pids.replace(/\n/g, ', ')}`);
// Kill the processes
execSync(`kill -9 ${pids.replace(/\n/g, ' ')}`);
console.log(`✓ Processes on port ${port} have been terminated`);
@ -42,4 +42,4 @@ try {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
}

View File

@ -5,6 +5,7 @@ import os from 'os';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
import dotenv from 'dotenv';
import crypto from 'crypto';
import { Vercel } from '@vercel/sdk';
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
@ -72,20 +73,18 @@ async function checkRequiredEnvVars(): Promise<void> {
name: 'NEXT_PUBLIC_MINI_APP_NAME',
message: 'Enter the name for your frame (e.g., My Cool Mini App):',
default: APP_NAME,
validate: (input: string) =>
input.trim() !== '' || 'Mini app name cannot be empty',
validate: (input: string) => input.trim() !== '' || 'Mini app name cannot be empty'
},
{
name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
message: 'Enter the text for your frame button:',
default: APP_BUTTON_TEXT ?? 'Launch Mini App',
validate: (input: string) =>
input.trim() !== '' || 'Button text cannot be empty',
},
validate: (input: string) => input.trim() !== '' || 'Button text cannot be empty'
}
];
const missingVars = requiredVars.filter(
varConfig => !process.env[varConfig.name],
(varConfig) => !process.env[varConfig.name]
);
if (missingVars.length > 0) {
@ -111,7 +110,7 @@ async function checkRequiredEnvVars(): Promise<void> {
const newLine = envContent ? '\n' : '';
fs.appendFileSync(
'.env',
`${newLine}${varConfig.name}="${value.trim()}"`,
`${newLine}${varConfig.name}="${value.trim()}"`
);
}
@ -131,10 +130,10 @@ async function checkRequiredEnvVars(): Promise<void> {
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
if (process.env.SEED_PHRASE) {
if (storeSeedPhrase) {
fs.appendFileSync(
'.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"`,
`\nSPONSOR_SIGNER="${sponsorSigner}"`
);
console.log('✅ Sponsor signer preference stored in .env.local');
}
@ -172,8 +171,8 @@ async function getGitRemote(): Promise<string | null> {
async function checkVercelCLI(): Promise<boolean> {
try {
execSync('vercel --version', {
stdio: 'ignore',
execSync('vercel --version', {
stdio: 'ignore'
});
return true;
} catch (error: unknown) {
@ -186,8 +185,8 @@ async function checkVercelCLI(): Promise<boolean> {
async function installVercelCLI(): Promise<void> {
console.log('Installing Vercel CLI...');
execSync('npm install -g vercel', {
stdio: 'inherit',
execSync('npm install -g vercel', {
stdio: 'inherit'
});
}
@ -223,9 +222,7 @@ async function getVercelToken(): Promise<string | null> {
return null; // We'll fall back to CLI operations
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(
'Not logged in to Vercel CLI. Please run this script again to login.',
);
throw new Error('Not logged in to Vercel CLI. Please run this script again to login.');
}
throw error;
}
@ -242,7 +239,7 @@ async function loginToVercel(): Promise<boolean> {
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',
'\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'], {
@ -250,14 +247,14 @@ async function loginToVercel(): Promise<boolean> {
});
await new Promise<void>((resolve, reject) => {
child.on('close', code => {
child.on('close', (code) => {
resolve();
});
});
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.",
"If you're creating a new account, please complete the Vercel account setup in your browser first."
);
for (let i = 0; i < 150; i++) {
@ -266,13 +263,10 @@ async function loginToVercel(): Promise<boolean> {
console.log('✅ Successfully logged in to Vercel!');
return true;
} catch (error: unknown) {
if (
error instanceof Error &&
error.message.includes('Account not found')
) {
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));
}
}
@ -283,12 +277,7 @@ async function loginToVercel(): Promise<boolean> {
return false;
}
async function setVercelEnvVarSDK(
vercelClient: Vercel,
projectId: string,
key: string,
value: string | object,
): Promise<boolean> {
async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: string, value: string | object): Promise<boolean> {
try {
let processedValue: string;
if (typeof value === 'object') {
@ -298,26 +287,17 @@ async function setVercelEnvVarSDK(
}
// Get existing environment variables
const existingVars = await vercelClient.projects.filterProjectEnvs({
const existingVars = await vercelClient.projects.getEnvironmentVariables({
idOrName: projectId,
});
// Handle different response types
let envs: any[] = [];
if ('envs' in existingVars && Array.isArray(existingVars.envs)) {
envs = existingVars.envs;
} else if ('target' in existingVars && 'key' in existingVars) {
// Single environment variable response
envs = [existingVars];
}
const existingVar = envs.find(
(env: any) => env.key === key && env.target?.includes('production'),
const existingVar = existingVars.envs?.find((env: any) =>
env.key === key && env.target?.includes('production')
);
if (existingVar && existingVar.id) {
if (existingVar) {
// Update existing variable
await vercelClient.projects.editProjectEnv({
await vercelClient.projects.editEnvironmentVariable({
idOrName: projectId,
id: existingVar.id,
requestBody: {
@ -328,7 +308,7 @@ async function setVercelEnvVarSDK(
console.log(`✅ Updated environment variable: ${key}`);
} else {
// Create new variable
await vercelClient.projects.createProjectEnv({
await vercelClient.projects.createEnvironmentVariable({
idOrName: projectId,
requestBody: {
key: key,
@ -343,21 +323,14 @@ async function setVercelEnvVarSDK(
return true;
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`,
error.message,
);
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
return false;
}
throw error;
}
}
async function setVercelEnvVarCLI(
key: string,
value: string | object,
projectRoot: string,
): Promise<boolean> {
async function setVercelEnvVarCLI(key: string, value: string | object, projectRoot: string): Promise<boolean> {
try {
// Remove existing env var
try {
@ -392,7 +365,7 @@ async function setVercelEnvVarCLI(
execSync(command, {
cwd: projectRoot,
stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
env: process.env,
env: process.env
});
fs.unlinkSync(tempFilePath);
@ -404,26 +377,18 @@ async function setVercelEnvVarCLI(
fs.unlinkSync(tempFilePath);
}
if (error instanceof Error) {
console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`,
error.message,
);
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
return false;
}
throw error;
}
}
async function setEnvironmentVariables(
vercelClient: Vercel | null,
projectId: string | null,
envVars: Record<string, string | object>,
projectRoot: string,
): Promise<Array<{ key: string; success: boolean }>> {
async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: string | null, envVars: Record<string, string | object>, projectRoot: string): Promise<Array<{ key: string; success: boolean }>> {
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;
@ -443,34 +408,29 @@ async function setEnvironmentVariables(
}
// 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}`));
failed.forEach((r) => console.warn(` - ${r.key}`));
console.warn(
'\nYou may need to set these manually in the Vercel dashboard.',
'\nYou may need to set these manually in the Vercel dashboard.'
);
}
return results;
}
async function waitForDeployment(
vercelClient: Vercel | null,
projectId: string,
maxWaitTime = 300000,
): Promise<any> {
// 5 minutes
async function waitForDeployment(vercelClient: Vercel | null, projectId: string, maxWaitTime = 300000): Promise<any> { // 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.getDeployments({
const deployments = await vercelClient?.deployments.list({
projectId: projectId,
limit: 1,
});
if (deployments?.deployments?.[0]) {
const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`);
@ -485,10 +445,10 @@ async function waitForDeployment(
}
// 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) {
@ -518,60 +478,58 @@ async function deployToVercel(useGitHub = false): Promise<void> {
framework: 'nextjs',
},
null,
2,
),
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',
'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',
'\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', [], {
cwd: projectRoot,
stdio: 'inherit',
shell: process.platform === 'win32' ? true : undefined,
});
const vercelSetup = spawn('vercel', [], {
cwd: projectRoot,
stdio: 'inherit',
shell: process.platform === 'win32' ? true : undefined
});
await new Promise<void>((resolve, reject) => {
vercelSetup.on('close', code => {
vercelSetup.on('close', (code) => {
if (code === 0 || code === null) {
console.log('✅ Vercel project setup completed');
resolve();
} else {
console.log('⚠️ Vercel setup command completed (this is normal)');
console.log('⚠️ Vercel setup command completed (this is normal)');
resolve(); // Don't reject, as this is often expected
}
});
vercelSetup.on('error', error => {
vercelSetup.on('error', (error) => {
console.log('⚠️ Vercel setup command completed (this is normal)');
resolve(); // Don't reject, as this is often expected
});
});
// 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'),
fs.readFileSync('.vercel/project.json', 'utf8')
);
projectId = projectJson.projectId;
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(
'Failed to load project info. Please ensure the Vercel project was created successfully.',
);
throw new Error('Failed to load project info. Please ensure the Vercel project was created successfully.');
}
throw error;
}
@ -582,15 +540,13 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const token = await getVercelToken();
if (token) {
vercelClient = new Vercel({
bearerToken: token,
bearerToken: token
});
console.log('✅ Initialized Vercel SDK client');
}
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(
'⚠️ Could not initialize Vercel SDK, falling back to CLI operations',
);
console.warn('⚠️ Could not initialize Vercel SDK, falling back to CLI operations');
}
throw error;
}
@ -602,22 +558,15 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient) {
try {
const projects = await vercelClient.projects.getProjects({});
const project = projects.projects.find(
p => p.id === projectId || p.name === projectId,
);
if (project) {
projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} else {
throw new Error('Project not found');
}
const project = await vercelClient.projects.get({
idOrName: projectId,
});
projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(
'⚠️ Could not get project details via SDK, using CLI fallback',
);
console.warn('⚠️ Could not get project details via SDK, using CLI fallback');
}
throw error;
}
@ -631,7 +580,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
{
cwd: projectRoot,
encoding: 'utf8',
},
}
);
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@ -647,7 +596,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🌐 Using project name for domain:', domain);
} else {
console.warn(
'⚠️ Could not determine project name from inspection, using fallback',
'⚠️ Could not determine project name from inspection, using fallback'
);
// Use a fallback domain based on project ID
domain = `project-${projectId.slice(-8)}.vercel.app`;
@ -666,23 +615,22 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}
// Prepare environment variables
const nextAuthSecret =
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
const vercelEnv = {
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
NEXT_PUBLIC_URL: `https://${domain}`,
...(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,
}),
...(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_'),
),
key.startsWith('NEXT_PUBLIC_')
)
),
};
@ -691,7 +639,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
vercelClient,
projectId,
vercelEnv,
projectRoot,
projectRoot
);
// Deploy the project
@ -715,7 +663,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
});
await new Promise<void>((resolve, reject) => {
vercelDeploy.on('close', code => {
vercelDeploy.on('close', (code) => {
if (code === 0) {
console.log('✅ Vercel deployment command completed');
resolve();
@ -725,7 +673,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}
});
vercelDeploy.on('error', error => {
vercelDeploy.on('error', (error) => {
console.error('❌ Vercel deployment error:', error.message);
reject(error);
});
@ -738,10 +686,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
deployment = await waitForDeployment(vercelClient, projectId);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(
'⚠️ Could not verify deployment completion:',
error.message,
);
console.warn('⚠️ Could not verify deployment completion:', error.message);
console.log(' Proceeding with domain verification...');
}
throw error;
@ -755,12 +700,10 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient && deployment) {
try {
actualDomain = deployment.url || domain;
console.log('🌐 Verified actual domain:', actualDomain);
console.log('🌐 Verified actual domain:', actualDomain);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn(
'⚠️ Could not verify domain via SDK, using assumed domain',
);
console.warn('⚠️ Could not verify domain via SDK, using assumed domain');
}
throw error;
}
@ -771,15 +714,11 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🔄 Updating environment variables with correct domain...');
const updatedEnv: Record<string, string | object> = {
NEXTAUTH_URL: `https://${actualDomain}`,
NEXT_PUBLIC_URL: `https://${actualDomain}`,
};
await setEnvironmentVariables(
vercelClient,
projectId,
updatedEnv,
projectRoot,
);
await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot);
console.log('\n📦 Redeploying with correct domain...');
const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], {
@ -789,7 +728,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
});
await new Promise<void>((resolve, reject) => {
vercelRedeploy.on('close', code => {
vercelRedeploy.on('close', (code) => {
if (code === 0) {
console.log('✅ Redeployment completed');
resolve();
@ -799,7 +738,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}
});
vercelRedeploy.on('error', error => {
vercelRedeploy.on('error', (error) => {
console.error('❌ Redeployment error:', error.message);
reject(error);
});
@ -810,24 +749,13 @@ async function deployToVercel(useGitHub = false): Promise<void> {
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',
);
console.log('\n📝 You can manage your project at https://vercel.com/dashboard');
// Prompt user to sign manifest in browser and paste accountAssociation
console.log(
`\n⚠ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`,
);
console.log(
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' +
domain,
);
console.log(
'2. Click "Transfer Ownership" and follow the instructions to sign the manifest.',
);
console.log(
'3. Copy the resulting accountAssociation JSON from the browser.',
);
console.log(`\n⚠ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`);
console.log('1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + domain);
console.log('2. Click "Transfer Ownership" and follow the instructions to sign the manifest.');
console.log('3. Copy the resulting accountAssociation JSON from the browser.');
console.log('4. Paste it below when prompted.');
const { userAccountAssociation } = await inquirer.prompt([
@ -845,8 +773,8 @@ async function deployToVercel(useGitHub = false): Promise<void> {
} catch (e) {
return 'Invalid JSON';
}
},
},
}
}
]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation);
@ -858,10 +786,11 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
constantsContent = constantsContent.replace(
/^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
newAccountAssociation,
newAccountAssociation
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts');
} catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Deployment failed:', error.message);
@ -875,7 +804,7 @@ async function main(): Promise<void> {
try {
console.log('🚀 Vercel Mini App Deployment (SDK Edition)');
console.log(
'This script will deploy your mini app to Vercel using the Vercel SDK.',
'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');
@ -889,9 +818,9 @@ async function main(): Promise<void> {
} catch (error: unknown) {
if (error instanceof Error) {
console.log('📦 Installing @vercel/sdk...');
execSync('npm install @vercel/sdk', {
execSync('npm install @vercel/sdk', {
cwd: projectRoot,
stdio: 'inherit',
stdio: 'inherit'
});
console.log('✅ @vercel/sdk installed successfully');
}
@ -951,6 +880,7 @@ async function main(): Promise<void> {
}
await deployToVercel(useGitHub);
} catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Error:', error.message);
@ -960,4 +890,4 @@ async function main(): Promise<void> {
}
}
main();
main();

View File

@ -1,9 +1,9 @@
import localtunnel from 'localtunnel';
import { spawn } from 'child_process';
import { createServer } from 'net';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import localtunnel from 'localtunnel';
// Load environment variables
dotenv.config({ path: '.env.local' });
@ -33,18 +33,18 @@ args.forEach((arg, index) => {
});
async function checkPort(port) {
return new Promise(resolve => {
return new Promise((resolve) => {
const server = createServer();
server.once('error', () => {
resolve(true); // Port is in use
});
server.once('listening', () => {
server.close();
resolve(false); // Port is free
});
server.listen(port);
});
}
@ -54,32 +54,29 @@ async function killProcessOnPort(port) {
if (process.platform === 'win32') {
// Windows: Use netstat to find the process
const netstat = spawn('netstat', ['-ano', '|', 'findstr', `:${port}`]);
netstat.stdout.on('data', data => {
netstat.stdout.on('data', (data) => {
const match = data.toString().match(/\s+(\d+)$/);
if (match) {
const pid = match[1];
spawn('taskkill', ['/F', '/PID', pid]);
}
});
await new Promise(resolve => netstat.on('close', resolve));
await new Promise((resolve) => netstat.on('close', resolve));
} else {
// Unix-like systems: Use lsof
const lsof = spawn('lsof', ['-ti', `:${port}`]);
lsof.stdout.on('data', data => {
data
.toString()
.split('\n')
.forEach(pid => {
if (pid) {
try {
process.kill(parseInt(pid), 'SIGKILL');
} catch (e) {
if (e.code !== 'ESRCH') throw e;
}
lsof.stdout.on('data', (data) => {
data.toString().split('\n').forEach(pid => {
if (pid) {
try {
process.kill(parseInt(pid), 'SIGKILL');
} catch (e) {
if (e.code !== 'ESRCH') throw e;
}
});
}
});
});
await new Promise(resolve => lsof.on('close', resolve));
await new Promise((resolve) => lsof.on('close', resolve));
}
} catch (e) {
// Ignore errors if no process found
@ -90,15 +87,13 @@ async function startDev() {
// Check if the specified port is already in use
const isPortInUse = await checkPort(port);
if (isPortInUse) {
console.error(
`Port ${port} is already in use. To find and kill the process using this port:\n\n` +
(process.platform === 'win32'
? `1. Run: netstat -ano | findstr :${port}\n` +
'2. Note the PID (Process ID) from the output\n' +
'3. Run: taskkill /PID <PID> /F\n'
: 'On macOS/Linux, run:\nnpm run cleanup\n') +
'\nThen try running this command again.',
);
console.error(`Port ${port} is already in use. To find and kill the process using this port:\n\n` +
(process.platform === 'win32'
? `1. Run: netstat -ano | findstr :${port}\n` +
'2. Note the PID (Process ID) from the output\n' +
'3. Run: taskkill /PID <PID> /F\n'
: `On macOS/Linux, run:\nnpm run cleanup\n`) +
'\nThen try running this command again.');
process.exit(1);
}
@ -110,9 +105,7 @@ async function startDev() {
tunnel = await localtunnel({ port: port });
let ip;
try {
ip = await fetch('https://ipv4.icanhazip.com')
.then(res => res.text())
.then(ip => ip.trim());
ip = await fetch('https://ipv4.icanhazip.com').then(res => res.text()).then(ip => ip.trim());
} catch (error) {
console.error('Error getting IP address:', error);
}
@ -150,17 +143,15 @@ async function startDev() {
4. Click "Preview" to test your mini app (note that it may take ~5 seconds to load the first time)
`);
}
// Start next dev with appropriate configuration
const nextBin = path.normalize(
path.join(projectRoot, 'node_modules', '.bin', 'next'),
);
const nextBin = path.normalize(path.join(projectRoot, 'node_modules', '.bin', 'next'));
nextDev = spawn(nextBin, ['dev', '-p', port.toString()], {
stdio: 'inherit',
env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl },
env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl },
cwd: projectRoot,
shell: process.platform === 'win32', // Add shell option for Windows
shell: process.platform === 'win32' // Add shell option for Windows
});
// Handle cleanup
@ -190,7 +181,7 @@ async function startDev() {
console.log('Note: Next.js process already terminated');
}
}
if (tunnel) {
try {
await tunnel.close();
@ -218,4 +209,4 @@ async function startDev() {
}
}
startDev().catch(console.error);
startDev().catch(console.error);

View File

@ -0,0 +1,6 @@
import NextAuth from "next-auth"
import { authOptions } from "~/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@ -10,7 +10,7 @@ export async function GET() {
console.error('Error fetching nonce:', error);
return NextResponse.json(
{ error: 'Failed to fetch nonce' },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -10,7 +10,7 @@ export async function GET(request: Request) {
if (!message || !signature) {
return NextResponse.json(
{ error: 'Message and signature are required' },
{ status: 400 },
{ status: 400 }
);
}
@ -37,7 +37,7 @@ export async function GET(request: Request) {
console.error('Error in session-signers API:', error);
return NextResponse.json(
{ error: 'Failed to fetch signers' },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -10,7 +10,7 @@ export async function POST() {
console.error('Error fetching signer:', error);
return NextResponse.json(
{ error: 'Failed to fetch signer' },
{ status: 500 },
{ status: 500 }
);
}
}
@ -22,7 +22,7 @@ export async function GET(request: Request) {
if (!signerUuid) {
return NextResponse.json(
{ error: 'signerUuid is required' },
{ status: 400 },
{ status: 400 }
);
}
@ -36,7 +36,7 @@ export async function GET(request: Request) {
console.error('Error fetching signed key:', error);
return NextResponse.json(
{ error: 'Failed to fetch signed key' },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -1,10 +1,10 @@
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';
import { getNeynarClient } from '~/lib/neynar';
const postRequiredFields = ['signerUuid', 'publicKey'];
@ -16,7 +16,7 @@ export async function POST(request: Request) {
if (!body[field]) {
return NextResponse.json(
{ error: `${field} is required` },
{ status: 400 },
{ status: 400 }
);
}
}
@ -26,7 +26,7 @@ export async function POST(request: Request) {
if (redirectUrl && typeof redirectUrl !== 'string') {
return NextResponse.json(
{ error: 'redirectUrl must be a string' },
{ status: 400 },
{ status: 400 }
);
}
@ -38,7 +38,7 @@ export async function POST(request: Request) {
if (!seedPhrase) {
return NextResponse.json(
{ error: 'App configuration missing (SEED_PHRASE or FID)' },
{ status: 500 },
{ status: 500 }
);
}
@ -85,7 +85,7 @@ export async function POST(request: Request) {
console.error('Error registering signed key:', error);
return NextResponse.json(
{ error: 'Failed to register signed key' },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -13,7 +13,7 @@ export async function GET(request: Request) {
{
error: `${param} parameter is required`,
},
{ status: 400 },
{ status: 400 }
);
}
}
@ -32,7 +32,7 @@ export async function GET(request: Request) {
console.error('Error fetching signers:', error);
return NextResponse.json(
{ error: 'Failed to fetch signers' },
{ status: 500 },
{ status: 500 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { createClient, Errors } from '@farcaster/quick-auth';
const client = createClient();
export async function POST(request: Request) {
try {
const { token } = await request.json();
if (!token) {
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
}
// Get domain from environment or request
const domain = process.env.NEXT_PUBLIC_URL
? new URL(process.env.NEXT_PUBLIC_URL).hostname
: request.headers.get('host') || 'localhost';
try {
// Use the official QuickAuth library to verify the JWT
const payload = await client.verifyJwt({
token,
domain,
});
return NextResponse.json({
success: true,
user: {
fid: payload.sub,
},
});
} catch (e) {
if (e instanceof Errors.InvalidTokenError) {
console.info('Invalid token:', e.message);
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
throw e;
}
} catch (error) {
console.error('Token validation error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}

View File

@ -4,21 +4,18 @@ export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url);
const fid = searchParams.get('fid');
if (!apiKey) {
return NextResponse.json(
{
error:
'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.',
},
{ status: 500 },
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
{ status: 500 }
);
}
if (!fid) {
return NextResponse.json(
{ error: 'FID parameter is required' },
{ status: 400 },
{ status: 400 }
);
}
@ -27,28 +24,23 @@ export async function GET(request: Request) {
`https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`,
{
headers: {
'x-api-key': apiKey,
"x-api-key": apiKey,
},
},
}
);
if (!response.ok) {
throw new Error(`Neynar API error: ${response.statusText}`);
}
const { users } = (await response.json()) as {
users: { user: { fid: number; username: string } }[];
};
const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] };
return NextResponse.json({ bestFriends: users });
} catch (error) {
console.error('Failed to fetch best friends:', error);
return NextResponse.json(
{
error:
'Failed to fetch best friends. Please check your Neynar API key and try again.',
},
{ status: 500 },
{ error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' },
{ status: 500 }
);
}
}
}

View File

@ -1,6 +1,6 @@
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import { getNeynarUser } from '~/lib/neynar';
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
import { getNeynarUser } from "~/lib/neynar";
export const dynamic = 'force-dynamic';
@ -15,24 +15,16 @@ export async function GET(request: NextRequest) {
<div tw="flex h-full w-full flex-col justify-center items-center relative bg-primary">
{user?.pfp_url && (
<div tw="flex w-96 h-96 rounded-full overflow-hidden mb-8 border-8 border-white">
<img
src={user.pfp_url}
alt="Profile"
tw="w-full h-full object-cover"
/>
<img src={user.pfp_url} alt="Profile" tw="w-full h-full object-cover" />
</div>
)}
<h1 tw="text-8xl text-white">
{user?.display_name
? `Hello from ${user.display_name ?? user.username}!`
: 'Hello!'}
</h1>
<h1 tw="text-8xl text-white">{user?.display_name ? `Hello from ${user.display_name ?? user.username}!` : 'Hello!'}</h1>
<p tw="text-5xl mt-4 text-white opacity-80">Powered by Neynar 🪐</p>
</div>
),
{
width: 1200,
height: 800,
},
}
);
}
}

View File

@ -1,9 +1,9 @@
import { NextRequest } from 'next/server';
import { notificationDetailsSchema } from '@farcaster/miniapp-sdk';
import { z } from 'zod';
import { setUserNotificationDetails } from '~/lib/kv';
import { sendNeynarMiniAppNotification } from '~/lib/neynar';
import { sendMiniAppNotification } from '~/lib/notifs';
import { notificationDetailsSchema } from "@farcaster/miniapp-sdk";
import { NextRequest } from "next/server";
import { z } from "zod";
import { setUserNotificationDetails } from "~/lib/kv";
import { sendMiniAppNotification } from "~/lib/notifs";
import { sendNeynarMiniAppNotification } from "~/lib/neynar";
const requestSchema = z.object({
fid: z.number(),
@ -13,8 +13,7 @@ const requestSchema = z.object({
export async function POST(request: NextRequest) {
// If Neynar is enabled, we don't need to store notification details
// as they will be managed by Neynar's system
const neynarEnabled =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
const requestJson = await request.json();
const requestBody = requestSchema.safeParse(requestJson);
@ -22,7 +21,7 @@ export async function POST(request: NextRequest) {
if (requestBody.success === false) {
return Response.json(
{ success: false, errors: requestBody.error.errors },
{ status: 400 },
{ status: 400 }
);
}
@ -30,29 +29,27 @@ export async function POST(request: NextRequest) {
if (!neynarEnabled) {
await setUserNotificationDetails(
Number(requestBody.data.fid),
requestBody.data.notificationDetails,
requestBody.data.notificationDetails
);
}
// Use appropriate notification function based on Neynar status
const sendNotification = neynarEnabled
? sendNeynarMiniAppNotification
: sendMiniAppNotification;
const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification;
const sendResult = await sendNotification({
fid: Number(requestBody.data.fid),
title: 'Test notification',
body: 'Sent at ' + new Date().toISOString(),
title: "Test notification",
body: "Sent at " + new Date().toISOString(),
});
if (sendResult.state === 'error') {
if (sendResult.state === "error") {
return Response.json(
{ success: false, error: sendResult.error },
{ status: 500 },
{ status: 500 }
);
} else if (sendResult.state === 'rate_limit') {
} else if (sendResult.state === "rate_limit") {
return Response.json(
{ success: false, error: 'Rate limited' },
{ status: 429 },
{ success: false, error: "Rate limited" },
{ status: 429 }
);
}

View File

@ -1,32 +1,29 @@
import { NextResponse } from 'next/server';
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url);
const fids = searchParams.get('fids');
if (!apiKey) {
return NextResponse.json(
{
error:
'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.',
},
{ status: 500 },
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
{ status: 500 }
);
}
if (!fids) {
return NextResponse.json(
{ error: 'FIDs parameter is required' },
{ status: 400 },
{ status: 400 }
);
}
try {
const neynar = new NeynarAPIClient({ apiKey });
const fidsArray = fids.split(',').map(fid => parseInt(fid.trim()));
const { users } = await neynar.fetchBulkUsers({
fids: fidsArray,
});
@ -35,11 +32,8 @@ export async function GET(request: Request) {
} catch (error) {
console.error('Failed to fetch users:', error);
return NextResponse.json(
{
error:
'Failed to fetch users. Please check your Neynar API key and try again.',
},
{ status: 500 },
{ error: 'Failed to fetch users. Please check your Neynar API key and try again.' },
{ status: 500 }
);
}
}

View File

@ -1,21 +1,20 @@
import { NextRequest } from 'next/server';
import {
ParseWebhookEvent,
parseWebhookEvent,
verifyAppKeyWithNeynar,
} from '@farcaster/miniapp-node';
import { APP_NAME } from '~/lib/constants';
} from "@farcaster/miniapp-node";
import { NextRequest } from "next/server";
import { APP_NAME } from "~/lib/constants";
import {
deleteUserNotificationDetails,
setUserNotificationDetails,
} from '~/lib/kv';
import { sendMiniAppNotification } from '~/lib/notifs';
} from "~/lib/kv";
import { sendMiniAppNotification } from "~/lib/notifs";
export async function POST(request: NextRequest) {
// If Neynar is enabled, we don't need to handle webhooks here
// as they will be handled by Neynar's webhook endpoint
const neynarEnabled =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
if (neynarEnabled) {
return Response.json({ success: true });
}
@ -29,24 +28,24 @@ export async function POST(request: NextRequest) {
const error = e as ParseWebhookEvent.ErrorType;
switch (error.name) {
case 'VerifyJsonFarcasterSignature.InvalidDataError':
case 'VerifyJsonFarcasterSignature.InvalidEventDataError':
case "VerifyJsonFarcasterSignature.InvalidDataError":
case "VerifyJsonFarcasterSignature.InvalidEventDataError":
// The request data is invalid
return Response.json(
{ success: false, error: error.message },
{ status: 400 },
{ status: 400 }
);
case 'VerifyJsonFarcasterSignature.InvalidAppKeyError':
case "VerifyJsonFarcasterSignature.InvalidAppKeyError":
// The app key is invalid
return Response.json(
{ success: false, error: error.message },
{ status: 401 },
{ status: 401 }
);
case 'VerifyJsonFarcasterSignature.VerifyAppKeyError':
case "VerifyJsonFarcasterSignature.VerifyAppKeyError":
// Internal error verifying the app key (caller may want to try again)
return Response.json(
{ success: false, error: error.message },
{ status: 500 },
{ status: 500 }
);
}
}
@ -57,33 +56,33 @@ export async function POST(request: NextRequest) {
// Only handle notifications if Neynar is not enabled
// When Neynar is enabled, notifications are handled through their webhook
switch (event.event) {
case 'frame_added':
case "frame_added":
if (event.notificationDetails) {
await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({
fid,
title: `Welcome to ${APP_NAME}`,
body: 'Mini app is now added to your client',
body: "Mini app is now added to your client",
});
} else {
await deleteUserNotificationDetails(fid);
}
break;
case 'frame_removed':
case "frame_removed":
await deleteUserNotificationDetails(fid);
break;
case 'notifications_enabled':
case "notifications_enabled":
await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({
fid,
title: `Welcome to ${APP_NAME}`,
body: 'Notifications are now enabled',
body: "Notifications are now enabled",
});
break;
case 'notifications_disabled':
case "notifications_disabled":
await deleteUserNotificationDetails(fid);
break;
}

View File

@ -1,15 +1,15 @@
'use client';
"use client";
import dynamic from 'next/dynamic';
import { APP_NAME } from '~/lib/constants';
import dynamic from "next/dynamic";
import { APP_NAME } from "~/lib/constants";
// note: dynamic import is required for components that use the Frame SDK
const AppComponent = dynamic(() => import('~/components/App'), {
const AppComponent = dynamic(() => import("~/components/App"), {
ssr: false,
});
export default function App(
{ title }: { title?: string } = { title: APP_NAME },
{ title }: { title?: string } = { title: APP_NAME }
) {
return <AppComponent title={title} />;
}

View File

@ -62,11 +62,11 @@ body {
.container {
@apply mx-auto max-w-md px-4;
}
.container-wide {
@apply mx-auto max-w-lg px-4;
}
.container-narrow {
@apply mx-auto max-w-sm px-4;
}
@ -75,7 +75,7 @@ body {
.card {
@apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm;
}
.card-primary {
@apply bg-primary/10 border-primary/20;
}
@ -84,15 +84,15 @@ body {
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none;
}
.btn-primary {
@apply bg-primary text-white hover:bg-primary-dark focus:ring-primary;
}
.btn-secondary {
@apply bg-secondary text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-secondary-dark dark:text-gray-100 dark:hover:bg-gray-600;
}
.btn-outline {
@apply border border-gray-300 bg-transparent hover:bg-gray-50 focus:ring-gray-500 dark:border-gray-600 dark:hover:bg-gray-800;
}
@ -106,7 +106,7 @@ body {
.spinner {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary;
}
.spinner-primary {
@apply animate-spin rounded-full border-2 border-white border-t-transparent;
}

View File

@ -1,7 +1,9 @@
import type { Metadata } from 'next';
import '~/app/globals.css';
import { Providers } from '~/app/providers';
import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
import type { Metadata } from "next";
import { getSession } from "~/auth"
import "~/app/globals.css";
import { Providers } from "~/app/providers";
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
export const metadata: Metadata = {
title: APP_NAME,
@ -12,11 +14,13 @@ export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}>) {
const session = await getSession()
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
<Providers session={session}>{children}</Providers>
</body>
</html>
);

View File

@ -1,7 +1,7 @@
import { Metadata } from 'next';
import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from '~/lib/constants';
import { getMiniAppEmbedMetadata } from '~/lib/utils';
import App from './app';
import { Metadata } from "next";
import App from "./app";
import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants";
import { getMiniAppEmbedMetadata } from "~/lib/utils";
export const revalidate = 300;
@ -14,11 +14,11 @@ export async function generateMetadata(): Promise<Metadata> {
images: [APP_OG_IMAGE_URL],
},
other: {
'fc:frame': JSON.stringify(getMiniAppEmbedMetadata()),
"fc:frame": JSON.stringify(getMiniAppEmbedMetadata()),
},
};
}
export default function Home() {
return <App />;
return (<App />);
}

View File

@ -1,30 +1,41 @@
'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 { AuthKitProvider } from '@farcaster/auth-kit';
const WagmiProvider = dynamic(
() => import('~/components/providers/WagmiProvider'),
{
ssr: false,
},
}
);
export function Providers({ children }: { children: React.ReactNode }) {
export function Providers({
session,
children,
}: {
session: Session | null;
children: React.ReactNode;
}) {
const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
return (
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
{children}
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
<SessionProvider session={session}>
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
</SessionProvider>
);
}

View File

@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import type { Metadata } from 'next';
import { APP_URL, APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
import { getMiniAppEmbedMetadata } from '~/lib/utils';
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { APP_URL, APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
import { getMiniAppEmbedMetadata } from "~/lib/utils";
export const revalidate = 300;
// This is an example of how to generate a dynamically generated share page based on fid:
@ -23,12 +23,12 @@ export async function generateMetadata({
images: [imageUrl],
},
other: {
'fc:frame': JSON.stringify(getMiniAppEmbedMetadata(imageUrl)),
"fc:frame": JSON.stringify(getMiniAppEmbedMetadata(imageUrl)),
},
};
}
export default function SharePage() {
// redirect to home page
redirect('/');
redirect("/");
}

View File

@ -1,10 +1,439 @@
import { sdk } from '@farcaster/miniapp-sdk';
import { AuthOptions, getServerSession } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { createAppClient, viemConnector } from '@farcaster/auth-client';
// Export QuickAuth from the SDK
export const quickAuth = sdk.quickAuth;
declare module 'next-auth' {
interface Session {
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<Record<string, unknown>>;
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;
}[];
}
// Helper function to get session (for server-side compatibility)
export const getSession = async () => {
// For QuickAuth, sessions are managed by the SDK
return null;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
power_badge?: boolean;
url?: string;
experimental?: {
neynar_user_score: number;
deprecation_notice: string;
};
score?: number;
};
}
}
function getDomainFromUrl(urlString: string | undefined): string {
if (!urlString) {
console.warn('NEXTAUTH_URL is not set, using localhost:3000 as fallback');
return 'localhost:3000';
}
try {
const url = new URL(urlString);
return url.hostname;
} catch (error) {
console.error('Invalid NEXTAUTH_URL:', urlString, error);
console.warn('Using localhost:3000 as fallback');
return 'localhost:3000';
}
}
export const authOptions: AuthOptions = {
// Configure one or more authentication providers
providers: [
CredentialsProvider({
id: 'farcaster',
name: 'Sign in with Farcaster',
credentials: {
message: {
label: 'Message',
type: 'text',
placeholder: '0x0',
},
signature: {
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',
},
pfp: {
label: 'Pfp',
type: 'text',
placeholder: '0x0',
},
},
async authorize(credentials, req) {
const nonce = req?.body?.csrfToken;
if (!nonce) {
console.error('No nonce or CSRF token provided');
return null;
}
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;
}
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 }) => {
// 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,
},
},
callbackUrl: {
name: `next-auth.callback-url`,
options: {
sameSite: 'none',
path: '/',
secure: true,
},
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'none',
path: '/',
secure: true,
},
},
},
};
export const getSession = async () => {
try {
return await getServerSession(authOptions);
} catch (error) {
console.error('Error getting server session:', error);
return null;
}
};

View File

@ -1,24 +1,19 @@
'use client';
"use client";
import { useEffect } from 'react';
import { useMiniApp } from '@neynar/react';
import { Footer } from '~/components/ui/Footer';
import { Header } from '~/components/ui/Header';
import {
HomeTab,
ActionsTab,
ContextTab,
WalletTab,
} from '~/components/ui/tabs';
import { USE_WALLET } from '~/lib/constants';
import { useNeynarUser } from '../hooks/useNeynarUser';
import { useEffect } from "react";
import { useMiniApp } from "@neynar/react";
import { Header } from "~/components/ui/Header";
import { Footer } from "~/components/ui/Footer";
import { HomeTab, ActionsTab, ContextTab, WalletTab } from "~/components/ui/tabs";
import { USE_WALLET } from "~/lib/constants";
import { useNeynarUser } from "../hooks/useNeynarUser";
// --- Types ---
export enum Tab {
Home = 'home',
Actions = 'actions',
Context = 'context',
Wallet = 'wallet',
Home = "home",
Actions = "actions",
Context = "context",
Wallet = "wallet",
}
export interface AppProps {
@ -27,39 +22,44 @@ export interface AppProps {
/**
* App component serves as the main container for the mini app interface.
*
*
* This component orchestrates the overall mini app experience by:
* - Managing tab navigation and state
* - Handling Farcaster mini app initialization
* - Coordinating wallet and context state
* - Providing error handling and loading states
* - Rendering the appropriate tab content based on user selection
*
*
* The component integrates with the Neynar SDK for Farcaster functionality
* and Wagmi for wallet management. It provides a complete mini app
* experience with multiple tabs for different functionality areas.
*
*
* Features:
* - Tab-based navigation (Home, Actions, Context, Wallet)
* - Farcaster mini app integration
* - Wallet connection management
* - Error handling and display
* - Loading states for async operations
*
*
* @param props - Component props
* @param props.title - Optional title for the mini app (defaults to "Neynar Starter Kit")
*
*
* @example
* ```tsx
* <App title="My Mini App" />
* ```
*/
export default function App(
{ title }: AppProps = { title: 'Neynar Starter Kit' },
{ title }: AppProps = { title: "Neynar Starter Kit" }
) {
// --- Hooks ---
const { isSDKLoaded, context, setInitialTab, setActiveTab, currentTab } =
useMiniApp();
const {
isSDKLoaded,
context,
setInitialTab,
setActiveTab,
currentTab,
} = useMiniApp();
// --- Neynar user hook ---
const { user: neynarUser } = useNeynarUser(context || undefined);
@ -67,7 +67,7 @@ export default function App(
// --- Effects ---
/**
* Sets the initial tab to "home" when the SDK is loaded.
*
*
* This effect ensures that users start on the home tab when they first
* load the mini app. It only runs when the SDK is fully loaded to
* prevent errors during initialization.
@ -115,12 +115,9 @@ export default function App(
{currentTab === Tab.Wallet && <WalletTab />}
{/* Footer with navigation */}
<Footer
activeTab={currentTab as Tab}
setActiveTab={setActiveTab}
showWallet={USE_WALLET}
/>
<Footer activeTab={currentTab as Tab} setActiveTab={setActiveTab} showWallet={USE_WALLET} />
</div>
</div>
);
}

View File

@ -1,13 +1,10 @@
import React, { createContext, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import React, { createContext, useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { sdk } from '@farcaster/miniapp-sdk';
const FarcasterSolanaProvider = dynamic(
() =>
import('@farcaster/mini-app-solana').then(
mod => mod.FarcasterSolanaProvider,
),
{ ssr: false },
() => import('@farcaster/mini-app-solana').then(mod => mod.FarcasterSolanaProvider),
{ ssr: false }
);
type SafeFarcasterSolanaProviderProps = {
@ -15,15 +12,10 @@ type SafeFarcasterSolanaProviderProps = {
children: React.ReactNode;
};
const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({
hasSolanaProvider: false,
});
const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ hasSolanaProvider: false });
export function SafeFarcasterSolanaProvider({
endpoint,
children,
}: SafeFarcasterSolanaProviderProps) {
const isClient = typeof window !== 'undefined';
export function SafeFarcasterSolanaProvider({ endpoint, children }: SafeFarcasterSolanaProviderProps) {
const isClient = typeof window !== "undefined";
const [hasSolanaProvider, setHasSolanaProvider] = useState<boolean>(false);
const [checked, setChecked] = useState<boolean>(false);
@ -56,8 +48,8 @@ export function SafeFarcasterSolanaProvider({
const origError = console.error;
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('WalletConnectionError: could not get Solana provider')
typeof args[0] === "string" &&
args[0].includes("WalletConnectionError: could not get Solana provider")
) {
if (!errorShown) {
origError(...args);

View File

@ -1,12 +1,12 @@
import React from 'react';
import { useEffect, useState } from 'react';
import { farcasterFrame } from '@farcaster/miniapp-wagmi-connector';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createConfig, http, WagmiProvider } from 'wagmi';
import { useConnect, useAccount } from 'wagmi';
import { base, degen, mainnet, optimism, unichain, celo } from 'wagmi/chains';
import { createConfig, http, WagmiProvider } from "wagmi";
import { base, degen, mainnet, optimism, unichain, celo } from "wagmi/chains";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { farcasterFrame } from "@farcaster/miniapp-wagmi-connector";
import { coinbaseWallet, metaMask } from 'wagmi/connectors';
import { APP_NAME, APP_ICON_URL, APP_URL } from '~/lib/constants';
import { APP_NAME, APP_ICON_URL, APP_URL } from "~/lib/constants";
import { useEffect, useState } from "react";
import { useConnect, useAccount } from "wagmi";
import React from "react";
// Custom hook for Coinbase Wallet detection and auto-connection
function useCoinbaseWalletAutoConnect() {
@ -17,16 +17,15 @@ function useCoinbaseWalletAutoConnect() {
useEffect(() => {
// Check if we're running in Coinbase Wallet
const checkCoinbaseWallet = () => {
const isInCoinbaseWallet =
window.ethereum?.isCoinbaseWallet ||
const isInCoinbaseWallet = window.ethereum?.isCoinbaseWallet ||
window.ethereum?.isCoinbaseWalletExtension ||
window.ethereum?.isCoinbaseWalletBrowser;
setIsCoinbaseWallet(!!isInCoinbaseWallet);
};
checkCoinbaseWallet();
window.addEventListener('ethereum#initialized', checkCoinbaseWallet);
return () => {
window.removeEventListener('ethereum#initialized', checkCoinbaseWallet);
};
@ -71,11 +70,7 @@ export const config = createConfig({
const queryClient = new QueryClient();
// Wrapper component that provides Coinbase Wallet auto-connection
function CoinbaseWalletAutoConnect({
children,
}: {
children: React.ReactNode;
}) {
function CoinbaseWalletAutoConnect({ children }: { children: React.ReactNode }) {
useCoinbaseWalletAutoConnect();
return <>{children}</>;
}
@ -84,7 +79,9 @@ export default function Provider({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<CoinbaseWalletAutoConnect>{children}</CoinbaseWalletAutoConnect>
<CoinbaseWalletAutoConnect>
{children}
</CoinbaseWalletAutoConnect>
</QueryClientProvider>
</WagmiProvider>
);

View File

@ -5,40 +5,43 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg';
}
export function Button({
children,
className = '',
isLoading = false,
export function Button({
children,
className = "",
isLoading = false,
variant = 'primary',
size = 'md',
...props
...props
}: ButtonProps) {
const baseClasses = 'btn';
const baseClasses = "btn";
const variantClasses = {
primary: 'btn-primary',
secondary: 'btn-secondary',
outline: 'btn-outline',
primary: "btn-primary",
secondary: "btn-secondary",
outline: "btn-outline"
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base"
};
const fullWidthClasses = 'w-full max-w-xs mx-auto block';
const fullWidthClasses = "w-full max-w-xs mx-auto block";
const combinedClasses = [
baseClasses,
variantClasses[variant],
sizeClasses[size],
fullWidthClasses,
className,
className
].join(' ');
return (
<button className={combinedClasses} {...props}>
<button
className={combinedClasses}
{...props}
>
{isLoading ? (
<div className="flex items-center justify-center">
<div className="spinner-primary h-5 w-5" />

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Tab } from '~/components/App';
import React from "react";
import { Tab } from "~/components/App";
interface FooterProps {
activeTab: Tab;
@ -7,19 +7,13 @@ interface FooterProps {
showWallet?: boolean;
}
export const Footer: React.FC<FooterProps> = ({
activeTab,
setActiveTab,
showWallet = false,
}) => (
export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWallet = false }) => (
<div className="fixed bottom-0 left-0 right-0 mx-4 mb-4 bg-gray-100 dark:bg-gray-800 border-[3px] border-double border-primary px-2 py-2 rounded-lg z-50">
<div className="flex justify-around items-center h-14">
<button
onClick={() => setActiveTab(Tab.Home)}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === Tab.Home
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
activeTab === Tab.Home ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
<span className="text-xl">🏠</span>
@ -28,9 +22,7 @@ export const Footer: React.FC<FooterProps> = ({
<button
onClick={() => setActiveTab(Tab.Actions)}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === Tab.Actions
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
activeTab === Tab.Actions ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
<span className="text-xl"></span>
@ -39,9 +31,7 @@ export const Footer: React.FC<FooterProps> = ({
<button
onClick={() => setActiveTab(Tab.Context)}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === Tab.Context
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
activeTab === Tab.Context ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
<span className="text-xl">📋</span>
@ -51,9 +41,7 @@ export const Footer: React.FC<FooterProps> = ({
<button
onClick={() => setActiveTab(Tab.Wallet)}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === Tab.Wallet
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
activeTab === Tab.Wallet ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
}`}
>
<span className="text-xl">👛</span>

View File

@ -1,10 +1,9 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import sdk from '@farcaster/miniapp-sdk';
import { useMiniApp } from '@neynar/react';
import { APP_NAME } from '~/lib/constants';
import { useState } from "react";
import { APP_NAME } from "~/lib/constants";
import sdk from "@farcaster/miniapp-sdk";
import { useMiniApp } from "@neynar/react";
type HeaderProps = {
neynarUser?: {
@ -19,19 +18,23 @@ export function Header({ neynarUser }: HeaderProps) {
return (
<div className="relative">
<div className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary">
<div className="text-lg font-light">Welcome to {APP_NAME}!</div>
<div
className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary"
>
<div className="text-lg font-light">
Welcome to {APP_NAME}!
</div>
{context?.user && (
<div
<div
className="cursor-pointer"
onClick={() => {
setIsUserDropdownOpen(!isUserDropdownOpen);
}}
>
{context.user.pfpUrl && (
<Image
src={context.user.pfpUrl}
alt="Profile"
<img
src={context.user.pfpUrl}
alt="Profile"
className="w-10 h-10 rounded-full border-2 border-primary"
/>
)}
@ -39,16 +42,14 @@ export function Header({ neynarUser }: HeaderProps) {
)}
</div>
{context?.user && (
<>
<>
{isUserDropdownOpen && (
<div className="absolute top-full right-0 z-50 w-fit mt-1 mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3 space-y-2">
<div className="text-right">
<h3
<h3
className="font-bold text-sm hover:underline cursor-pointer inline-block"
onClick={() =>
sdk.actions.viewProfile({ fid: context.user.fid })
}
onClick={() => sdk.actions.viewProfile({ fid: context.user.fid })}
>
{context.user.displayName || context.user.username}
</h3>

View File

@ -169,7 +169,7 @@ export function AuthDialog({
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(
content.qrUrl,
content.qrUrl
)}`}
alt="QR Code"
className="w-48 h-48"
@ -197,13 +197,13 @@ export function AuthDialog({
content.qrUrl
.replace(
'https://farcaster.xyz/',
'https://client.farcaster.xyz/deeplinks/',
'https://client.farcaster.xyz/deeplinks/'
)
.replace(
'https://client.farcaster.xyz/deeplinks/signed-key-request',
'https://farcaster.xyz/~/connect',
'https://farcaster.xyz/~/connect'
),
'_blank',
'_blank'
);
}
}}

View File

@ -16,14 +16,8 @@ export function ProfileButton({
useDetectClickOutside(ref, () => setShowDropdown(false));
const name =
userData?.username && userData.username.trim() !== ''
? userData.username
: `!${userData?.fid}`;
const pfpUrl =
userData?.pfpUrl && userData.pfpUrl.trim() !== ''
? userData.pfpUrl
: 'https://farcaster.xyz/avatar.png';
const name = userData?.username ?? `!${userData?.fid}`;
const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png';
return (
<div className="relative" ref={ref}>
@ -33,7 +27,7 @@ export function ProfileButton({
'flex items-center gap-3 px-4 py-2 min-w-0 rounded-lg',
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100',
'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
'focus:outline-none focus:ring-1 focus:ring-primary',
'focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
@ -41,7 +35,7 @@ export function ProfileButton({
src={pfpUrl}
alt="Profile"
className="w-6 h-6 rounded-full object-cover flex-shrink-0"
onError={e => {
onError={(e) => {
(e.target as HTMLImageElement).src =
'https://farcaster.xyz/avatar.png';
}}
@ -52,7 +46,7 @@ export function ProfileButton({
<svg
className={cn(
'w-4 h-4 transition-transform flex-shrink-0',
showDropdown && 'rotate-180',
showDropdown && 'rotate-180'
)}
fill="none"
stroke="currentColor"

View File

@ -1,12 +1,12 @@
'use client';
import '@farcaster/auth-kit/styles.css';
import { useSignIn } from '@farcaster/auth-kit';
import { useCallback, useEffect, useState } from 'react';
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 { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
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 {
@ -14,7 +14,7 @@ import {
signOut as backendSignOut,
useSession,
} from 'next-auth/react';
import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
type User = {
fid: number;
@ -102,16 +102,18 @@ export function NeynarAuthButton() {
// New state for unified dialog flow
const [showDialog, setShowDialog] = useState(false);
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
'loading',
'loading'
);
const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>(
null,
null
);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null,
null
);
const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(null);
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
const signerFlowStartedRef = useRef(false);
// Determine which flow to use based on context
const useBackendFlow = context !== undefined;
@ -139,7 +141,7 @@ export function NeynarAuthButton() {
const updateSessionWithSigners = useCallback(
async (
signers: StoredAuthState['signers'],
_user: StoredAuthState['user'],
user: StoredAuthState['user']
) => {
if (!useBackendFlow) return;
@ -180,7 +182,7 @@ export function NeynarAuthButton() {
return null;
}
},
[],
[]
);
// Helper function to generate signed key request
@ -208,7 +210,7 @@ export function NeynarAuthButton() {
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to generate signed key request: ${errorData.error}`,
`Failed to generate signed key request: ${errorData.error}`
);
}
@ -220,7 +222,7 @@ export function NeynarAuthButton() {
// throw error;
}
},
[],
[]
);
// Helper function to fetch all signers
@ -231,10 +233,10 @@ export function NeynarAuthButton() {
const endpoint = useBackendFlow
? `/api/auth/session-signers?message=${encodeURIComponent(
message,
message
)}&signature=${signature}`
: `/api/auth/signers?message=${encodeURIComponent(
message,
message
)}&signature=${signature}`;
const response = await fetch(endpoint);
@ -256,7 +258,7 @@ export function NeynarAuthButton() {
if (signerData.signers && signerData.signers.length > 0) {
const fetchedUser = (await fetchUserData(
signerData.signers[0].fid,
signerData.signers[0].fid
)) as StoredAuthState['user'];
user = fetchedUser;
}
@ -283,20 +285,52 @@ export function NeynarAuthButton() {
setSignersLoading(false);
}
},
[useBackendFlow, fetchUserData, updateSessionWithSigners],
[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}`,
`/api/auth/signer?signerUuid=${signerUuid}`
);
if (!response.ok) {
throw new Error('Failed to poll signer status');
// 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();
@ -318,7 +352,7 @@ export function NeynarAuthButton() {
setPollingInterval(interval);
},
[fetchAllSigners]
[fetchAllSigners, pollingInterval]
);
// Cleanup polling on unmount
@ -327,6 +361,7 @@ export function NeynarAuthButton() {
if (pollingInterval) {
clearInterval(pollingInterval);
}
signerFlowStartedRef.current = false;
};
}, [pollingInterval]);
@ -361,11 +396,11 @@ export function NeynarAuthButton() {
// Success callback - this is critical!
const onSuccessCallback = useCallback(
async (res: unknown) => {
async (res: UseSignInData) => {
if (!useBackendFlow) {
// Only handle localStorage for frontend flow
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const user = await fetchUserData(res.fid);
const user = res.fid ? await fetchUserData(res.fid) : null;
const authState: StoredAuthState = {
...existingAuth,
isAuthenticated: true,
@ -408,6 +443,11 @@ export function NeynarAuthButton() {
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
@ -419,8 +459,16 @@ export function NeynarAuthButton() {
// Handle fetching signers after successful authentication
useEffect(() => {
if (message && signature) {
if (
message &&
signature &&
!isSignerFlowRunning &&
!signerFlowStartedRef.current
) {
signerFlowStartedRef.current = true;
const handleSignerFlow = async () => {
setIsSignerFlowRunning(true);
try {
const clientContext = context?.client as Record<string, unknown>;
const isMobileContext =
@ -436,6 +484,7 @@ export function NeynarAuthButton() {
// 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
@ -446,7 +495,7 @@ export function NeynarAuthButton() {
// Step 2: Generate signed key request
const signedKeyData = await generateSignedKeyRequest(
newSigner.signer_uuid,
newSigner.public_key,
newSigner.public_key
);
// Step 3: Show QR code in access dialog for signer approval
@ -456,8 +505,8 @@ export function NeynarAuthButton() {
setShowDialog(false);
await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/',
'farcaster://'
'https://client.farcaster.xyz/deeplinks/signed-key-request',
'https://farcaster.xyz/~/connect'
)
);
} else {
@ -480,21 +529,14 @@ export function NeynarAuthButton() {
setSignersLoading(false);
setShowDialog(false);
setSignerApprovalUrl(null);
} finally {
setIsSignerFlowRunning(false);
}
};
handleSignerFlow();
}
}, [
message,
signature,
fetchAllSigners,
createSigner,
generateSignedKeyRequest,
startPolling,
context,
useBackendFlow,
]);
}, [message, signature]); // Simplified dependencies
// Backend flow using NextAuth
const handleBackendSignIn = useCallback(async () => {
@ -567,6 +609,9 @@ export function NeynarAuthButton() {
clearInterval(pollingInterval);
setPollingInterval(null);
}
// Reset signer flow flag
signerFlowStartedRef.current = false;
} catch (error) {
console.error('❌ Error during sign out:', error);
// Optionally handle error state
@ -662,4 +707,4 @@ export function NeynarAuthButton() {
}
</>
);
}
}

View File

@ -1,10 +1,10 @@
'use client';
import { useCallback, useState, useEffect } from 'react';
import { type ComposeCast } from '@farcaster/miniapp-sdk';
import { useMiniApp } from '@neynar/react';
import { APP_URL } from '~/lib/constants';
import { Button } from './Button';
import { useMiniApp } from '@neynar/react';
import { type ComposeCast } from "@farcaster/miniapp-sdk";
import { APP_URL } from '~/lib/constants';
interface EmbedConfig {
path?: string;
@ -24,16 +24,9 @@ interface ShareButtonProps {
isLoading?: boolean;
}
export function ShareButton({
buttonText,
cast,
className = '',
isLoading = false,
}: ShareButtonProps) {
export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [bestFriends, setBestFriends] = useState<
{ fid: number; username: string }[] | null
>(null);
const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null);
const [isLoadingBestFriends, setIsLoadingBestFriends] = useState(false);
const { context, actions } = useMiniApp();
@ -59,7 +52,7 @@ export function ShareButton({
if (cast.bestFriends) {
if (bestFriends) {
// Replace @N with usernames, or remove if no matching friend
finalText = finalText.replace(/@\d+/g, match => {
finalText = finalText.replace(/@\d+/g, (match) => {
const friendIndex = parseInt(match.slice(1)) - 1;
const friend = bestFriends[friendIndex];
if (friend) {
@ -75,7 +68,7 @@ export function ShareButton({
// Process embeds
const processedEmbeds = await Promise.all(
(cast.embeds || []).map(async embed => {
(cast.embeds || []).map(async (embed) => {
if (typeof embed === 'string') {
return embed;
}
@ -84,10 +77,7 @@ export function ShareButton({
const url = new URL(`${baseUrl}${embed.path}`);
// Add UTM parameters
url.searchParams.set(
'utm_source',
`share-cast-${context?.user?.fid || 'unknown'}`,
);
url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`);
// If custom image generator is provided, use it
if (embed.imageUrl) {
@ -98,7 +88,7 @@ export function ShareButton({
return url.toString();
}
return embed.url || '';
}),
})
);
// Open cast composer with all supported intents

View File

@ -1,21 +1,22 @@
import * as React from 'react';
import { cn } from '~/lib/utils';
import * as React from "react"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
import { cn } from "~/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-base ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300',
className,
"flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-base ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300",
className
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
)
}
)
Input.displayName = "Input"
export { Input };
export { Input }

View File

@ -1,13 +1,14 @@
'use client';
"use client"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@ -19,7 +20,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label };
export { Label }

View File

@ -1,22 +1,13 @@
'use client';
import { useCallback, useState } from 'react';
import { type Haptics } from '@farcaster/miniapp-sdk';
import { useMiniApp } from '@neynar/react';
import { APP_URL } from '~/lib/constants';
import { Button } from '../Button';
import { ShareButton } from '../Share';
import { Button } from '../Button';
import { SignIn } from '../wallet/SignIn';
// Optional import for NeynarAuthButton - may not exist in all templates
let NeynarAuthButton: React.ComponentType | null = null;
try {
const module = require('../NeynarAuthButton/index');
NeynarAuthButton = module.NeynarAuthButton;
} catch (error) {
// Component doesn't exist, that's okay
console.log('NeynarAuthButton not available in this template');
}
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.
@ -61,7 +52,7 @@ export function ActionsTab() {
* @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;
}
@ -76,22 +67,22 @@ export function ActionsTab() {
}),
});
if (response.status === 200) {
setNotificationState(prev => ({ ...prev, sendStatus: 'Success' }));
setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' }));
return;
} else if (response.status === 429) {
setNotificationState(prev => ({
setNotificationState((prev) => ({
...prev,
sendStatus: 'Rate limited',
}));
return;
}
const responseText = await response.text();
setNotificationState(prev => ({
setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${responseText}`,
}));
} catch (error) {
setNotificationState(prev => ({
setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${error}`,
}));
@ -108,11 +99,11 @@ export function ActionsTab() {
if (context?.user?.fid) {
const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl);
setNotificationState(prev => ({ ...prev, shareUrlCopied: true }));
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
setTimeout(
() =>
setNotificationState(prev => ({ ...prev, shareUrlCopied: false })),
2000,
setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })),
2000
);
}
}, [context?.user?.fid]);
@ -149,7 +140,7 @@ export function ActionsTab() {
<SignIn />
{/* Neynar Authentication */}
{NeynarAuthButton && <NeynarAuthButton />}
<NeynarAuthButton />
{/* Mini app actions */}
<Button
@ -195,9 +186,9 @@ export function ActionsTab() {
</label>
<select
value={selectedHapticIntensity}
onChange={e =>
onChange={(e) =>
setSelectedHapticIntensity(
e.target.value as Haptics.ImpactOccurredType,
e.target.value as Haptics.ImpactOccurredType
)
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"

View File

@ -1,19 +1,19 @@
'use client';
"use client";
import { useMiniApp } from '@neynar/react';
import { useMiniApp } from "@neynar/react";
/**
* ContextTab component displays the current mini app context in JSON format.
*
*
* This component provides a developer-friendly view of the Farcaster mini app context,
* including user information, client details, and other contextual data. It's useful
* for debugging and understanding what data is available to the mini app.
*
*
* The context includes:
* - User information (FID, username, display name, profile picture)
* - Client information (safe area insets, platform details)
* - Mini app configuration and state
*
*
* @example
* ```tsx
* <ContextTab />
@ -21,7 +21,7 @@ import { useMiniApp } from '@neynar/react';
*/
export function ContextTab() {
const { context } = useMiniApp();
return (
<div className="mx-6">
<h2 className="text-lg font-semibold mb-2">Context</h2>
@ -32,4 +32,4 @@ export function ContextTab() {
</div>
</div>
);
}
}

View File

@ -1,12 +1,12 @@
'use client';
"use client";
/**
* HomeTab component displays the main landing content for the mini app.
*
*
* This is the default tab that users see when they first open the mini app.
* It provides a simple welcome message and placeholder content that can be
* customized for specific use cases.
*
*
* @example
* ```tsx
* <HomeTab />
@ -17,10 +17,8 @@ export function HomeTab() {
<div className="flex items-center justify-center h-[calc(100vh-200px)] px-6">
<div className="text-center w-full max-w-md mx-auto">
<p className="text-lg mb-2">Put your content here!</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Powered by Neynar 🪐
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Powered by Neynar 🪐</p>
</div>
</div>
);
}
}

View File

@ -1,32 +1,22 @@
'use client';
"use client";
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useMiniApp } from '@neynar/react';
import { useCallback, useMemo, useState, useEffect } from "react";
import { useAccount, useSendTransaction, useSignTypedData, useWaitForTransactionReceipt, useDisconnect, useConnect, useSwitchChain, useChainId, type Connector } from "wagmi";
import { useWallet as useSolanaWallet } from '@solana/wallet-adapter-react';
import {
useAccount,
useSendTransaction,
useSignTypedData,
useWaitForTransactionReceipt,
useDisconnect,
useConnect,
useSwitchChain,
useChainId,
type Connector,
} from 'wagmi';
import { base, degen, mainnet, optimism, unichain } from 'wagmi/chains';
import { USE_WALLET, APP_NAME } from '../../../lib/constants';
import { renderError } from '../../../lib/errorUtils';
import { truncateAddress } from '../../../lib/truncateAddress';
import { Button } from '../Button';
import { SendEth } from '../wallet/SendEth';
import { SendSolana } from '../wallet/SendSolana';
import { SignEvmMessage } from '../wallet/SignEvmMessage';
import { SignSolanaMessage } from '../wallet/SignSolanaMessage';
import { base, degen, mainnet, optimism, unichain } from "wagmi/chains";
import { Button } from "../Button";
import { truncateAddress } from "../../../lib/truncateAddress";
import { renderError } from "../../../lib/errorUtils";
import { SignEvmMessage } from "../wallet/SignEvmMessage";
import { SendEth } from "../wallet/SendEth";
import { SignSolanaMessage } from "../wallet/SignSolanaMessage";
import { SendSolana } from "../wallet/SendSolana";
import { USE_WALLET, APP_NAME } from "../../../lib/constants";
import { useMiniApp } from "@neynar/react";
/**
* WalletTab component manages wallet-related UI for both EVM and Solana chains.
*
*
* This component provides a comprehensive wallet interface that supports:
* - EVM wallet connections (Farcaster Frame, Coinbase Wallet, MetaMask)
* - Solana wallet integration
@ -34,10 +24,10 @@ import { SignSolanaMessage } from '../wallet/SignSolanaMessage';
* - Transaction sending for both chains
* - Chain switching for EVM chains
* - Auto-connection in Farcaster clients
*
*
* The component automatically detects when running in a Farcaster client
* and attempts to auto-connect using the Farcaster Frame connector.
*
*
* @example
* ```tsx
* <WalletTab />
@ -57,8 +47,7 @@ function WalletStatus({ address, chainId }: WalletStatusProps) {
<>
{address && (
<div className="text-xs w-full">
Address:{' '}
<pre className="inline w-full">{truncateAddress(address)}</pre>
Address: <pre className="inline w-full">{truncateAddress(address)}</pre>
</div>
)}
{chainId && (
@ -101,14 +90,13 @@ function ConnectionControls({
if (context) {
return (
<div className="space-y-2 w-full">
<Button
onClick={() => connect({ connector: connectors[0] })}
className="w-full"
>
<Button onClick={() => connect({ connector: connectors[0] })} className="w-full">
Connect (Auto)
</Button>
<Button
onClick={() => {
console.log("Manual Farcaster connection attempt");
console.log("Connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
connect({ connector: connectors[0] });
}}
className="w-full"
@ -120,16 +108,10 @@ function ConnectionControls({
}
return (
<div className="space-y-3 w-full">
<Button
onClick={() => connect({ connector: connectors[1] })}
className="w-full"
>
<Button onClick={() => connect({ connector: connectors[1] })} className="w-full">
Connect Coinbase Wallet
</Button>
<Button
onClick={() => connect({ connector: connectors[2] })}
className="w-full"
>
<Button onClick={() => connect({ connector: connectors[2] })} className="w-full">
Connect MetaMask
</Button>
</div>
@ -138,10 +120,8 @@ function ConnectionControls({
export function WalletTab() {
// --- State ---
const [evmContractTransactionHash, setEvmContractTransactionHash] = useState<
string | null
>(null);
const [evmContractTransactionHash, setEvmContractTransactionHash] = useState<string | null>(null);
// --- Hooks ---
const { context } = useMiniApp();
const { address, isConnected } = useAccount();
@ -157,12 +137,10 @@ export function WalletTab() {
isPending: isEvmTransactionPending,
} = useSendTransaction();
const {
isLoading: isEvmTransactionConfirming,
isSuccess: isEvmTransactionConfirmed,
} = useWaitForTransactionReceipt({
hash: evmContractTransactionHash as `0x${string}`,
});
const { isLoading: isEvmTransactionConfirming, isSuccess: isEvmTransactionConfirmed } =
useWaitForTransactionReceipt({
hash: evmContractTransactionHash as `0x${string}`,
});
const {
signTypedData,
@ -184,32 +162,38 @@ export function WalletTab() {
// --- Effects ---
/**
* Auto-connect when Farcaster context is available.
*
*
* This effect detects when the app is running in a Farcaster client
* and automatically attempts to connect using the Farcaster Frame connector.
* It includes comprehensive logging for debugging connection issues.
*/
useEffect(() => {
// Check if we're in a Farcaster client environment
const isInFarcasterClient =
typeof window !== 'undefined' &&
(window.location.href.includes('warpcast.com') ||
window.location.href.includes('farcaster') ||
window.ethereum?.isFarcaster ||
context?.client);
if (
context?.user?.fid &&
!isConnected &&
connectors.length > 0 &&
isInFarcasterClient
) {
const isInFarcasterClient = typeof window !== 'undefined' &&
(window.location.href.includes('warpcast.com') ||
window.location.href.includes('farcaster') ||
window.ethereum?.isFarcaster ||
context?.client);
if (context?.user?.fid && !isConnected && connectors.length > 0 && isInFarcasterClient) {
console.log("Attempting auto-connection with Farcaster context...");
console.log("- User FID:", context.user.fid);
console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
console.log("- Using connector:", connectors[0].name);
console.log("- In Farcaster client:", isInFarcasterClient);
// Use the first connector (farcasterFrame) for auto-connection
try {
connect({ connector: connectors[0] });
} catch (error) {
console.error('Auto-connection failed:', error);
console.error("Auto-connection failed:", error);
}
} else {
console.log("Auto-connection conditions not met:");
console.log("- Has context:", !!context?.user?.fid);
console.log("- Is connected:", isConnected);
console.log("- Has connectors:", connectors.length > 0);
console.log("- In Farcaster client:", isInFarcasterClient);
}
}, [context?.user?.fid, isConnected, connectors, connect, context?.client]);
@ -243,7 +227,7 @@ export function WalletTab() {
/**
* Sends a transaction to call the yoink() function on the Yoink contract.
*
*
* This function sends a transaction to a specific contract address with
* the encoded function call data for the yoink() function.
*/
@ -251,20 +235,20 @@ export function WalletTab() {
sendTransaction(
{
// call yoink() on Yoink contract
to: '0x4bBFD120d9f352A0BEd7a014bd67913a2007a878',
data: '0x9846cd9efc000023c0',
to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878",
data: "0x9846cd9efc000023c0",
},
{
onSuccess: hash => {
onSuccess: (hash) => {
setEvmContractTransactionHash(hash);
},
},
}
);
}, [sendTransaction]);
/**
* Signs typed data using EIP-712 standard.
*
*
* This function creates a typed data structure with the app name, version,
* and chain ID, then requests the user to sign it.
*/
@ -272,16 +256,16 @@ export function WalletTab() {
signTypedData({
domain: {
name: APP_NAME,
version: '1',
version: "1",
chainId,
},
types: {
Message: [{ name: 'content', type: 'string' }],
Message: [{ name: "content", type: "string" }],
},
message: {
content: `Hello from ${APP_NAME}!`,
},
primaryType: 'Message',
primaryType: "Message",
});
}, [chainId, signTypedData]);
@ -324,12 +308,12 @@ export function WalletTab() {
<div className="text-xs w-full">
<div>Hash: {truncateAddress(evmContractTransactionHash)}</div>
<div>
Status:{' '}
Status:{" "}
{isEvmTransactionConfirming
? 'Confirming...'
? "Confirming..."
: isEvmTransactionConfirmed
? 'Confirmed!'
: 'Pending'}
? "Confirmed!"
: "Pending"}
</div>
</div>
)}
@ -363,4 +347,4 @@ export function WalletTab() {
)}
</div>
);
}
}

View File

@ -1,4 +1,4 @@
export { HomeTab } from './HomeTab';
export { ActionsTab } from './ActionsTab';
export { ContextTab } from './ContextTab';
export { WalletTab } from './WalletTab';
export { WalletTab } from './WalletTab';

View File

@ -1,29 +1,25 @@
'use client';
"use client";
import { useCallback, useMemo } from 'react';
import {
useAccount,
useSendTransaction,
useWaitForTransactionReceipt,
} from 'wagmi';
import { base } from 'wagmi/chains';
import { renderError } from '../../../lib/errorUtils';
import { truncateAddress } from '../../../lib/truncateAddress';
import { Button } from '../Button';
import { useCallback, useMemo } from "react";
import { useAccount, useSendTransaction, useWaitForTransactionReceipt } from "wagmi";
import { base } from "wagmi/chains";
import { Button } from "../Button";
import { truncateAddress } from "../../../lib/truncateAddress";
import { renderError } from "../../../lib/errorUtils";
/**
* SendEth component handles sending ETH transactions to protocol guild addresses.
*
*
* This component provides a simple interface for users to send small amounts
* of ETH to protocol guild addresses. It automatically selects the appropriate
* recipient address based on the current chain and displays transaction status.
*
*
* Features:
* - Chain-specific recipient addresses
* - Transaction status tracking
* - Error handling and display
* - Transaction hash display
*
*
* @example
* ```tsx
* <SendEth />
@ -40,34 +36,32 @@ export function SendEth() {
isPending: isEthTransactionPending,
} = useSendTransaction();
const {
isLoading: isEthTransactionConfirming,
isSuccess: isEthTransactionConfirmed,
} = useWaitForTransactionReceipt({
hash: ethTransactionHash,
});
const { isLoading: isEthTransactionConfirming, isSuccess: isEthTransactionConfirmed } =
useWaitForTransactionReceipt({
hash: ethTransactionHash,
});
// --- Computed Values ---
/**
* Determines the recipient address based on the current chain.
*
*
* Uses different protocol guild addresses for different chains:
* - Base: 0x32e3C7fD24e175701A35c224f2238d18439C7dBC
* - Other chains: 0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830
*
*
* @returns string - The recipient address for the current chain
*/
const protocolGuildRecipientAddress = useMemo(() => {
// Protocol guild address
return chainId === base.id
? '0x32e3C7fD24e175701A35c224f2238d18439C7dBC'
: '0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830';
? "0x32e3C7fD24e175701A35c224f2238d18439C7dBC"
: "0xB3d8d7887693a9852734b4D25e9C0Bb35Ba8a830";
}, [chainId]);
// --- Handlers ---
/**
* Handles sending the ETH transaction.
*
*
* This function sends a small amount of ETH (1 wei) to the protocol guild
* address for the current chain. The transaction is sent using the wagmi
* sendTransaction hook.
@ -94,15 +88,15 @@ export function SendEth() {
<div className="mt-2 text-xs">
<div>Hash: {truncateAddress(ethTransactionHash)}</div>
<div>
Status:{' '}
Status:{" "}
{isEthTransactionConfirming
? 'Confirming...'
? "Confirming..."
: isEthTransactionConfirmed
? 'Confirmed!'
: 'Pending'}
? "Confirmed!"
: "Pending"}
</div>
</div>
)}
</>
);
}
}

View File

@ -1,32 +1,29 @@
'use client';
"use client";
import { useCallback, useState } from 'react';
import {
useConnection as useSolanaConnection,
useWallet as useSolanaWallet,
} from '@solana/wallet-adapter-react';
import { useCallback, useState } from "react";
import { useConnection as useSolanaConnection, useWallet as useSolanaWallet } from '@solana/wallet-adapter-react';
import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { renderError } from '../../../lib/errorUtils';
import { truncateAddress } from '../../../lib/truncateAddress';
import { Button } from '../Button';
import { Button } from "../Button";
import { truncateAddress } from "../../../lib/truncateAddress";
import { renderError } from "../../../lib/errorUtils";
/**
* SendSolana component handles sending SOL transactions on Solana.
*
*
* This component provides a simple interface for users to send SOL transactions
* using their connected Solana wallet. It includes transaction status tracking
* and error handling.
*
*
* Features:
* - SOL transaction sending
* - Transaction status tracking
* - Error handling and display
* - Loading state management
*
*
* Note: This component is a placeholder implementation. In a real application,
* you would integrate with a Solana wallet adapter and transaction library
* like @solana/web3.js to handle actual transactions.
*
*
* @example
* ```tsx
* <SendSolana />
@ -45,8 +42,7 @@ export function SendSolana() {
// This should be replaced but including it from the original demo
// https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx#L718
const ashoatsPhantomSolanaWallet =
'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1';
const ashoatsPhantomSolanaWallet = 'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1';
/**
* Handles sending the Solana transaction
@ -76,8 +72,7 @@ export function SendSolana() {
transaction.recentBlockhash = blockhash;
transaction.feePayer = new PublicKey(fromPubkeyStr);
const simulation =
await solanaConnection.simulateTransaction(transaction);
const simulation = await solanaConnection.simulateTransaction(transaction);
if (simulation.value.err) {
// Gather logs and error details for debugging
const logs = simulation.value.logs?.join('\n') ?? 'No logs';
@ -105,8 +100,7 @@ export function SendSolana() {
>
Send Transaction (sol)
</Button>
{solanaTransactionState.status === 'error' &&
renderError(solanaTransactionState.error)}
{solanaTransactionState.status === 'error' && renderError(solanaTransactionState.error)}
{solanaTransactionState.status === 'success' && (
<div className="mt-2 text-xs">
<div>Hash: {truncateAddress(solanaTransactionState.signature)}</div>
@ -114,4 +108,4 @@ export function SendSolana() {
)}
</>
);
}
}

View File

@ -1,26 +1,26 @@
'use client';
"use client";
import { useCallback } from 'react';
import { useAccount, useConnect, useSignMessage } from 'wagmi';
import { base } from 'wagmi/chains';
import { APP_NAME } from '../../../lib/constants';
import { renderError } from '../../../lib/errorUtils';
import { config } from '../../providers/WagmiProvider';
import { Button } from '../Button';
import { useCallback } from "react";
import { useAccount, useConnect, useSignMessage } from "wagmi";
import { base } from "wagmi/chains";
import { Button } from "../Button";
import { config } from "../../providers/WagmiProvider";
import { APP_NAME } from "../../../lib/constants";
import { renderError } from "../../../lib/errorUtils";
/**
* SignEvmMessage component handles signing messages on EVM-compatible chains.
*
*
* This component provides a simple interface for users to sign messages using
* their connected EVM wallet. It automatically handles wallet connection if
* the user is not already connected, and displays the signature result.
*
*
* Features:
* - Automatic wallet connection if needed
* - Message signing with app name
* - Error handling and display
* - Signature result display
*
*
* @example
* ```tsx
* <SignEvmMessage />
@ -41,12 +41,12 @@ export function SignEvmMessage() {
// --- Handlers ---
/**
* Handles the message signing process.
*
*
* This function first ensures the user is connected to an EVM wallet,
* then requests them to sign a message containing the app name.
* If the user is not connected, it automatically connects using the
* Farcaster Frame connector.
*
*
* @returns Promise<void>
*/
const signEvmMessage = useCallback(async () => {
@ -78,4 +78,4 @@ export function SignEvmMessage() {
)}
</>
);
}
}

View File

@ -1,20 +1,22 @@
'use client';
import { useCallback, useState } from 'react';
import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import { useQuickAuth } from '~/hooks/useQuickAuth';
import { Button } from '../Button';
import { useCallback, useState } from "react";
import { signIn, signOut, getCsrfToken } from "next-auth/react";
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk";
import { useSession } from "next-auth/react";
import { Button } from "../Button";
/**
* SignIn component handles Farcaster authentication using QuickAuth.
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
*
* This component provides a complete authentication flow for Farcaster users:
* - Uses the built-in QuickAuth functionality from the Farcaster SDK
* - Manages authentication state in memory (no persistence)
* - 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 the Farcaster Frame SDK and QuickAuth
* The component integrates with both the Farcaster Frame SDK and NextAuth
* to provide seamless authentication within mini apps.
*
* @example
@ -34,32 +36,52 @@ export function SignIn() {
signingIn: false,
signingOut: false,
});
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
const [signInFailure, setSignInFailure] = useState<string>();
// --- Hooks ---
const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
const { data: session, status } = useSession();
// --- Handlers ---
/**
* Handles the sign-in process using QuickAuth.
* Generates a nonce for the sign-in process.
*
* This function uses the built-in QuickAuth functionality:
* 1. Gets a token from QuickAuth (handles SIWF flow automatically)
* 2. Validates the token with our server
* 3. Updates the session state
* 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<string> - The generated nonce token
* @throws Error if unable to generate nonce
*/
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error('Unable to generate nonce');
return nonce;
}, []);
/**
* Handles the sign-in process using Farcaster SDK.
*
* This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection
*
* @returns Promise<void>
*/
const handleSignIn = useCallback(async () => {
try {
setAuthState(prev => ({ ...prev, signingIn: true }));
setAuthState((prev) => ({ ...prev, signingIn: true }));
setSignInFailure(undefined);
const success = await signIn();
if (!success) {
setSignInFailure('Authentication failed');
}
const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce });
setSignInResult(result);
await signIn('farcaster', {
message: result.message,
signature: result.signature,
redirect: false,
});
} catch (e) {
if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure('Rejected by user');
@ -67,49 +89,52 @@ export function SignIn() {
}
setSignInFailure('Unknown error');
} finally {
setAuthState(prev => ({ ...prev, signingIn: false }));
setAuthState((prev) => ({ ...prev, signingIn: false }));
}
}, [signIn]);
}, [getNonce]);
/**
* Handles the sign-out process.
*
* This function clears the QuickAuth session and resets the local state.
* 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<void>
*/
const handleSignOut = useCallback(async () => {
try {
setAuthState(prev => ({ ...prev, signingOut: true }));
await signOut();
setAuthState((prev) => ({ ...prev, signingOut: true }));
// 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 }));
setAuthState((prev) => ({ ...prev, signingOut: false }));
}
}, [signOut]);
}, [session]);
// --- Render ---
return (
<>
{/* Authentication Buttons */}
{status !== 'authenticated' && (
{(status !== 'authenticated' || session?.provider !== 'farcaster') && (
<Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster
</Button>
)}
{status === 'authenticated' && (
{status === 'authenticated' && session?.provider === 'farcaster' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out
</Button>
)}
{/* Session Information */}
{authenticatedUser && (
{session && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authenticated User
</div>
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Session</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(authenticatedUser, null, 2)}
{JSON.stringify(session, null, 2)}
</div>
</div>
)}
@ -117,11 +142,17 @@ export function SignIn() {
{/* Error Display */}
{signInFailure && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authentication Error
</div>
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div>
</div>
)}
{/* Success Result Display */}
{signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{signInFailure}
{JSON.stringify(signInResult, null, 2)}
</div>
</div>
)}

View File

@ -1,8 +1,8 @@
'use client';
"use client";
import { useCallback, useState } from 'react';
import { renderError } from '../../../lib/errorUtils';
import { Button } from '../Button';
import { useCallback, useState } from "react";
import { Button } from "../Button";
import { renderError } from "../../../lib/errorUtils";
interface SignSolanaMessageProps {
signMessage?: (message: Uint8Array) => Promise<Uint8Array>;
@ -10,20 +10,20 @@ interface SignSolanaMessageProps {
/**
* SignSolanaMessage component handles signing messages on Solana.
*
*
* This component provides a simple interface for users to sign messages using
* their connected Solana wallet. It accepts a signMessage function as a prop
* and handles the complete signing flow including error handling.
*
*
* Features:
* - Message signing with Solana wallet
* - Error handling and display
* - Signature result display (base64 encoded)
* - Loading state management
*
*
* @param props - Component props
* @param props.signMessage - Function to sign messages with Solana wallet
*
*
* @example
* ```tsx
* <SignSolanaMessage signMessage={solanaWallet.signMessage} />
@ -38,11 +38,11 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
// --- Handlers ---
/**
* Handles the Solana message signing process.
*
*
* This function encodes a message as UTF-8 bytes, signs it using the provided
* signMessage function, and displays the base64-encoded signature result.
* It includes comprehensive error handling and loading state management.
*
*
* @returns Promise<void>
*/
const handleSignMessage = useCallback(async () => {
@ -51,7 +51,7 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
if (!signMessage) {
throw new Error('no Solana signMessage');
}
const input = new TextEncoder().encode('Hello from Solana!');
const input = new TextEncoder().encode("Hello from Solana!");
const signatureBytes = await signMessage(input);
const signature = btoa(String.fromCharCode(...signatureBytes));
setSignature(signature);
@ -84,4 +84,4 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
)}
</>
);
}
}

View File

@ -2,4 +2,4 @@ export { SignIn } from './SignIn';
export { SignEvmMessage } from './SignEvmMessage';
export { SendEth } from './SendEth';
export { SignSolanaMessage } from './SignSolanaMessage';
export { SendSolana } from './SendSolana';
export { SendSolana } from './SendSolana';

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react';
export function useDetectClickOutside<T extends HTMLElement>(
ref: React.RefObject<T | null>,
callback: () => void,
callback: () => void
) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from "react";
export interface NeynarUser {
fid: number;
@ -19,21 +19,20 @@ export function useNeynarUser(context?: { user?: { fid?: number } }) {
setLoading(true);
setError(null);
fetch(`/api/users?fids=${context.user.fid}`)
.then(response => {
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
.then((response) => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
})
.then(data => {
.then((data) => {
if (data.users?.[0]) {
setUser(data.users[0]);
} else {
setUser(null);
}
})
.catch(err => setError(err.message))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [context?.user?.fid]);
return { user, loading, error };
}
}

View File

@ -1,207 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { sdk } from '@farcaster/miniapp-sdk';
/**
* Represents the current authenticated user state
*/
interface AuthenticatedUser {
/** The user's Farcaster ID (FID) */
fid: number;
}
/**
* Possible authentication states for QuickAuth
*/
type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
/**
* Return type for the useQuickAuth hook
*/
interface UseQuickAuthReturn {
/** Current authenticated user data, or null if not authenticated */
authenticatedUser: AuthenticatedUser | null;
/** Current authentication status */
status: QuickAuthStatus;
/** Function to initiate the sign-in process using QuickAuth */
signIn: () => Promise<boolean>;
/** Function to sign out and clear the current authentication state */
signOut: () => Promise<void>;
/** Function to retrieve the current authentication token */
getToken: () => Promise<string | null>;
}
/**
* Custom hook for managing QuickAuth authentication state
*
* This hook provides a complete authentication flow using Farcaster's QuickAuth:
* - Automatically checks for existing authentication on mount
* - Validates tokens with the server-side API
* - Manages authentication state in memory (no persistence)
* - Provides sign-in/sign-out functionality
*
* QuickAuth tokens are managed in memory only, so signing out of the Farcaster
* client will automatically sign the user out of this mini app as well.
*
* @returns {UseQuickAuthReturn} Object containing user state and authentication methods
*
* @example
* ```tsx
* const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
*
* if (status === 'loading') return <div>Loading...</div>;
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
*
* return (
* <div>
* <p>Welcome, FID: {authenticatedUser?.fid}</p>
* <button onClick={signOut}>Sign Out</button>
* </div>
* );
* ```
*/
export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] =
useState<AuthenticatedUser | null>(null);
// Current authentication status
const [status, setStatus] = useState<QuickAuthStatus>('loading');
/**
* Validates a QuickAuth token with the server-side API
*
* @param {string} authToken - The JWT token to validate
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
*/
const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
try {
const validationResponse = await fetch('/api/auth/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: authToken }),
});
if (validationResponse.ok) {
const responseData = await validationResponse.json();
return responseData.user;
}
return null;
} catch (error) {
console.error('Token validation failed:', error);
return null;
}
};
/**
* Checks for existing authentication token and validates it on component mount
* This runs automatically when the hook is first used
*/
useEffect(() => {
const checkExistingAuthentication = async () => {
try {
// Attempt to retrieve existing token from QuickAuth SDK
const { token } = await sdk.quickAuth.getToken();
if (token) {
// Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) {
// Token is valid, set authenticated state
setAuthenticatedUser(validatedUserSession);
setStatus('authenticated');
} else {
// Token is invalid or expired, clear authentication state
setStatus('unauthenticated');
}
} else {
// No existing token found, user is not authenticated
setStatus('unauthenticated');
}
} catch (error) {
console.error('Error checking existing authentication:', error);
setStatus('unauthenticated');
}
};
checkExistingAuthentication();
}, []);
/**
* Initiates the QuickAuth sign-in process
*
* Uses sdk.quickAuth.getToken() to get a QuickAuth session token.
* If there is already a session token in memory that hasn't expired,
* it will be immediately returned, otherwise a fresh one will be acquired.
*
* @returns {Promise<boolean>} True if sign-in was successful, false otherwise
*/
const signIn = useCallback(async (): Promise<boolean> => {
try {
setStatus('loading');
// Get QuickAuth session token
const { token } = await sdk.quickAuth.getToken();
if (token) {
// Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) {
// Authentication successful, update user state
setAuthenticatedUser(validatedUserSession);
setStatus('authenticated');
return true;
}
}
// Authentication failed, clear user state
setStatus('unauthenticated');
return false;
} catch (error) {
console.error('Sign-in process failed:', error);
setStatus('unauthenticated');
return false;
}
}, []);
/**
* Signs out the current user and clears the authentication state
*
* Since QuickAuth tokens are managed in memory only, this simply clears
* the local user state. The actual token will be cleared when the
* user signs out of their Farcaster client.
*/
const signOut = useCallback(async (): Promise<void> => {
// Clear local user state
setAuthenticatedUser(null);
setStatus('unauthenticated');
}, []);
/**
* Retrieves the current authentication token from QuickAuth
*
* @returns {Promise<string | null>} The current auth token, or null if not authenticated
*/
const getToken = useCallback(async (): Promise<string | null> => {
try {
const { token } = await sdk.quickAuth.getToken();
return token;
} catch (error) {
console.error('Failed to retrieve authentication token:', error);
return null;
}
}, []);
return {
authenticatedUser,
status,
signIn,
signOut,
getToken,
};
}

View File

@ -65,15 +65,14 @@ export const APP_SPLASH_URL: string = `${APP_URL}/splash.png`;
* Background color for the splash screen.
* Used as fallback when splash image is loading.
*/
export const APP_SPLASH_BACKGROUND_COLOR: string = '#f7f7f7';
export const APP_SPLASH_BACKGROUND_COLOR: string = "#f7f7f7";
/**
* Account association for the mini app.
* Used to associate the mini app with a Farcaster account.
* If not provided, the mini app will be unsigned and have limited capabilities.
*/
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
undefined;
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = undefined;
// --- UI Configuration ---
/**
@ -90,8 +89,7 @@ export const APP_BUTTON_TEXT: string = 'Launch NSK';
* Neynar webhook endpoint. Otherwise, falls back to a local webhook
* endpoint for development and testing.
*/
export const APP_WEBHOOK_URL: string =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `${APP_URL}/api/webhook`;

View File

@ -1,9 +1,9 @@
import { type ReactElement } from 'react';
import { BaseError, UserRejectedRequestError } from 'viem';
import { type ReactElement } from "react";
import { BaseError, UserRejectedRequestError } from "viem";
/**
* Renders an error object in a user-friendly format.
*
*
* This utility function takes an error object and renders it as a React element
* with consistent styling. It handles different types of errors including:
* - Error objects with message properties
@ -11,14 +11,14 @@ import { BaseError, UserRejectedRequestError } from 'viem';
* - String errors
* - Unknown error types
* - User rejection errors (special handling for wallet rejections)
*
*
* The rendered error is displayed in a gray container with monospace font
* for better readability of technical error details. User rejections are
* displayed with a simpler, more user-friendly message.
*
*
* @param error - The error object to render
* @returns ReactElement - A styled error display component, or null if no error
*
*
* @example
* ```tsx
* {isError && renderError(error)}
@ -27,11 +27,11 @@ import { BaseError, UserRejectedRequestError } from 'viem';
export function renderError(error: unknown): ReactElement | null {
// Handle null/undefined errors
if (!error) return null;
// Special handling for user rejections in wallet operations
if (error instanceof BaseError) {
const isUserRejection = error.walk(
e => e instanceof UserRejectedRequestError,
(e) => e instanceof UserRejectedRequestError
);
if (isUserRejection) {
@ -43,10 +43,10 @@ export function renderError(error: unknown): ReactElement | null {
);
}
}
// Extract error message from different error types
let errorMessage: string;
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'object' && error !== null && 'error' in error) {
@ -63,4 +63,4 @@ export function renderError(error: unknown): ReactElement | null {
<div className="whitespace-pre-wrap break-words">{errorMessage}</div>
</div>
);
}
}

View File

@ -1,25 +1,23 @@
import { FrameNotificationDetails } from '@farcaster/miniapp-sdk';
import { Redis } from '@upstash/redis';
import { APP_NAME } from './constants';
import { FrameNotificationDetails } from "@farcaster/miniapp-sdk";
import { Redis } from "@upstash/redis";
import { APP_NAME } from "./constants";
// In-memory fallback storage
const localStore = new Map<string, FrameNotificationDetails>();
// Use Redis if KV env vars are present, otherwise use in-memory
const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN;
const redis = useRedis
? new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
: null;
const redis = useRedis ? new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
}) : null;
function getUserNotificationDetailsKey(fid: number): string {
return `${APP_NAME}:user:${fid}`;
}
export async function getUserNotificationDetails(
fid: number,
fid: number
): Promise<FrameNotificationDetails | null> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {
@ -30,7 +28,7 @@ export async function getUserNotificationDetails(
export async function setUserNotificationDetails(
fid: number,
notificationDetails: FrameNotificationDetails,
notificationDetails: FrameNotificationDetails
): Promise<void> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {
@ -41,7 +39,7 @@ export async function setUserNotificationDetails(
}
export async function deleteUserNotificationDetails(
fid: number,
fid: number
): Promise<void> {
const key = getUserNotificationDetailsKey(fid);
if (redis) {

View File

@ -1,15 +1,11 @@
import {
NeynarAPIClient,
Configuration,
WebhookUserCreated,
} from '@neynar/nodejs-sdk';
import { NeynarAPIClient, Configuration, WebhookUserCreated } from '@neynar/nodejs-sdk';
import { APP_URL } from './constants';
let neynarClient: NeynarAPIClient | null = null;
// Example usage:
// const client = getNeynarClient();
// const user = await client.lookupUserByFid(fid);
// const user = await client.lookupUserByFid(fid);
export function getNeynarClient() {
if (!neynarClient) {
const apiKey = process.env.NEYNAR_API_KEY;
@ -37,12 +33,12 @@ export async function getNeynarUser(fid: number): Promise<User | null> {
type SendMiniAppNotificationResult =
| {
state: 'error';
state: "error";
error: unknown;
}
| { state: 'no_token' }
| { state: 'rate_limit' }
| { state: 'success' };
| { state: "no_token" }
| { state: "rate_limit" }
| { state: "success" };
export async function sendNeynarMiniAppNotification({
fid,
@ -62,19 +58,19 @@ export async function sendNeynarMiniAppNotification({
target_url: APP_URL,
};
const result = await client.publishFrameNotifications({
targetFids,
notification,
const result = await client.publishFrameNotifications({
targetFids,
notification
});
if (result.notification_deliveries.length > 0) {
return { state: 'success' };
return { state: "success" };
} else if (result.notification_deliveries.length === 0) {
return { state: 'no_token' };
return { state: "no_token" };
} else {
return { state: 'error', error: result || 'Unknown error' };
return { state: "error", error: result || "Unknown error" };
}
} catch (error) {
return { state: 'error', error };
return { state: "error", error };
}
}
}

View File

@ -1,18 +1,18 @@
import {
SendNotificationRequest,
sendNotificationResponseSchema,
} from '@farcaster/miniapp-sdk';
import { getUserNotificationDetails } from '~/lib/kv';
import { APP_URL } from './constants';
} from "@farcaster/miniapp-sdk";
import { getUserNotificationDetails } from "~/lib/kv";
import { APP_URL } from "./constants";
type SendMiniAppNotificationResult =
| {
state: 'error';
state: "error";
error: unknown;
}
| { state: 'no_token' }
| { state: 'rate_limit' }
| { state: 'success' };
| { state: "no_token" }
| { state: "rate_limit" }
| { state: "success" };
export async function sendMiniAppNotification({
fid,
@ -25,13 +25,13 @@ export async function sendMiniAppNotification({
}): Promise<SendMiniAppNotificationResult> {
const notificationDetails = await getUserNotificationDetails(fid);
if (!notificationDetails) {
return { state: 'no_token' };
return { state: "no_token" };
}
const response = await fetch(notificationDetails.url, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
notificationId: crypto.randomUUID(),
@ -48,17 +48,17 @@ export async function sendMiniAppNotification({
const responseBody = sendNotificationResponseSchema.safeParse(responseJson);
if (responseBody.success === false) {
// Malformed response
return { state: 'error', error: responseBody.error.errors };
return { state: "error", error: responseBody.error.errors };
}
if (responseBody.data.result.rateLimitedTokens.length) {
// Rate limited
return { state: 'rate_limit' };
return { state: "rate_limit" };
}
return { state: 'success' };
return { state: "success" };
} else {
// Error response
return { state: 'error', error: responseJson };
return { state: "error", error: responseJson };
}
}

View File

@ -1,4 +1,4 @@
export const truncateAddress = (address: string) => {
if (!address) return '';
if (!address) return "";
return `${address.slice(0, 14)}...${address.slice(-12)}`;
};

View File

@ -1,6 +1,6 @@
import { type Manifest } from '@farcaster/miniapp-node';
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type Manifest } from '@farcaster/miniapp-node';
import {
APP_BUTTON_TEXT,
APP_DESCRIPTION,
@ -10,8 +10,7 @@ import {
APP_PRIMARY_CATEGORY,
APP_SPLASH_BACKGROUND_COLOR,
APP_SPLASH_URL,
APP_TAGS,
APP_URL,
APP_TAGS, APP_URL,
APP_WEBHOOK_URL,
APP_ACCOUNT_ASSOCIATION,
} from './constants';
@ -22,12 +21,12 @@ export function cn(...inputs: ClassValue[]) {
export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
return {
version: 'next',
version: "next",
imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
button: {
title: APP_BUTTON_TEXT,
action: {
type: 'launch_frame',
type: "launch_frame",
name: APP_NAME,
url: APP_URL,
splashImageUrl: APP_SPLASH_URL,
@ -45,12 +44,12 @@ export async function getFarcasterDomainManifest(): Promise<Manifest> {
return {
accountAssociation: APP_ACCOUNT_ASSOCIATION,
miniapp: {
version: '1',
name: APP_NAME ?? 'Neynar Starter Kit',
version: "1",
name: APP_NAME ?? "Neynar Starter Kit",
iconUrl: APP_ICON_URL,
homeUrl: APP_URL,
imageUrl: APP_OG_IMAGE_URL,
buttonTitle: APP_BUTTON_TEXT ?? 'Launch Mini App',
buttonTitle: APP_BUTTON_TEXT ?? "Launch Mini App",
splashImageUrl: APP_SPLASH_URL,
splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR,
webhookUrl: APP_WEBHOOK_URL,

60
tailwind.config.ts Normal file
View File

@ -0,0 +1,60 @@
import type { Config } from "tailwindcss";
/**
* Tailwind CSS Configuration
*
* This configuration centralizes all theme colors for the mini app.
* To change the app's color scheme, simply update the 'primary' color value below.
*
* Example theme changes:
* - Blue theme: primary: "#3182CE"
* - Green theme: primary: "#059669"
* - Red theme: primary: "#DC2626"
* - Orange theme: primary: "#EA580C"
*/
export default {
darkMode: "media",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
// Main theme color - change this to update the entire app's color scheme
primary: "#8b5cf6", // Main brand color
"primary-light": "#a78bfa", // For hover states
"primary-dark": "#7c3aed", // For active states
// Secondary colors for backgrounds and text
secondary: "#f8fafc", // Light backgrounds
"secondary-dark": "#334155", // Dark backgrounds
// Legacy CSS variables for backward compatibility
background: 'var(--background)',
foreground: 'var(--foreground)'
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
// Custom spacing for consistent layout
spacing: {
'18': '4.5rem',
'88': '22rem',
},
// Custom container sizes
maxWidth: {
'xs': '20rem',
'sm': '24rem',
'md': '28rem',
'lg': '32rem',
'xl': '36rem',
'2xl': '42rem',
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

View File

@ -1,4 +1,4 @@
{
"buildCommand": "next build",
"framework": "nextjs"
}
}