Merge pull request #15 from neynarxyz/shreyas-formatting

Formatting
This commit is contained in:
Lucas Myers 2025-07-15 09:25:35 -07:00 committed by GitHub
commit b1fdfc19a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 6265 additions and 1073 deletions

29
.editorconfig Normal file
View File

@ -0,0 +1,29 @@
# 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

95
.eslintignore Normal file
View File

@ -0,0 +1,95 @@
# 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,3 +1,113 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"] "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"
}
}
]
} }

View File

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

22
.gitignore vendored
View File

@ -39,3 +39,25 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts 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/

4
.lintstagedrc Normal file
View File

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

204
.prettierignore Normal file
View File

@ -0,0 +1,204 @@
# 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/

17
.prettierrc Normal file
View File

@ -0,0 +1,17 @@
{
"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
}

13
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"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,24 +11,51 @@ Check out [this Neynar docs page](https://docs.neynar.com/docs/create-farcaster-
## Getting Started ## Getting Started
To create a new mini app project, run: To create a new mini app project, run:
```{bash} ```{bash}
npx @neynar/create-farcaster-mini-app@latest npx @neynar/create-farcaster-mini-app@latest
``` ```
To run the project: To run the project:
```{bash} ```{bash}
cd <PROJECT_NAME> cd <PROJECT_NAME>
npm run dev 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 ### Importing the CLI
To invoke the CLI directly in JavaScript, add the npm package to your project and use the following import statement: To invoke the CLI directly in JavaScript, add the npm package to your project and use the following import statement:
```{javascript} ```{javascript}
import { init } from '@neynar/create-farcaster-mini-app'; import { init } from '@neynar/create-farcaster-mini-app';
``` ```
## Deploying to Vercel ## Deploying to Vercel
For projects that have made minimal changes to the quickstart template, deploy to vercel by running: For projects that have made minimal changes to the quickstart template, deploy to vercel by running:
```{bash} ```{bash}
npm run deploy:vercel npm run deploy:vercel
``` ```
@ -36,6 +63,7 @@ npm run deploy:vercel
## Building for Production ## Building for Production
To create a production build, run: To create a production build, run:
```{bash} ```{bash}
npm run build npm run build
``` ```
@ -51,11 +79,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: 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: 1. In your installer/template repo (this repo), run:
```bash ```bash
npm link npm link
``` ```
This makes your local version globally available as a symlinked package.
This makes your local version globally available as a symlinked package.
1. Now, when you run: 1. Now, when you run:
```bash ```bash
@ -76,4 +105,3 @@ However, this does not fully replicate the npx install flow and may not catch al
### Environment Variables and Scripts ### 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. 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 args.splice(yIndex, 1); // Remove -y from args
} }
// Parse other arguments // Parse other arguments
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
if (arg === '-p' || arg === '--project') { if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) { if (i + 1 < args.length) {
projectName = args[i + 1]; projectName = args[i + 1];
if (projectName.startsWith('-')) { if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)'); 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); process.exit(1);
} }
} else if (arg === '-k' || arg === '--api-key') { args.splice(i, 2); // Remove both the flag and its value
if (i + 1 < args.length) { i--; // Adjust index since we removed 2 elements
apiKey = args[i + 1]; } else {
if (apiKey.startsWith('-')) { console.error('Error: -p/--project requires a project name');
console.error('Error: API key cannot start with a dash (-)'); process.exit(1);
process.exit(1); }
} } else if (arg === '-k' || arg === '--api-key') {
args.splice(i, 2); // Remove both the flag and its value if (i + 1 < args.length) {
i--; // Adjust index since we removed 2 elements apiKey = args[i + 1];
} else { if (apiKey.startsWith('-')) {
console.error('Error: -k/--api-key requires an API key'); console.error('Error: API key cannot start with a dash (-)');
process.exit(1); 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 // Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) { 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); process.exit(1);
} }
init(projectName, autoAcceptDefaults, apiKey).catch((err) => { init(projectName, autoAcceptDefaults, apiKey).catch(err => {
console.error('Error:', err); console.error('Error:', err);
process.exit(1); process.exit(1);
}); });

View File

@ -1,19 +1,19 @@
#!/usr/bin/env node #!/usr/bin/env node
import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import fs from 'fs';
import { dirname } from 'path';
import path from 'path';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git'; const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git';
const SCRIPT_VERSION = JSON.parse( const SCRIPT_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8') fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
).version; ).version;
// ANSI color codes // ANSI color codes
@ -47,12 +47,12 @@ async function queryNeynarApp(apiKey) {
} }
try { try {
const response = await fetch( 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: { headers: {
'x-api-key': apiKey, 'x-api-key': apiKey,
}, },
} },
); );
const data = await response.json(); const data = await response.json();
return data; return data;
@ -63,7 +63,11 @@ async function queryNeynarApp(apiKey) {
} }
// Export the main CLI function for programmatic use // 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(); printWelcomeMessage();
// Ask about Neynar usage // Ask about Neynar usage
@ -107,7 +111,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
} else { } else {
if (!autoAcceptDefaults) { if (!autoAcceptDefaults) {
console.log( 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',
); );
} }
@ -144,13 +148,13 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
if (useDemoKey.useDemo) { if (useDemoKey.useDemo) {
console.warn( 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( 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( 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'; neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
} }
@ -163,7 +167,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
break; break;
} }
console.log( 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([ const { retry } = await inquirer.prompt([
{ {
@ -239,7 +243,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
name: 'projectName', name: 'projectName',
message: 'What is the name of your mini app?', message: 'What is the name of your mini app?',
default: projectName || defaultMiniAppName, default: projectName || defaultMiniAppName,
validate: (input) => { validate: input => {
if (input.trim() === '') { if (input.trim() === '') {
return 'Project name cannot be empty'; return 'Project name cannot be empty';
} }
@ -286,13 +290,13 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
message: message:
'Enter tags for your mini app (separate with spaces or commas, optional):', 'Enter tags for your mini app (separate with spaces or commas, optional):',
default: '', default: '',
filter: (input) => { filter: input => {
if (!input.trim()) return []; if (!input.trim()) return [];
// Split by both spaces and commas, trim whitespace, and filter out empty strings // Split by both spaces and commas, trim whitespace, and filter out empty strings
return input return input
.split(/[,\s]+/) .split(/[,\s]+/)
.map((tag) => tag.trim()) .map(tag => tag.trim())
.filter((tag) => tag.length > 0); .filter(tag => tag.length > 0);
}, },
}, },
{ {
@ -300,7 +304,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
name: 'buttonText', name: 'buttonText',
message: 'Enter the button text for your mini app:', message: 'Enter the button text for your mini app:',
default: 'Launch Mini App', default: 'Launch Mini App',
validate: (input) => { validate: input => {
if (input.trim() === '') { if (input.trim() === '') {
return 'Button text cannot be empty'; return 'Button text cannot be empty';
} }
@ -370,8 +374,9 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
{ {
type: 'password', type: 'password',
name: 'seedPhrase', name: 'seedPhrase',
message: 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):', message:
validate: (input) => { 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):',
validate: input => {
if (!input || input.trim().split(' ').length < 12) { if (!input || input.trim().split(' ').length < 12) {
return 'Seed phrase must be at least 12 words'; return 'Seed phrase must be at least 12 words';
} }
@ -439,7 +444,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
// Update package.json // Update package.json
console.log('\nUpdating package.json...'); console.log('\nUpdating package.json...');
const packageJsonPath = path.join(projectPath, 'package.json'); const packageJsonPath = path.join(projectPath, 'package.json');
let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.name = finalProjectName; packageJson.name = finalProjectName;
packageJson.version = '0.1.0'; packageJson.version = '0.1.0';
@ -483,20 +488,20 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
}; };
packageJson.devDependencies = { packageJson.devDependencies = {
"@types/inquirer": "^9.0.8", '@types/inquirer': '^9.0.8',
"@types/node": "^20", '@types/node': '^20',
"@types/react": "^19", '@types/react': '^19',
"@types/react-dom": "^19", '@types/react-dom': '^19',
"@vercel/sdk": "^1.9.0", '@vercel/sdk': '^1.9.0',
"crypto": "^1.0.1", crypto: '^1.0.1',
"eslint": "^8", eslint: '^8',
"eslint-config-next": "15.0.3", 'eslint-config-next': '15.0.3',
"localtunnel": "^2.0.2", localtunnel: '^2.0.2',
"pino-pretty": "^13.0.0", 'pino-pretty': '^13.0.0',
"postcss": "^8", postcss: '^8',
"tailwindcss": "^3.4.1", tailwindcss: '^3.4.1',
"ts-node": "^10.9.2", 'ts-node': '^10.9.2',
"typescript": "^5" typescript: '^5',
}; };
// Add Neynar SDK if selected // Add Neynar SDK if selected
@ -524,21 +529,21 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
let constantsContent = fs.readFileSync(constantsPath, 'utf8'); let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Helper function to escape single quotes in strings // 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 // Helper function to safely replace constants with validation
const safeReplace = (content, pattern, replacement, constantName) => { const safeReplace = (content, pattern, replacement, constantName) => {
const match = content.match(pattern); const match = content.match(pattern);
if (!match) { if (!match) {
console.log( 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(`Pattern: ${pattern}`);
console.log( console.log(
`Expected to match in: ${ `Expected to match in: ${
content.split('\n').find((line) => line.includes(constantName)) || content.split('\n').find(line => line.includes(constantName)) ||
'Not found' 'Not found'
}` }`,
); );
} else { } else {
const newContent = content.replace(pattern, replacement); const newContent = content.replace(pattern, replacement);
@ -549,7 +554,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
// Regex patterns that match whole lines with export const (with TypeScript types) // Regex patterns that match whole lines with export const (with TypeScript types)
const patterns = { 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: APP_DESCRIPTION:
/^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY: APP_PRIMARY_CATEGORY:
@ -557,7 +563,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m, APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
APP_BUTTON_TEXT: APP_BUTTON_TEXT:
/^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m, /^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: ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m, /^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
}; };
@ -567,7 +574,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.APP_NAME, patterns.APP_NAME,
`export const APP_NAME = '${escapeString(answers.projectName)}';`, `export const APP_NAME = '${escapeString(answers.projectName)}';`,
'APP_NAME' 'APP_NAME',
); );
// Update APP_DESCRIPTION // Update APP_DESCRIPTION
@ -575,9 +582,9 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.APP_DESCRIPTION, patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString( `export const APP_DESCRIPTION = '${escapeString(
answers.description answers.description,
)}';`, )}';`,
'APP_DESCRIPTION' 'APP_DESCRIPTION',
); );
// Update APP_PRIMARY_CATEGORY (always update, null becomes empty string) // Update APP_PRIMARY_CATEGORY (always update, null becomes empty string)
@ -585,21 +592,21 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.APP_PRIMARY_CATEGORY, patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString( `export const APP_PRIMARY_CATEGORY = '${escapeString(
answers.primaryCategory || '' answers.primaryCategory || '',
)}';`, )}';`,
'APP_PRIMARY_CATEGORY' 'APP_PRIMARY_CATEGORY',
); );
// Update APP_TAGS // Update APP_TAGS
const tagsString = const tagsString =
answers.tags.length > 0 answers.tags.length > 0
? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']` ? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']`
: "['neynar', 'starter-kit', 'demo']"; : "['neynar', 'starter-kit', 'demo']";
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_TAGS, patterns.APP_TAGS,
`export const APP_TAGS = ${tagsString};`, `export const APP_TAGS = ${tagsString};`,
'APP_TAGS' 'APP_TAGS',
); );
// Update APP_BUTTON_TEXT (always update, use answers value) // Update APP_BUTTON_TEXT (always update, use answers value)
@ -607,9 +614,9 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.APP_BUTTON_TEXT, patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString( `export const APP_BUTTON_TEXT = '${escapeString(
answers.buttonText || '' answers.buttonText || '',
)}';`, )}';`,
'APP_BUTTON_TEXT' 'APP_BUTTON_TEXT',
); );
// Update USE_WALLET // Update USE_WALLET
@ -617,7 +624,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.USE_WALLET, patterns.USE_WALLET,
`export const USE_WALLET = ${answers.useWallet};`, `export const USE_WALLET = ${answers.useWallet};`,
'USE_WALLET' 'USE_WALLET',
); );
// Update ANALYTICS_ENABLED // Update ANALYTICS_ENABLED
@ -625,7 +632,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
constantsContent, constantsContent,
patterns.ANALYTICS_ENABLED, patterns.ANALYTICS_ENABLED,
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, `export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED' 'ANALYTICS_ENABLED',
); );
fs.writeFileSync(constantsPath, constantsContent); fs.writeFileSync(constantsPath, constantsContent);
@ -635,14 +642,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
fs.appendFileSync( fs.appendFileSync(
envPath, envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`,
); );
if (useNeynar && neynarApiKey && neynarClientId) { if (useNeynar && neynarApiKey && neynarClientId) {
fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`);
fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`);
} else if (useNeynar) { } else if (useNeynar) {
console.log( 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) { if (answers.seedPhrase) {
@ -653,7 +660,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
fs.unlinkSync(envExamplePath); fs.unlinkSync(envExamplePath);
} else { } else {
console.log( console.log(
'\n.env.example does not exist, skipping copy and remove operations' '\n.env.example does not exist, skipping copy and remove operations',
); );
} }
@ -698,7 +705,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
execSync('git add .', { cwd: projectPath }); execSync('git add .', { cwd: projectPath });
execSync( execSync(
'git commit -m "initial commit from @neynar/create-farcaster-mini-app"', 'git commit -m "initial commit from @neynar/create-farcaster-mini-app"',
{ cwd: projectPath } { cwd: projectPath },
); );
// Calculate border length based on message length // Calculate border length based on message length

View File

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

2
index.d.ts vendored
View File

@ -2,4 +2,4 @@
* Initialize a new Farcaster mini app project * Initialize a new Farcaster mini app project
* @returns Promise<void> * @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 = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */

4978
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", "name": "@neynar/create-farcaster-mini-app",
"version": "1.7.1", "version": "1.7.2",
"type": "module", "type": "module",
"private": false, "private": false,
"access": "public", "access": "public",
@ -35,6 +35,9 @@
"build:raw": "next build", "build:raw": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "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:vercel": "ts-node scripts/deploy.ts",
"deploy:raw": "vercel --prod", "deploy:raw": "vercel --prod",
"cleanup": "node scripts/cleanup.js" "cleanup": "node scripts/cleanup.js"
@ -50,6 +53,11 @@
"devDependencies": { "devDependencies": {
"@neynar/nodejs-sdk": "^2.19.0", "@neynar/nodejs-sdk": "^2.19.0",
"@types/node": "^22.13.10", "@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" "typescript": "^5.6.3"
} }
} }

View File

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

View File

@ -72,18 +72,20 @@ async function checkRequiredEnvVars(): Promise<void> {
name: 'NEXT_PUBLIC_MINI_APP_NAME', name: 'NEXT_PUBLIC_MINI_APP_NAME',
message: 'Enter the name for your frame (e.g., My Cool Mini App):', message: 'Enter the name for your frame (e.g., My Cool Mini App):',
default: APP_NAME, 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', name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
message: 'Enter the text for your frame button:', message: 'Enter the text for your frame button:',
default: APP_BUTTON_TEXT ?? 'Launch Mini App', 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( const missingVars = requiredVars.filter(
(varConfig) => !process.env[varConfig.name] varConfig => !process.env[varConfig.name],
); );
if (missingVars.length > 0) { if (missingVars.length > 0) {
@ -109,7 +111,7 @@ async function checkRequiredEnvVars(): Promise<void> {
const newLine = envContent ? '\n' : ''; const newLine = envContent ? '\n' : '';
fs.appendFileSync( fs.appendFileSync(
'.env', '.env',
`${newLine}${varConfig.name}="${value.trim()}"` `${newLine}${varConfig.name}="${value.trim()}"`,
); );
} }
@ -132,7 +134,7 @@ async function checkRequiredEnvVars(): Promise<void> {
if (process.env.SEED_PHRASE) { if (process.env.SEED_PHRASE) {
fs.appendFileSync( fs.appendFileSync(
'.env.local', '.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"` `\nSPONSOR_SIGNER="${sponsorSigner}"`,
); );
console.log('✅ Sponsor signer preference stored in .env.local'); console.log('✅ Sponsor signer preference stored in .env.local');
} }
@ -170,8 +172,8 @@ async function getGitRemote(): Promise<string | null> {
async function checkVercelCLI(): Promise<boolean> { async function checkVercelCLI(): Promise<boolean> {
try { try {
execSync('vercel --version', { execSync('vercel --version', {
stdio: 'ignore' stdio: 'ignore',
}); });
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
@ -184,8 +186,8 @@ async function checkVercelCLI(): Promise<boolean> {
async function installVercelCLI(): Promise<void> { async function installVercelCLI(): Promise<void> {
console.log('Installing Vercel CLI...'); console.log('Installing Vercel CLI...');
execSync('npm install -g vercel', { execSync('npm install -g vercel', {
stdio: 'inherit' stdio: 'inherit',
}); });
} }
@ -221,7 +223,9 @@ async function getVercelToken(): Promise<string | null> {
return null; // We'll fall back to CLI operations return null; // We'll fall back to CLI operations
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; throw error;
} }
@ -238,7 +242,7 @@ async function loginToVercel(): Promise<boolean> {
console.log('3. Complete the Vercel account setup in your browser'); 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('4. Return here once your Vercel account is created\n');
console.log( 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'], { const child = spawn('vercel', ['login'], {
@ -246,14 +250,14 @@ async function loginToVercel(): Promise<boolean> {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
child.on('close', (code) => { child.on('close', code => {
resolve(); resolve();
}); });
}); });
console.log('\n📱 Waiting for login to complete...'); console.log('\n📱 Waiting for login to complete...');
console.log( 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++) { for (let i = 0; i < 150; i++) {
@ -262,10 +266,13 @@ async function loginToVercel(): Promise<boolean> {
console.log('✅ Successfully logged in to Vercel!'); console.log('✅ Successfully logged in to Vercel!');
return true; return true;
} catch (error: unknown) { } 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...'); console.log(' Waiting for Vercel account setup to complete...');
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
@ -276,7 +283,12 @@ async function loginToVercel(): Promise<boolean> {
return false; 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 { try {
let processedValue: string; let processedValue: string;
if (typeof value === 'object') { if (typeof value === 'object') {
@ -298,9 +310,9 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
// Single environment variable response // Single environment variable response
envs = [existingVars]; envs = [existingVars];
} }
const existingVar = envs.find((env: any) => const existingVar = envs.find(
env.key === key && env.target?.includes('production') (env: any) => env.key === key && env.target?.includes('production'),
); );
if (existingVar && existingVar.id) { if (existingVar && existingVar.id) {
@ -331,14 +343,21 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; return false;
} }
throw error; 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 { try {
// Remove existing env var // Remove existing env var
try { try {
@ -373,7 +392,7 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
execSync(command, { execSync(command, {
cwd: projectRoot, cwd: projectRoot,
stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
env: process.env env: process.env,
}); });
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
@ -385,18 +404,26 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
} }
if (error instanceof Error) { 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; return false;
} }
throw error; 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...'); console.log('\n📝 Setting up environment variables...');
const results: Array<{ key: string; success: boolean }> = []; const results: Array<{ key: string; success: boolean }> = [];
for (const [key, value] of Object.entries(envVars)) { for (const [key, value] of Object.entries(envVars)) {
if (!value) continue; if (!value) continue;
@ -416,19 +443,24 @@ async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: s
} }
// Report results // Report results
const failed = results.filter((r) => !r.success); const failed = results.filter(r => !r.success);
if (failed.length > 0) { if (failed.length > 0) {
console.warn(`\n⚠ Failed to set ${failed.length} environment variables:`); 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( 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; 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...'); console.log('\n⏳ Waiting for deployment to complete...');
const startTime = Date.now(); const startTime = Date.now();
@ -438,7 +470,7 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string,
projectId: projectId, projectId: projectId,
limit: 1, limit: 1,
}); });
if (deployments?.deployments?.[0]) { if (deployments?.deployments?.[0]) {
const deployment = deployments.deployments[0]; const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`); console.log(`📊 Deployment status: ${deployment.state}`);
@ -453,10 +485,10 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string,
} }
// Still building, wait and check again // 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 { } else {
console.log('⏳ No deployment found yet, waiting...'); console.log('⏳ No deployment found yet, waiting...');
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
@ -486,58 +518,60 @@ async function deployToVercel(useGitHub = false): Promise<void> {
framework: 'nextjs', framework: 'nextjs',
}, },
null, null,
2 2,
) ),
); );
} }
// Set up Vercel project // Set up Vercel project
console.log('\n📦 Setting up Vercel project...'); console.log('\n📦 Setting up Vercel project...');
console.log( 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( 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 // Use spawn instead of execSync for better error handling
const { spawn } = await import('child_process'); const { spawn } = await import('child_process');
const vercelSetup = spawn('vercel', [], { const vercelSetup = spawn('vercel', [], {
cwd: projectRoot, cwd: projectRoot,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32' ? true : undefined shell: process.platform === 'win32' ? true : undefined,
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelSetup.on('close', (code) => { vercelSetup.on('close', code => {
if (code === 0 || code === null) { if (code === 0 || code === null) {
console.log('✅ Vercel project setup completed'); console.log('✅ Vercel project setup completed');
resolve(); resolve();
} else { } 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 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)'); console.log('⚠️ Vercel setup command completed (this is normal)');
resolve(); // Don't reject, as this is often expected resolve(); // Don't reject, as this is often expected
}); });
}); });
// Wait a moment for project files to be written // 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 // Load project info
let projectId: string; let projectId: string;
try { try {
const projectJson = JSON.parse( const projectJson = JSON.parse(
fs.readFileSync('.vercel/project.json', 'utf8') fs.readFileSync('.vercel/project.json', 'utf8'),
); );
projectId = projectJson.projectId; projectId = projectJson.projectId;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; throw error;
} }
@ -548,13 +582,15 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const token = await getVercelToken(); const token = await getVercelToken();
if (token) { if (token) {
vercelClient = new Vercel({ vercelClient = new Vercel({
bearerToken: token bearerToken: token,
}); });
console.log('✅ Initialized Vercel SDK client'); console.log('✅ Initialized Vercel SDK client');
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; throw error;
} }
@ -567,7 +603,9 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient) { if (vercelClient) {
try { try {
const projects = await vercelClient.projects.getProjects({}); const projects = await vercelClient.projects.getProjects({});
const project = projects.projects.find(p => p.id === projectId || p.name === projectId); const project = projects.projects.find(
p => p.id === projectId || p.name === projectId,
);
if (project) { if (project) {
projectName = project.name; projectName = project.name;
domain = `${projectName}.vercel.app`; domain = `${projectName}.vercel.app`;
@ -577,7 +615,9 @@ async function deployToVercel(useGitHub = false): Promise<void> {
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; throw error;
} }
@ -591,7 +631,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
{ {
cwd: projectRoot, cwd: projectRoot,
encoding: 'utf8', encoding: 'utf8',
} },
); );
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@ -607,7 +647,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🌐 Using project name for domain:', domain); console.log('🌐 Using project name for domain:', domain);
} else { } else {
console.warn( 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 // Use a fallback domain based on project ID
domain = `project-${projectId.slice(-8)}.vercel.app`; domain = `project-${projectId.slice(-8)}.vercel.app`;
@ -628,15 +668,21 @@ async function deployToVercel(useGitHub = false): Promise<void> {
// Prepare environment variables // Prepare environment variables
const vercelEnv = { const vercelEnv = {
NEXT_PUBLIC_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`,
...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }), ...(process.env.NEYNAR_API_KEY && {
...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID }), NEYNAR_API_KEY: process.env.NEYNAR_API_KEY,
...(process.env.SPONSOR_SIGNER && { SPONSOR_SIGNER: process.env.SPONSOR_SIGNER }), }),
...(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.fromEntries(
Object.entries(process.env).filter(([key]) => Object.entries(process.env).filter(([key]) =>
key.startsWith('NEXT_PUBLIC_') key.startsWith('NEXT_PUBLIC_'),
) ),
), ),
}; };
@ -645,7 +691,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
vercelClient, vercelClient,
projectId, projectId,
vercelEnv, vercelEnv,
projectRoot projectRoot,
); );
// Deploy the project // Deploy the project
@ -669,7 +715,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelDeploy.on('close', (code) => { vercelDeploy.on('close', code => {
if (code === 0) { if (code === 0) {
console.log('✅ Vercel deployment command completed'); console.log('✅ Vercel deployment command completed');
resolve(); resolve();
@ -679,7 +725,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
} }
}); });
vercelDeploy.on('error', (error) => { vercelDeploy.on('error', error => {
console.error('❌ Vercel deployment error:', error.message); console.error('❌ Vercel deployment error:', error.message);
reject(error); reject(error);
}); });
@ -692,7 +738,10 @@ async function deployToVercel(useGitHub = false): Promise<void> {
deployment = await waitForDeployment(vercelClient, projectId); deployment = await waitForDeployment(vercelClient, projectId);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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...'); console.log(' Proceeding with domain verification...');
} }
throw error; throw error;
@ -706,10 +755,12 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient && deployment) { if (vercelClient && deployment) {
try { try {
actualDomain = deployment.url || domain; actualDomain = deployment.url || domain;
console.log('🌐 Verified actual domain:', actualDomain); console.log('🌐 Verified actual domain:', actualDomain);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { 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; throw error;
} }
@ -723,7 +774,12 @@ async function deployToVercel(useGitHub = false): Promise<void> {
NEXT_PUBLIC_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...'); console.log('\n📦 Redeploying with correct domain...');
const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], {
@ -733,7 +789,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelRedeploy.on('close', (code) => { vercelRedeploy.on('close', code => {
if (code === 0) { if (code === 0) {
console.log('✅ Redeployment completed'); console.log('✅ Redeployment completed');
resolve(); resolve();
@ -743,7 +799,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
} }
}); });
vercelRedeploy.on('error', (error) => { vercelRedeploy.on('error', error => {
console.error('❌ Redeployment error:', error.message); console.error('❌ Redeployment error:', error.message);
reject(error); reject(error);
}); });
@ -754,13 +810,24 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log('\n✨ Deployment complete! Your mini app is now live at:');
console.log(`🌐 https://${domain}`); 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 // 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(
console.log('1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + domain); `\n⚠ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`,
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(
'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.'); console.log('4. Paste it below when prompted.');
const { userAccountAssociation } = await inquirer.prompt([ const { userAccountAssociation } = await inquirer.prompt([
@ -778,8 +845,8 @@ async function deployToVercel(useGitHub = false): Promise<void> {
} catch (e) { } catch (e) {
return 'Invalid JSON'; return 'Invalid JSON';
} }
} },
} },
]); ]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation); const parsedAccountAssociation = JSON.parse(userAccountAssociation);
@ -791,11 +858,10 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`; const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
constantsContent = constantsContent.replace( constantsContent = constantsContent.replace(
/^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m, /^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
newAccountAssociation newAccountAssociation,
); );
fs.writeFileSync(constantsPath, constantsContent); fs.writeFileSync(constantsPath, constantsContent);
console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts'); console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts');
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
console.error('\n❌ Deployment failed:', error.message); console.error('\n❌ Deployment failed:', error.message);
@ -809,7 +875,7 @@ async function main(): Promise<void> {
try { try {
console.log('🚀 Vercel Mini App Deployment (SDK Edition)'); console.log('🚀 Vercel Mini App Deployment (SDK Edition)');
console.log( 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('\nThe script will:');
console.log('1. Check for required environment variables'); console.log('1. Check for required environment variables');
@ -823,9 +889,9 @@ async function main(): Promise<void> {
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
console.log('📦 Installing @vercel/sdk...'); console.log('📦 Installing @vercel/sdk...');
execSync('npm install @vercel/sdk', { execSync('npm install @vercel/sdk', {
cwd: projectRoot, cwd: projectRoot,
stdio: 'inherit' stdio: 'inherit',
}); });
console.log('✅ @vercel/sdk installed successfully'); console.log('✅ @vercel/sdk installed successfully');
} }
@ -885,7 +951,6 @@ async function main(): Promise<void> {
} }
await deployToVercel(useGitHub); await deployToVercel(useGitHub);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
console.error('\n❌ Error:', error.message); console.error('\n❌ Error:', error.message);

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export async function GET(request: Request) {
if (!message || !signature) { if (!message || !signature) {
return NextResponse.json( return NextResponse.json(
{ error: 'Message and signature are required' }, { 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); console.error('Error in session-signers API:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch signers' }, { 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); console.error('Error fetching signer:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch signer' }, { error: 'Failed to fetch signer' },
{ status: 500 } { status: 500 },
); );
} }
} }
@ -22,7 +22,7 @@ export async function GET(request: Request) {
if (!signerUuid) { if (!signerUuid) {
return NextResponse.json( return NextResponse.json(
{ error: 'signerUuid is required' }, { 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); console.error('Error fetching signed key:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch signed key' }, { error: 'Failed to fetch signed key' },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

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

View File

@ -8,15 +8,12 @@ export async function POST(request: Request) {
const { token } = await request.json(); const { token } = await request.json();
if (!token) { if (!token) {
return NextResponse.json( return NextResponse.json({ error: 'Token is required' }, { status: 400 });
{ error: 'Token is required' },
{ status: 400 }
);
} }
// Get domain from environment or request // Get domain from environment or request
const domain = process.env.NEXT_PUBLIC_URL const domain = process.env.NEXT_PUBLIC_URL
? new URL(process.env.NEXT_PUBLIC_URL).hostname ? new URL(process.env.NEXT_PUBLIC_URL).hostname
: request.headers.get('host') || 'localhost'; : request.headers.get('host') || 'localhost';
try { try {
@ -35,10 +32,7 @@ export async function POST(request: Request) {
} catch (e) { } catch (e) {
if (e instanceof Errors.InvalidTokenError) { if (e instanceof Errors.InvalidTokenError) {
console.info('Invalid token:', e.message); console.info('Invalid token:', e.message);
return NextResponse.json( return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
{ error: 'Invalid token' },
{ status: 401 }
);
} }
throw e; throw e;
} }
@ -46,7 +40,7 @@ export async function POST(request: Request) {
console.error('Token validation error:', error); console.error('Token validation error:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@ -4,18 +4,21 @@ export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY; const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fid = searchParams.get('fid'); const fid = searchParams.get('fid');
if (!apiKey) { if (!apiKey) {
return NextResponse.json( 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) { if (!fid) {
return NextResponse.json( return NextResponse.json(
{ error: 'FID parameter is required' }, { error: 'FID parameter is required' },
{ status: 400 } { status: 400 },
); );
} }
@ -24,23 +27,28 @@ export async function GET(request: Request) {
`https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`, `https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`,
{ {
headers: { headers: {
"x-api-key": apiKey, 'x-api-key': apiKey,
}, },
} },
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`Neynar API error: ${response.statusText}`); 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 }); return NextResponse.json({ bestFriends: users });
} catch (error) { } catch (error) {
console.error('Failed to fetch best friends:', error); console.error('Failed to fetch best friends:', error);
return NextResponse.json( 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 { ImageResponse } from 'next/og';
import { NextRequest } from "next/server"; import { NextRequest } from 'next/server';
import { getNeynarUser } from "~/lib/neynar"; import { getNeynarUser } from '~/lib/neynar';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -15,16 +15,24 @@ export async function GET(request: NextRequest) {
<div tw="flex h-full w-full flex-col justify-center items-center relative bg-primary"> <div tw="flex h-full w-full flex-col justify-center items-center relative bg-primary">
{user?.pfp_url && ( {user?.pfp_url && (
<div tw="flex w-96 h-96 rounded-full overflow-hidden mb-8 border-8 border-white"> <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> </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> <p tw="text-5xl mt-4 text-white opacity-80">Powered by Neynar 🪐</p>
</div> </div>
), ),
{ {
width: 1200, width: 1200,
height: 800, height: 800,
} },
); );
} }

View File

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

View File

@ -1,29 +1,32 @@
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
export async function GET(request: Request) { export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY; const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fids = searchParams.get('fids'); const fids = searchParams.get('fids');
if (!apiKey) { if (!apiKey) {
return NextResponse.json( 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) { if (!fids) {
return NextResponse.json( return NextResponse.json(
{ error: 'FIDs parameter is required' }, { error: 'FIDs parameter is required' },
{ status: 400 } { status: 400 },
); );
} }
try { try {
const neynar = new NeynarAPIClient({ apiKey }); const neynar = new NeynarAPIClient({ apiKey });
const fidsArray = fids.split(',').map(fid => parseInt(fid.trim())); const fidsArray = fids.split(',').map(fid => parseInt(fid.trim()));
const { users } = await neynar.fetchBulkUsers({ const { users } = await neynar.fetchBulkUsers({
fids: fidsArray, fids: fidsArray,
}); });
@ -32,8 +35,11 @@ export async function GET(request: Request) {
} catch (error) { } catch (error) {
console.error('Failed to fetch users:', error); console.error('Failed to fetch users:', error);
return NextResponse.json( 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,20 +1,21 @@
import { NextRequest } from 'next/server';
import { import {
ParseWebhookEvent, ParseWebhookEvent,
parseWebhookEvent, parseWebhookEvent,
verifyAppKeyWithNeynar, verifyAppKeyWithNeynar,
} from "@farcaster/miniapp-node"; } from '@farcaster/miniapp-node';
import { NextRequest } from "next/server"; import { APP_NAME } from '~/lib/constants';
import { APP_NAME } from "~/lib/constants";
import { import {
deleteUserNotificationDetails, deleteUserNotificationDetails,
setUserNotificationDetails, setUserNotificationDetails,
} from "~/lib/kv"; } from '~/lib/kv';
import { sendMiniAppNotification } from "~/lib/notifs"; import { sendMiniAppNotification } from '~/lib/notifs';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
// If Neynar is enabled, we don't need to handle webhooks here // If Neynar is enabled, we don't need to handle webhooks here
// as they will be handled by Neynar's webhook endpoint // 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) { if (neynarEnabled) {
return Response.json({ success: true }); return Response.json({ success: true });
} }
@ -28,24 +29,24 @@ export async function POST(request: NextRequest) {
const error = e as ParseWebhookEvent.ErrorType; const error = e as ParseWebhookEvent.ErrorType;
switch (error.name) { switch (error.name) {
case "VerifyJsonFarcasterSignature.InvalidDataError": case 'VerifyJsonFarcasterSignature.InvalidDataError':
case "VerifyJsonFarcasterSignature.InvalidEventDataError": case 'VerifyJsonFarcasterSignature.InvalidEventDataError':
// The request data is invalid // The request data is invalid
return Response.json( return Response.json(
{ success: false, error: error.message }, { success: false, error: error.message },
{ status: 400 } { status: 400 },
); );
case "VerifyJsonFarcasterSignature.InvalidAppKeyError": case 'VerifyJsonFarcasterSignature.InvalidAppKeyError':
// The app key is invalid // The app key is invalid
return Response.json( return Response.json(
{ success: false, error: error.message }, { 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) // Internal error verifying the app key (caller may want to try again)
return Response.json( return Response.json(
{ success: false, error: error.message }, { success: false, error: error.message },
{ status: 500 } { status: 500 },
); );
} }
} }
@ -56,33 +57,33 @@ export async function POST(request: NextRequest) {
// Only handle notifications if Neynar is not enabled // Only handle notifications if Neynar is not enabled
// When Neynar is enabled, notifications are handled through their webhook // When Neynar is enabled, notifications are handled through their webhook
switch (event.event) { switch (event.event) {
case "frame_added": case 'frame_added':
if (event.notificationDetails) { if (event.notificationDetails) {
await setUserNotificationDetails(fid, event.notificationDetails); await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({ await sendMiniAppNotification({
fid, fid,
title: `Welcome to ${APP_NAME}`, title: `Welcome to ${APP_NAME}`,
body: "Mini app is now added to your client", body: 'Mini app is now added to your client',
}); });
} else { } else {
await deleteUserNotificationDetails(fid); await deleteUserNotificationDetails(fid);
} }
break; break;
case "frame_removed": case 'frame_removed':
await deleteUserNotificationDetails(fid); await deleteUserNotificationDetails(fid);
break; break;
case "notifications_enabled": case 'notifications_enabled':
await setUserNotificationDetails(fid, event.notificationDetails); await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({ await sendMiniAppNotification({
fid, fid,
title: `Welcome to ${APP_NAME}`, title: `Welcome to ${APP_NAME}`,
body: "Notifications are now enabled", body: 'Notifications are now enabled',
}); });
break; break;
case "notifications_disabled": case 'notifications_disabled':
await deleteUserNotificationDetails(fid); await deleteUserNotificationDetails(fid);
break; break;
} }

View File

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

View File

@ -62,11 +62,11 @@ body {
.container { .container {
@apply mx-auto max-w-md px-4; @apply mx-auto max-w-md px-4;
} }
.container-wide { .container-wide {
@apply mx-auto max-w-lg px-4; @apply mx-auto max-w-lg px-4;
} }
.container-narrow { .container-narrow {
@apply mx-auto max-w-sm px-4; @apply mx-auto max-w-sm px-4;
} }
@ -75,7 +75,7 @@ body {
.card { .card {
@apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm; @apply bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm;
} }
.card-primary { .card-primary {
@apply bg-primary/10 border-primary/20; @apply bg-primary/10 border-primary/20;
} }
@ -84,15 +84,15 @@ body {
.btn { .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; @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 { .btn-primary {
@apply bg-primary text-white hover:bg-primary-dark focus:ring-primary; @apply bg-primary text-white hover:bg-primary-dark focus:ring-primary;
} }
.btn-secondary { .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; @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 { .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; @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 { .spinner {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary; @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary;
} }
.spinner-primary { .spinner-primary {
@apply animate-spin rounded-full border-2 border-white border-t-transparent; @apply animate-spin rounded-full border-2 border-white border-t-transparent;
} }

View File

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

View File

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

View File

@ -9,14 +9,10 @@ const WagmiProvider = dynamic(
() => import('~/components/providers/WagmiProvider'), () => import('~/components/providers/WagmiProvider'),
{ {
ssr: false, ssr: false,
} },
); );
export function Providers({ export function Providers({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const solanaEndpoint = const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React from "react"; import React from 'react';
import { Tab } from "~/components/App"; import { Tab } from '~/components/App';
interface FooterProps { interface FooterProps {
activeTab: Tab; activeTab: Tab;
@ -7,13 +7,19 @@ interface FooterProps {
showWallet?: boolean; 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="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"> <div className="flex justify-around items-center h-14">
<button <button
onClick={() => setActiveTab(Tab.Home)} onClick={() => setActiveTab(Tab.Home)}
className={`flex flex-col items-center justify-center w-full h-full ${ 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> <span className="text-xl">🏠</span>
@ -22,7 +28,9 @@ export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWal
<button <button
onClick={() => setActiveTab(Tab.Actions)} onClick={() => setActiveTab(Tab.Actions)}
className={`flex flex-col items-center justify-center w-full h-full ${ 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> <span className="text-xl"></span>
@ -31,7 +39,9 @@ export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWal
<button <button
onClick={() => setActiveTab(Tab.Context)} onClick={() => setActiveTab(Tab.Context)}
className={`flex flex-col items-center justify-center w-full h-full ${ 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> <span className="text-xl">📋</span>
@ -41,7 +51,9 @@ export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWal
<button <button
onClick={() => setActiveTab(Tab.Wallet)} onClick={() => setActiveTab(Tab.Wallet)}
className={`flex flex-col items-center justify-center w-full h-full ${ 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> <span className="text-xl">👛</span>

View File

@ -1,9 +1,10 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import { APP_NAME } from "~/lib/constants"; import Image from 'next/image';
import sdk from "@farcaster/miniapp-sdk"; import sdk from '@farcaster/miniapp-sdk';
import { useMiniApp } from "@neynar/react"; import { useMiniApp } from '@neynar/react';
import { APP_NAME } from '~/lib/constants';
type HeaderProps = { type HeaderProps = {
neynarUser?: { neynarUser?: {
@ -18,23 +19,19 @@ export function Header({ neynarUser }: HeaderProps) {
return ( return (
<div className="relative"> <div className="relative">
<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">
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="text-lg font-light">
Welcome to {APP_NAME}!
</div>
{context?.user && ( {context?.user && (
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
setIsUserDropdownOpen(!isUserDropdownOpen); setIsUserDropdownOpen(!isUserDropdownOpen);
}} }}
> >
{context.user.pfpUrl && ( {context.user.pfpUrl && (
<img <Image
src={context.user.pfpUrl} src={context.user.pfpUrl}
alt="Profile" alt="Profile"
className="w-10 h-10 rounded-full border-2 border-primary" className="w-10 h-10 rounded-full border-2 border-primary"
/> />
)} )}
@ -42,14 +39,16 @@ export function Header({ neynarUser }: HeaderProps) {
)} )}
</div> </div>
{context?.user && ( {context?.user && (
<> <>
{isUserDropdownOpen && ( {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="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="p-3 space-y-2">
<div className="text-right"> <div className="text-right">
<h3 <h3
className="font-bold text-sm hover:underline cursor-pointer inline-block" 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} {context.user.displayName || context.user.username}
</h3> </h3>

View File

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

View File

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

View File

@ -1,13 +1,13 @@
'use client'; 'use client';
import { useCallback, useEffect, useState, useRef } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react';
import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/Button';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { useMiniApp } from '@neynar/react';
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
import { useMiniApp } from '@neynar/react';
import { Button } from '~/components/ui/Button';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { useQuickAuth } from '~/hooks/useQuickAuth'; import { useQuickAuth } from '~/hooks/useQuickAuth';
import { cn } from '~/lib/utils';
type User = { type User = {
fid: number; fid: number;
@ -90,24 +90,31 @@ export function NeynarAuthButton() {
const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(null); const [storedAuth, setStoredAuth] = useState<StoredAuthState | null>(null);
const [signersLoading, setSignersLoading] = useState(false); const [signersLoading, setSignersLoading] = useState(false);
const { context } = useMiniApp(); const { context } = useMiniApp();
const { authenticatedUser: quickAuthUser, signIn: quickAuthSignIn, signOut: quickAuthSignOut } = useQuickAuth(); const {
authenticatedUser: quickAuthUser,
signIn: quickAuthSignIn,
signOut: quickAuthSignOut,
} = useQuickAuth();
// New state for unified dialog flow // New state for unified dialog flow
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
'loading' 'loading',
); );
const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>( const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>(
null null,
); );
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>( const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null null,
); );
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(null); const [signature, setSignature] = useState<string | null>(null);
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
const signerFlowStartedRef = useRef(false); const signerFlowStartedRef = useRef(false);
const [backendUserProfile, setBackendUserProfile] = useState<{ username?: string; pfpUrl?: string }>({}); const [backendUserProfile, setBackendUserProfile] = useState<{
username?: string;
pfpUrl?: string;
}>({});
// Determine which flow to use based on context // Determine which flow to use based on context
const useBackendFlow = context !== undefined; const useBackendFlow = context !== undefined;
@ -135,7 +142,7 @@ export function NeynarAuthButton() {
const updateSessionWithSigners = useCallback( const updateSessionWithSigners = useCallback(
async ( async (
signers: StoredAuthState['signers'], signers: StoredAuthState['signers'],
user: StoredAuthState['user'] _user: StoredAuthState['user'],
) => { ) => {
if (!useBackendFlow) return; if (!useBackendFlow) return;
@ -148,7 +155,7 @@ export function NeynarAuthButton() {
console.error('❌ Error updating session with signers:', error); console.error('❌ Error updating session with signers:', error);
} }
}, },
[useBackendFlow, quickAuthSignIn] [useBackendFlow, quickAuthSignIn],
); );
// Helper function to fetch user data from Neynar API // Helper function to fetch user data from Neynar API
@ -166,7 +173,7 @@ export function NeynarAuthButton() {
return null; return null;
} }
}, },
[] [],
); );
// Helper function to generate signed key request // Helper function to generate signed key request
@ -194,7 +201,7 @@ export function NeynarAuthButton() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error( throw new Error(
`Failed to generate signed key request: ${errorData.error}` `Failed to generate signed key request: ${errorData.error}`,
); );
} }
@ -206,7 +213,7 @@ export function NeynarAuthButton() {
// throw error; // throw error;
} }
}, },
[] [],
); );
// Helper function to fetch all signers // Helper function to fetch all signers
@ -217,10 +224,10 @@ export function NeynarAuthButton() {
const endpoint = useBackendFlow const endpoint = useBackendFlow
? `/api/auth/session-signers?message=${encodeURIComponent( ? `/api/auth/session-signers?message=${encodeURIComponent(
message message,
)}&signature=${signature}` )}&signature=${signature}`
: `/api/auth/signers?message=${encodeURIComponent( : `/api/auth/signers?message=${encodeURIComponent(
message message,
)}&signature=${signature}`; )}&signature=${signature}`;
const response = await fetch(endpoint); const response = await fetch(endpoint);
@ -229,11 +236,13 @@ export function NeynarAuthButton() {
if (response.ok) { if (response.ok) {
if (useBackendFlow) { if (useBackendFlow) {
// For backend flow, update session with signers // For backend flow, update session with signers
if (signerData.signers && signerData.signers.length > 0) { if (signerData.signers && signerData.signers.length > 0) {
// Get user data for the first signer // Get user data for the first signer
let user: StoredAuthState['user'] | null = null; let user: StoredAuthState['user'] | null = null;
if (signerData.signers[0].fid) { if (signerData.signers[0].fid) {
user = await fetchUserData(signerData.signers[0].fid) as StoredAuthState['user']; user = (await fetchUserData(
signerData.signers[0].fid,
)) as StoredAuthState['user'];
} }
await updateSessionWithSigners(signerData.signers, user); await updateSessionWithSigners(signerData.signers, user);
} }
@ -244,7 +253,7 @@ export function NeynarAuthButton() {
if (signerData.signers && signerData.signers.length > 0) { if (signerData.signers && signerData.signers.length > 0) {
const fetchedUser = (await fetchUserData( const fetchedUser = (await fetchUserData(
signerData.signers[0].fid signerData.signers[0].fid,
)) as StoredAuthState['user']; )) as StoredAuthState['user'];
user = fetchedUser; user = fetchedUser;
} }
@ -270,7 +279,7 @@ export function NeynarAuthButton() {
setSignersLoading(false); setSignersLoading(false);
} }
}, },
[useBackendFlow, fetchUserData, updateSessionWithSigners] [useBackendFlow, fetchUserData, updateSessionWithSigners],
); );
// Helper function to poll signer status // Helper function to poll signer status
@ -293,10 +302,10 @@ export function NeynarAuthButton() {
setPollingInterval(null); setPollingInterval(null);
return; return;
} }
try { try {
const response = await fetch( const response = await fetch(
`/api/auth/signer?signerUuid=${signerUuid}` `/api/auth/signer?signerUuid=${signerUuid}`,
); );
if (!response.ok) { if (!response.ok) {
@ -306,7 +315,7 @@ export function NeynarAuthButton() {
setPollingInterval(null); setPollingInterval(null);
return; return;
} }
// Increment retry count for other errors // Increment retry count for other errors
retryCount++; retryCount++;
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
@ -314,7 +323,7 @@ export function NeynarAuthButton() {
setPollingInterval(null); setPollingInterval(null);
return; return;
} }
throw new Error(`Failed to poll signer status: ${response.status}`); throw new Error(`Failed to poll signer status: ${response.status}`);
} }
@ -337,7 +346,7 @@ export function NeynarAuthButton() {
setPollingInterval(interval); setPollingInterval(interval);
}, },
[fetchAllSigners, pollingInterval] [fetchAllSigners, pollingInterval],
); );
// Cleanup polling on unmount // Cleanup polling on unmount
@ -383,7 +392,7 @@ export function NeynarAuthButton() {
setMessage(result.message); setMessage(result.message);
setSignature(result.signature); setSignature(result.signature);
// Use QuickAuth to sign in // Use QuickAuth to sign in
const signInResult = await quickAuthSignIn(); await quickAuthSignIn();
// Fetch user profile after sign in // Fetch user profile after sign in
if (quickAuthUser?.fid) { if (quickAuthUser?.fid) {
const user = await fetchUserData(quickAuthUser.fid); const user = await fetchUserData(quickAuthUser.fid);
@ -418,10 +427,10 @@ export function NeynarAuthButton() {
try { try {
setSignersLoading(true); setSignersLoading(true);
const result = await sdk.actions.signIn({ nonce: nonce || '' }); const result = await sdk.actions.signIn({ nonce: nonce || '' });
setMessage(result.message); setMessage(result.message);
setSignature(result.signature); setSignature(result.signature);
// For frontend flow, we'll handle the signer flow in the useEffect // For frontend flow, we'll handle the signer flow in the useEffect
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
@ -458,7 +467,7 @@ export function NeynarAuthButton() {
clearInterval(pollingInterval); clearInterval(pollingInterval);
setPollingInterval(null); setPollingInterval(null);
} }
// Reset signer flow flag // Reset signer flow flag
signerFlowStartedRef.current = false; signerFlowStartedRef.current = false;
} catch (error) { } catch (error) {
@ -471,9 +480,14 @@ export function NeynarAuthButton() {
// Handle fetching signers after successful authentication // Handle fetching signers after successful authentication
useEffect(() => { useEffect(() => {
if (message && signature && !isSignerFlowRunning && !signerFlowStartedRef.current) { if (
message &&
signature &&
!isSignerFlowRunning &&
!signerFlowStartedRef.current
) {
signerFlowStartedRef.current = true; signerFlowStartedRef.current = true;
const handleSignerFlow = async () => { const handleSignerFlow = async () => {
setIsSignerFlowRunning(true); setIsSignerFlowRunning(true);
try { try {
@ -491,7 +505,7 @@ export function NeynarAuthButton() {
// First, fetch existing signers // First, fetch existing signers
const signers = await fetchAllSigners(message, signature); const signers = await fetchAllSigners(message, signature);
if (useBackendFlow && isMobileContext) setSignersLoading(true); if (useBackendFlow && isMobileContext) setSignersLoading(true);
// Check if no signers exist or if we have empty signers // Check if no signers exist or if we have empty signers
@ -502,7 +516,7 @@ export function NeynarAuthButton() {
// Step 2: Generate signed key request // Step 2: Generate signed key request
const signedKeyData = await generateSignedKeyRequest( const signedKeyData = await generateSignedKeyRequest(
newSigner.signer_uuid, newSigner.signer_uuid,
newSigner.public_key newSigner.public_key,
); );
// Step 3: Show QR code in access dialog for signer approval // Step 3: Show QR code in access dialog for signer approval
@ -513,8 +527,8 @@ export function NeynarAuthButton() {
await sdk.actions.openUrl( await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace( signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/signed-key-request', 'https://client.farcaster.xyz/deeplinks/signed-key-request',
'https://farcaster.xyz/~/connect' 'https://farcaster.xyz/~/connect',
) ),
); );
} else { } else {
setShowDialog(true); // Ensure dialog is shown during loading setShowDialog(true); // Ensure dialog is shown during loading
@ -547,7 +561,8 @@ export function NeynarAuthButton() {
const authenticated = useBackendFlow const authenticated = useBackendFlow
? !!quickAuthUser?.fid ? !!quickAuthUser?.fid
: storedAuth?.isAuthenticated && !!(storedAuth?.signers && storedAuth.signers.length > 0); : storedAuth?.isAuthenticated &&
!!(storedAuth?.signers && storedAuth.signers.length > 0);
const userData = useBackendFlow const userData = useBackendFlow
? { ? {
@ -586,7 +601,7 @@ export function NeynarAuthButton() {
className={cn( className={cn(
'btn btn-primary flex items-center gap-3', 'btn btn-primary flex items-center gap-3',
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
'transform transition-all duration-200 active:scale-[0.98]' 'transform transition-all duration-200 active:scale-[0.98]',
)} )}
> >
{signersLoading ? ( {signersLoading ? (

View File

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

View File

@ -1,22 +1,21 @@
import * as React from "react" import * as React from 'react';
import { cn } from '~/lib/utils';
import { cn } from "~/lib/utils" const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( 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", '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 className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = 'Input';
export { Input } export { Input };

View File

@ -1,14 +1,13 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
import { cn } from "~/lib/utils"
const labelVariants = cva( 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< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
@ -20,7 +19,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />
)) ));
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName;
export { Label } export { Label };

View File

@ -1,13 +1,13 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { useMiniApp } from "@neynar/react"; import { type Haptics } from '@farcaster/miniapp-sdk';
import { ShareButton } from "../Share"; import { useMiniApp } from '@neynar/react';
import { Button } from "../Button"; import { APP_URL } from '~/lib/constants';
import { SignIn } from "../wallet/SignIn"; import { Button } from '../Button';
import { type Haptics } from "@farcaster/miniapp-sdk";
import { APP_URL } from "~/lib/constants";
import { NeynarAuthButton } from '../NeynarAuthButton/index'; import { NeynarAuthButton } from '../NeynarAuthButton/index';
import { ShareButton } from '../Share';
import { SignIn } from '../wallet/SignIn';
/** /**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
@ -52,7 +52,7 @@ export function ActionsTab() {
* @returns Promise that resolves when the notification is sent or fails * @returns Promise that resolves when the notification is sent or fails
*/ */
const sendFarcasterNotification = useCallback(async () => { const sendFarcasterNotification = useCallback(async () => {
setNotificationState((prev) => ({ ...prev, sendStatus: '' })); setNotificationState(prev => ({ ...prev, sendStatus: '' }));
if (!notificationDetails || !context) { if (!notificationDetails || !context) {
return; return;
} }
@ -67,22 +67,22 @@ export function ActionsTab() {
}), }),
}); });
if (response.status === 200) { if (response.status === 200) {
setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' })); setNotificationState(prev => ({ ...prev, sendStatus: 'Success' }));
return; return;
} else if (response.status === 429) { } else if (response.status === 429) {
setNotificationState((prev) => ({ setNotificationState(prev => ({
...prev, ...prev,
sendStatus: 'Rate limited', sendStatus: 'Rate limited',
})); }));
return; return;
} }
const responseText = await response.text(); const responseText = await response.text();
setNotificationState((prev) => ({ setNotificationState(prev => ({
...prev, ...prev,
sendStatus: `Error: ${responseText}`, sendStatus: `Error: ${responseText}`,
})); }));
} catch (error) { } catch (error) {
setNotificationState((prev) => ({ setNotificationState(prev => ({
...prev, ...prev,
sendStatus: `Error: ${error}`, sendStatus: `Error: ${error}`,
})); }));
@ -99,11 +99,11 @@ export function ActionsTab() {
if (context?.user?.fid) { if (context?.user?.fid) {
const userShareUrl = `${APP_URL}/share/${context.user.fid}`; const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl); await navigator.clipboard.writeText(userShareUrl);
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); setNotificationState(prev => ({ ...prev, shareUrlCopied: true }));
setTimeout( setTimeout(
() => () =>
setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), setNotificationState(prev => ({ ...prev, shareUrlCopied: false })),
2000 2000,
); );
} }
}, [context?.user?.fid]); }, [context?.user?.fid]);
@ -124,16 +124,16 @@ export function ActionsTab() {
// --- Render --- // --- Render ---
return ( return (
<div className='space-y-3 px-6 w-full max-w-md mx-auto'> <div className="space-y-3 px-6 w-full max-w-md mx-auto">
{/* Share functionality */} {/* Share functionality */}
<ShareButton <ShareButton
buttonText='Share Mini App' buttonText="Share Mini App"
cast={{ cast={{
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐', text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
bestFriends: true, bestFriends: true,
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`] embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`],
}} }}
className='w-full' className="w-full"
/> />
{/* Authentication */} {/* Authentication */}
@ -147,25 +147,25 @@ export function ActionsTab() {
onClick={() => onClick={() =>
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ') actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
} }
className='w-full' className="w-full"
> >
Open Link Open Link
</Button> </Button>
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'> <Button onClick={actions.addMiniApp} disabled={added} className="w-full">
Add Mini App to Client Add Mini App to Client
</Button> </Button>
{/* Notification functionality */} {/* Notification functionality */}
{notificationState.sendStatus && ( {notificationState.sendStatus && (
<div className='text-sm w-full'> <div className="text-sm w-full">
Send notification result: {notificationState.sendStatus} Send notification result: {notificationState.sendStatus}
</div> </div>
)} )}
<Button <Button
onClick={sendFarcasterNotification} onClick={sendFarcasterNotification}
disabled={!notificationDetails} disabled={!notificationDetails}
className='w-full' className="w-full"
> >
Send notification Send notification
</Button> </Button>
@ -174,24 +174,24 @@ export function ActionsTab() {
<Button <Button
onClick={copyUserShareUrl} onClick={copyUserShareUrl}
disabled={!context?.user?.fid} disabled={!context?.user?.fid}
className='w-full' className="w-full"
> >
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'} {notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
</Button> </Button>
{/* Haptic feedback controls */} {/* Haptic feedback controls */}
<div className='space-y-2'> <div className="space-y-2">
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Haptic Intensity Haptic Intensity
</label> </label>
<select <select
value={selectedHapticIntensity} value={selectedHapticIntensity}
onChange={(e) => onChange={e =>
setSelectedHapticIntensity( 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' className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value={'light'}>Light</option> <option value={'light'}>Light</option>
<option value={'medium'}>Medium</option> <option value={'medium'}>Medium</option>
@ -199,7 +199,7 @@ export function ActionsTab() {
<option value={'soft'}>Soft</option> <option value={'soft'}>Soft</option>
<option value={'rigid'}>Rigid</option> <option value={'rigid'}>Rigid</option>
</select> </select>
<Button onClick={triggerHapticFeedback} className='w-full'> <Button onClick={triggerHapticFeedback} className="w-full">
Trigger Haptic Feedback Trigger Haptic Feedback
</Button> </Button>
</div> </div>

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

View File

@ -1,12 +1,12 @@
"use client"; 'use client';
/** /**
* HomeTab component displays the main landing content for the mini app. * 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. * 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 * It provides a simple welcome message and placeholder content that can be
* customized for specific use cases. * customized for specific use cases.
* *
* @example * @example
* ```tsx * ```tsx
* <HomeTab /> * <HomeTab />
@ -17,8 +17,10 @@ export function HomeTab() {
<div className="flex items-center justify-center h-[calc(100vh-200px)] px-6"> <div className="flex items-center justify-center h-[calc(100vh-200px)] px-6">
<div className="text-center w-full max-w-md mx-auto"> <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-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>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk"; import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import { Button } from "../Button"; import { useQuickAuth } from '~/hooks/useQuickAuth';
import { useQuickAuth } from "~/hooks/useQuickAuth"; import { Button } from '../Button';
/** /**
* SignIn component handles Farcaster authentication using QuickAuth. * SignIn component handles Farcaster authentication using QuickAuth.
@ -52,11 +52,11 @@ export function SignIn() {
*/ */
const handleSignIn = useCallback(async () => { const handleSignIn = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingIn: true })); setAuthState(prev => ({ ...prev, signingIn: true }));
setSignInFailure(undefined); setSignInFailure(undefined);
const success = await signIn(); const success = await signIn();
if (!success) { if (!success) {
setSignInFailure('Authentication failed'); setSignInFailure('Authentication failed');
} }
@ -67,7 +67,7 @@ export function SignIn() {
} }
setSignInFailure('Unknown error'); setSignInFailure('Unknown error');
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingIn: false })); setAuthState(prev => ({ ...prev, signingIn: false }));
} }
}, [signIn]); }, [signIn]);
@ -80,10 +80,10 @@ export function SignIn() {
*/ */
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingOut: true })); setAuthState(prev => ({ ...prev, signingOut: true }));
await signOut(); await signOut();
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingOut: false })); setAuthState(prev => ({ ...prev, signingOut: false }));
} }
}, [signOut]); }, [signOut]);
@ -105,7 +105,9 @@ export function SignIn() {
{/* Session Information */} {/* Session Information */}
{authenticatedUser && ( {authenticatedUser && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <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">
Authenticated User
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(authenticatedUser, null, 2)} {JSON.stringify(authenticatedUser, null, 2)}
</div> </div>
@ -115,8 +117,12 @@ export function SignIn() {
{/* Error Display */} {/* Error Display */}
{signInFailure && !authState.signingIn && ( {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="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">
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div> Authentication Error
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{signInFailure}
</div>
</div> </div>
)} )}
</> </>

View File

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

View File

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

View File

@ -34,25 +34,25 @@ interface UseQuickAuthReturn {
/** /**
* Custom hook for managing QuickAuth authentication state * Custom hook for managing QuickAuth authentication state
* *
* This hook provides a complete authentication flow using Farcaster's QuickAuth: * This hook provides a complete authentication flow using Farcaster's QuickAuth:
* - Automatically checks for existing authentication on mount * - Automatically checks for existing authentication on mount
* - Validates tokens with the server-side API * - Validates tokens with the server-side API
* - Manages authentication state in memory (no persistence) * - Manages authentication state in memory (no persistence)
* - Provides sign-in/sign-out functionality * - Provides sign-in/sign-out functionality
* *
* QuickAuth tokens are managed in memory only, so signing out of the Farcaster * 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. * client will automatically sign the user out of this mini app as well.
* *
* @returns {UseQuickAuthReturn} Object containing user state and authentication methods * @returns {UseQuickAuthReturn} Object containing user state and authentication methods
* *
* @example * @example
* ```tsx * ```tsx
* const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); * const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
* *
* if (status === 'loading') return <div>Loading...</div>; * if (status === 'loading') return <div>Loading...</div>;
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>; * if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
* *
* return ( * return (
* <div> * <div>
* <p>Welcome, FID: {authenticatedUser?.fid}</p> * <p>Welcome, FID: {authenticatedUser?.fid}</p>
@ -63,17 +63,20 @@ interface UseQuickAuthReturn {
*/ */
export function useQuickAuth(): UseQuickAuthReturn { export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data // Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(null); const [authenticatedUser, setAuthenticatedUser] =
useState<AuthenticatedUser | null>(null);
// Current authentication status // Current authentication status
const [status, setStatus] = useState<QuickAuthStatus>('loading'); const [status, setStatus] = useState<QuickAuthStatus>('loading');
/** /**
* Validates a QuickAuth token with the server-side API * Validates a QuickAuth token with the server-side API
* *
* @param {string} authToken - The JWT token to validate * @param {string} authToken - The JWT token to validate
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise * @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
*/ */
const validateTokenWithServer = async (authToken: string): Promise<AuthenticatedUser | null> => { const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
try { try {
const validationResponse = await fetch('/api/auth/validate', { const validationResponse = await fetch('/api/auth/validate', {
method: 'POST', method: 'POST',
@ -85,7 +88,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
const responseData = await validationResponse.json(); const responseData = await validationResponse.json();
return responseData.user; return responseData.user;
} }
return null; return null;
} catch (error) { } catch (error) {
console.error('Token validation failed:', error); console.error('Token validation failed:', error);
@ -102,11 +105,11 @@ export function useQuickAuth(): UseQuickAuthReturn {
try { try {
// Attempt to retrieve existing token from QuickAuth SDK // Attempt to retrieve existing token from QuickAuth SDK
const { token } = await sdk.quickAuth.getToken(); const { token } = await sdk.quickAuth.getToken();
if (token) { if (token) {
// Validate the token with our server-side API // Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token); const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) { if (validatedUserSession) {
// Token is valid, set authenticated state // Token is valid, set authenticated state
setAuthenticatedUser(validatedUserSession); setAuthenticatedUser(validatedUserSession);
@ -130,24 +133,24 @@ export function useQuickAuth(): UseQuickAuthReturn {
/** /**
* Initiates the QuickAuth sign-in process * Initiates the QuickAuth sign-in process
* *
* Uses sdk.quickAuth.getToken() to get a QuickAuth session token. * Uses sdk.quickAuth.getToken() to get a QuickAuth session token.
* If there is already a session token in memory that hasn't expired, * 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. * it will be immediately returned, otherwise a fresh one will be acquired.
* *
* @returns {Promise<boolean>} True if sign-in was successful, false otherwise * @returns {Promise<boolean>} True if sign-in was successful, false otherwise
*/ */
const signIn = useCallback(async (): Promise<boolean> => { const signIn = useCallback(async (): Promise<boolean> => {
try { try {
setStatus('loading'); setStatus('loading');
// Get QuickAuth session token // Get QuickAuth session token
const { token } = await sdk.quickAuth.getToken(); const { token } = await sdk.quickAuth.getToken();
if (token) { if (token) {
// Validate the token with our server-side API // Validate the token with our server-side API
const validatedUserSession = await validateTokenWithServer(token); const validatedUserSession = await validateTokenWithServer(token);
if (validatedUserSession) { if (validatedUserSession) {
// Authentication successful, update user state // Authentication successful, update user state
setAuthenticatedUser(validatedUserSession); setAuthenticatedUser(validatedUserSession);
@ -155,7 +158,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
return true; return true;
} }
} }
// Authentication failed, clear user state // Authentication failed, clear user state
setStatus('unauthenticated'); setStatus('unauthenticated');
return false; return false;
@ -168,7 +171,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
/** /**
* Signs out the current user and clears the authentication state * Signs out the current user and clears the authentication state
* *
* Since QuickAuth tokens are managed in memory only, this simply clears * Since QuickAuth tokens are managed in memory only, this simply clears
* the local user state. The actual token will be cleared when the * the local user state. The actual token will be cleared when the
* user signs out of their Farcaster client. * user signs out of their Farcaster client.
@ -181,7 +184,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
/** /**
* Retrieves the current authentication token from QuickAuth * Retrieves the current authentication token from QuickAuth
* *
* @returns {Promise<string | null>} The current auth token, or null if not authenticated * @returns {Promise<string | null>} The current auth token, or null if not authenticated
*/ */
const getToken = useCallback(async (): Promise<string | null> => { const getToken = useCallback(async (): Promise<string | null> => {
@ -201,4 +204,4 @@ export function useQuickAuth(): UseQuickAuthReturn {
signOut, signOut,
getToken, getToken,
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,60 +0,0 @@
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", "buildCommand": "next build",
"framework": "nextjs" "framework": "nextjs"
} }