Merge branch 'main' into veganbeef/fix-siwn

This commit is contained in:
veganbeef 2025-07-15 09:29:35 -07:00
commit b72198575a
No known key found for this signature in database
72 changed files with 6224 additions and 1046 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"
}
}
]
} }

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';
@ -488,20 +493,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
@ -529,21 +534,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);
@ -554,7 +559,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:
@ -562,7 +568,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,
}; };
@ -572,7 +579,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
@ -580,9 +587,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)
@ -590,21 +597,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)
@ -612,9 +619,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
@ -622,7 +629,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
@ -630,7 +637,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);
@ -640,14 +647,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) {
@ -658,7 +665,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',
); );
} }
@ -712,7 +719,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

@ -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

@ -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

@ -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');
} }
@ -171,7 +173,7 @@ 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) {
@ -185,7 +187,7 @@ 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') {
@ -299,8 +311,8 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
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,14 +404,22 @@ 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 }> = [];
@ -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();
@ -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`;
@ -629,14 +669,20 @@ async function deployToVercel(useGitHub = false): Promise<void> {
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');
@ -825,7 +891,7 @@ async function main(): Promise<void> {
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,7 +33,7 @@ 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', () => {
@ -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);
} }
@ -145,13 +152,15 @@ async function startDev() {
} }
// 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

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,10 +8,7 @@ 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
@ -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

@ -7,15 +7,18 @@ export async function GET(request: Request) {
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,5 +1,5 @@
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;
@ -8,15 +8,18 @@ export async function GET(request: Request) {
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 },
); );
} }
@ -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

@ -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,

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 {
@ -50,16 +55,11 @@ export interface AppProps {
* ``` * ```
*/ */
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);
@ -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,7 +17,8 @@ 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);
@ -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

@ -7,41 +7,38 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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 = { const sizeClasses = {
sm: "px-3 py-1.5 text-xs", sm: 'px-3 py-1.5 text-xs',
md: "px-4 py-2 text-sm", md: 'px-4 py-2 text-sm',
lg: "px-6 py-3 text-base" lg: 'px-6 py-3 text-base',
}; };
const fullWidthClasses = "w-full max-w-xs mx-auto block"; const fullWidthClasses = 'w-full max-w-xs mx-auto block';
const combinedClasses = [ 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,12 +19,8 @@ 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"
@ -32,7 +29,7 @@ export function Header({ neynarUser }: HeaderProps) {
}} }}
> >
{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"
@ -49,7 +46,9 @@ export function Header({ neynarUser }: HeaderProps) {
<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

@ -5,6 +5,7 @@ import { useSignIn } from '@farcaster/auth-kit';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { cn } from '~/lib/utils'; import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/Button'; import { Button } from '~/components/ui/Button';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { getItem, removeItem, setItem } from '~/lib/localStorage'; import { getItem, removeItem, setItem } from '~/lib/localStorage';
@ -102,13 +103,13 @@ export function NeynarAuthButton() {
// 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);
@ -139,7 +140,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;
@ -180,7 +181,7 @@ export function NeynarAuthButton() {
return null; return null;
} }
}, },
[] [],
); );
// Helper function to generate signed key request // Helper function to generate signed key request
@ -208,7 +209,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}`,
); );
} }
@ -220,7 +221,7 @@ export function NeynarAuthButton() {
// throw error; // throw error;
} }
}, },
[] [],
); );
// Helper function to fetch all signers // Helper function to fetch all signers
@ -231,10 +232,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);
@ -256,7 +257,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;
} }
@ -283,7 +284,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
@ -292,7 +293,7 @@ export function NeynarAuthButton() {
const interval = setInterval(async () => { const interval = setInterval(async () => {
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) {
@ -446,7 +447,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

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,12 +1,12 @@
'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 { ShareButton } from '../Share';
import { APP_URL } from "~/lib/constants"; import { SignIn } from '../wallet/SignIn';
// Optional import for NeynarAuthButton - may not exist in all templates // Optional import for NeynarAuthButton - may not exist in all templates
let NeynarAuthButton: React.ComponentType | null = null; let NeynarAuthButton: React.ComponentType | null = null;
@ -61,7 +61,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;
} }
@ -76,22 +76,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}`,
})); }));
@ -108,11 +108,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]);
@ -133,16 +133,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 */}
@ -156,25 +156,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>
@ -183,24 +183,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>
@ -208,7 +208,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,6 +1,6 @@
"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.

View File

@ -1,4 +1,4 @@
"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.
@ -17,7 +17,9 @@ 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,18 +1,28 @@
"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.
@ -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,7 +138,9 @@ 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();
@ -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,
@ -169,31 +191,25 @@ export function WalletTab() {
*/ */
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 =
typeof window !== 'undefined' &&
(window.location.href.includes('warpcast.com') || (window.location.href.includes('warpcast.com') ||
window.location.href.includes('farcaster') || window.location.href.includes('farcaster') ||
window.ethereum?.isFarcaster || window.ethereum?.isFarcaster ||
context?.client); context?.client);
if (context?.user?.fid && !isConnected && connectors.length > 0 && isInFarcasterClient) {
console.log("Attempting auto-connection with Farcaster context...");
console.log("- User FID:", context.user.fid);
console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
console.log("- Using connector:", connectors[0].name);
console.log("- In Farcaster client:", isInFarcasterClient);
if (
context?.user?.fid &&
!isConnected &&
connectors.length > 0 &&
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]);
@ -235,14 +251,14 @@ 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]);
@ -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>
)} )}

View File

@ -1,11 +1,15 @@
"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.
@ -36,10 +40,12 @@ 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 ---
/** /**
@ -54,8 +60,8 @@ export function SendEth() {
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 ---
@ -88,12 +94,12 @@ 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,11 +1,14 @@
"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.
@ -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>

View File

@ -1,12 +1,12 @@
"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.

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,7 +52,7 @@ 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();
@ -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>;
@ -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);

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,18 +19,19 @@ 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]);

View File

@ -63,7 +63,8 @@ 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');
@ -73,7 +74,9 @@ export function useQuickAuth(): UseQuickAuthReturn {
* @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',

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,5 +1,5 @@
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.
@ -31,7 +31,7 @@ export function renderError(error: unknown): ReactElement | 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) {

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,4 +1,8 @@
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;
@ -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,
@ -60,17 +64,17 @@ export async function sendNeynarMiniAppNotification({
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;