Compare commits

...

20 Commits

Author SHA1 Message Date
Shreyaschorge
5fbd9c5c09 format 2025-07-15 01:00:59 +05:30
Shreyaschorge
3815f45b44 Merge branch 'main' into shreyas-formatting 2025-07-15 00:00:11 +05:30
Shreyaschorge
c713d53054 Merge pull request #17 from neynarxyz/shreyas/show-modal-on-top
Show Dialog on top to show I am using my phone button
2025-07-14 23:51:52 +05:30
Shreyaschorge
d10bee4d4c Merge branch 'main' into shreyas/show-modal-on-top 2025-07-14 23:51:24 +05:30
veganbeef
44d0c6f905 fix: regex error 2025-07-14 10:03:01 -07:00
Lucas Myers
4f308d3f07 Merge pull request #16 from neynarxyz/veganbeef/deploy-script-update
feat: update deploy script to and manifest generation for simplicity [NEYN-5843]
2025-07-14 09:45:27 -07:00
veganbeef
4b370746cb Merge branch 'main' into veganbeef/deploy-script-update 2025-07-14 09:44:58 -07:00
Shreyaschorge
00fdd21044 remove unnecessary rules 2025-07-14 21:52:14 +05:30
Shreyaschorge
8475b28107 remove console logs 2025-07-14 20:51:03 +05:30
Shreyaschorge
e74b2581df Format after fixing conflicts 2025-07-14 20:04:44 +05:30
Shreyaschorge
505aa54b16 Merge branch 'main' into shreyas-formatting 2025-07-14 18:55:06 +05:30
Shreyaschorge
8de3f598b7 Show modal on top 2025-07-14 16:35:22 +05:30
veganbeef
fb4f8b8b53 feat: set up CI auto publish 2025-07-12 15:21:50 -07:00
veganbeef
5fa624a063 fix: auth kit url update and seed phrase input 2025-07-11 22:28:05 -07:00
Manan
36d2b5d0f7 Merge pull request #13 from neynarxyz/shreyas/neyn-5735-sign-in-connect-farcaster-using-developer-branded-signer
Shreyas/neyn 5735 sign in connect farcaster using developer branded signer
2025-07-11 16:26:45 -07:00
veganbeef
d487a3e8e5 feat: update deploy script to and manifest generation for simplicity 2025-07-11 09:51:42 -07:00
Shreyaschorge
65419a76ee Remove unnecessary file 2025-07-08 00:48:13 +05:30
Shreyaschorge
6b0350d477 Remove AI generated docs 2025-07-07 16:12:17 +05:30
Shreyaschorge
2a1a3d7c40 Correct formating imports 2025-07-07 16:07:33 +05:30
Shreyaschorge
193dffe03a formatting 2025-07-07 14:10:47 +05:30
75 changed files with 6538 additions and 1618 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"
}
}
]
} }

30
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Publish to npm 🚀
on:
push:
branches:
- main
paths:
- package.json
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Publish to npm
run: npm publish --access public

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

@@ -6,6 +6,7 @@ import { init } from './init.js';
const args = process.argv.slice(2); const args = process.argv.slice(2);
let projectName = null; let projectName = null;
let autoAcceptDefaults = false; let autoAcceptDefaults = false;
let apiKey = null;
// Check for -y flag // Check for -y flag
const yIndex = args.indexOf('-y'); const yIndex = args.indexOf('-y');
@@ -14,18 +15,48 @@ if (yIndex !== -1) {
args.splice(yIndex, 1); // Remove -y from args args.splice(yIndex, 1); // Remove -y from args
} }
// If there's a remaining argument, it's the project name // Parse other arguments
if (args.length > 0) { for (let i = 0; i < args.length; i++) {
projectName = args[0]; const arg = args[i];
if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) {
projectName = args[i + 1];
if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -p/--project requires a project name');
process.exit(1);
}
} else if (arg === '-k' || arg === '--api-key') {
if (i + 1 < args.length) {
apiKey = args[i + 1];
if (apiKey.startsWith('-')) {
console.error('Error: API key cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -k/--api-key requires an API key');
process.exit(1);
}
}
} }
// If -y is used without project name, we still need to ask for project name // Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) { if (autoAcceptDefaults && !projectName) {
// We'll handle this case in the init function by asking only for project name console.error(
autoAcceptDefaults = false; 'Error: -y flag requires a project name. Use -p/--project to specify the project name.',
);
process.exit(1);
} }
init(projectName, autoAcceptDefaults).catch((err) => { init(projectName, autoAcceptDefaults, apiKey).catch(err => {
console.error('Error:', err); 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) { export async function init(
projectName = null,
autoAcceptDefaults = false,
apiKey = null,
) {
printWelcomeMessage(); printWelcomeMessage();
// Ask about Neynar usage // Ask about Neynar usage
@@ -101,9 +105,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break; break;
} }
// Use provided API key if available, otherwise prompt for it
if (apiKey) {
neynarApiKey = apiKey;
} else {
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',
); );
}
let neynarKeyAnswer; let neynarKeyAnswer;
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
@@ -138,17 +148,18 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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';
} }
} }
}
if (!neynarApiKey) { if (!neynarApiKey) {
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
@@ -156,7 +167,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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([
{ {
@@ -232,7 +243,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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';
} }
@@ -279,13 +290,13 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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);
}, },
}, },
{ {
@@ -293,7 +304,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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';
} }
@@ -338,6 +349,44 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
]); ]);
answers.useTunnel = hostingAnswer.useTunnel; answers.useTunnel = hostingAnswer.useTunnel;
// Ask about Neynar Sponsored Signers / SIWN
const sponsoredSignerAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useSponsoredSigner',
message:
'Would you like to use Neynar Sponsored Signers and/or Sign In With Neynar (SIWN)?\n' +
'This enables the simplest, most secure, and most user-friendly Farcaster authentication for your app.\n\n' +
'Benefits of using Neynar Sponsored Signers/SIWN:\n' +
'- No auth buildout or signer management required for developers\n' +
'- Cost-effective for users (no gas for signers)\n' +
'- Users can revoke signers at any time\n' +
'- Plug-and-play for web and React Native\n' +
'- Recommended for most developers\n' +
'\n⚠ A seed phrase is required for this option.\n',
default: false,
},
]);
answers.useSponsoredSigner = sponsoredSignerAnswer.useSponsoredSigner;
if (answers.useSponsoredSigner) {
const { seedPhrase } = await inquirer.prompt([
{
type: 'password',
name: 'seedPhrase',
message:
'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):',
validate: input => {
if (!input || input.trim().split(' ').length < 12) {
return 'Seed phrase must be at least 12 words';
}
return true;
},
},
]);
answers.seedPhrase = seedPhrase;
}
// Ask about analytics opt-out // Ask about analytics opt-out
const analyticsAnswer = await inquirer.prompt([ const analyticsAnswer = await inquirer.prompt([
{ {
@@ -395,7 +444,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// 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';
@@ -451,6 +500,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
postcss: '^8', postcss: '^8',
tailwindcss: '^3.4.1', tailwindcss: '^3.4.1',
typescript: '^5', typescript: '^5',
'ts-node': '^10.9.2',
}; };
// Add Neynar SDK if selected // Add Neynar SDK if selected
@@ -478,21 +528,21 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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);
@@ -501,19 +551,21 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
return content; return content;
}; };
// Regex patterns that match whole lines with export const // Regex patterns that match whole lines with export const (with TypeScript types)
const patterns = { const patterns = {
APP_NAME: /^export const APP_NAME\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*['"`][^'"`]*['"`];$/m, /^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY: APP_PRIMARY_CATEGORY:
/^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_PRIMARY_CATEGORY\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_TAGS: /^export const APP_TAGS\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*['"`][^'"`]*['"`];$/m, /^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
USE_WALLET: /^export const USE_WALLET\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*(true|false);$/m, /^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
}; };
// Update APP_NAME // Update APP_NAME
@@ -521,7 +573,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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
@@ -529,9 +581,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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)
@@ -539,21 +591,21 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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)
@@ -561,9 +613,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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
@@ -571,7 +623,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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
@@ -579,7 +631,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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);
@@ -589,26 +641,25 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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) {
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="${answers.sponsorSigner}"`);
} }
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
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',
); );
} }
@@ -653,7 +704,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
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

@@ -1,6 +1,6 @@
{ {
"name": "@neynar/create-farcaster-mini-app", "name": "@neynar/create-farcaster-mini-app",
"version": "1.5.7", "version": "1.6.3",
"type": "module", "type": "module",
"private": false, "private": false,
"access": "public", "access": "public",
@@ -31,11 +31,14 @@
], ],
"scripts": { "scripts": {
"dev": "node scripts/dev.js", "dev": "node scripts/dev.js",
"build": "node scripts/build.js", "build": "next build",
"build:raw": "next build", "build:raw": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"deploy:vercel": "node scripts/deploy.js", "lint:fix": "next lint --fix",
"format:check": "prettier --check .",
"format": "prettier --write . && eslint --fix . --max-warnings 50",
"deploy:vercel": "ts-node scripts/deploy.ts",
"deploy:raw": "vercel --prod", "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

@@ -1,359 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import inquirer from "inquirer";
import dotenv from "dotenv";
import crypto from "crypto";
// Load environment variables in specific order
// First load .env for main config
dotenv.config({ path: ".env" });
async function loadEnvLocal() {
try {
if (fs.existsSync(".env.local")) {
const { loadLocal } = await inquirer.prompt([
{
type: "confirm",
name: "loadLocal",
message:
"Found .env.local, likely created by the install script - would you like to load its values?",
default: false,
},
]);
if (loadLocal) {
console.log("Loading values from .env.local...");
const localEnv = dotenv.parse(fs.readFileSync(".env.local"));
// Copy all values to .env
const envContent = fs.existsSync(".env")
? fs.readFileSync(".env", "utf8") + "\n"
: "";
let newEnvContent = envContent;
for (const [key, value] of Object.entries(localEnv)) {
// Update process.env
process.env[key] = value;
// Add to .env content if not already there
if (!envContent.includes(`${key}=`)) {
newEnvContent += `${key}="${value}"\n`;
}
}
// Write updated content to .env
fs.writeFileSync(".env", newEnvContent);
console.log("✅ Values from .env.local have been written to .env");
}
if (localEnv.SPONSOR_SIGNER) {
process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER;
}
}
} catch (error) {
// Error reading .env.local, which is fine
console.log("Note: No .env.local file found");
}
}
// TODO: make sure rebuilding is supported
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, "..");
async function validateDomain(domain) {
// Remove http:// or https:// if present
const cleanDomain = domain.replace(/^https?:\/\//, "");
// Basic domain validation
if (
!cleanDomain.match(
/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/
)
) {
throw new Error("Invalid domain format");
}
return cleanDomain;
}
async function queryNeynarApp(apiKey) {
if (!apiKey) {
return null;
}
try {
const response = await fetch(
`https://api.neynar.com/portal/app_by_api_key`,
{
headers: {
"x-api-key": apiKey,
},
}
);
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying Neynar app data:", error);
return null;
}
}
async function generateFarcasterMetadata(domain, webhookUrl) {
const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(",");
return {
accountAssociation: {
header: "",
payload: "",
signature: "",
},
frame: {
version: "1",
name: process.env.NEXT_PUBLIC_MINI_APP_NAME,
iconUrl: `https://${domain}/icon.png`,
homeUrl: `https://${domain}`,
imageUrl: `https://${domain}/api/opengraph-image`,
buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT,
splashImageUrl: `https://${domain}/splash.png`,
splashBackgroundColor: "#f7f7f7",
webhookUrl,
description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION,
primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY,
tags,
},
};
}
async function main() {
try {
console.log("\n📝 Checking environment variables...");
console.log("Loading values from .env...");
// Load .env.local if user wants to
await loadEnvLocal();
// Get domain from user
const { domain } = await inquirer.prompt([
{
type: "input",
name: "domain",
message:
"Enter the domain where your mini app will be deployed (e.g., example.com):",
validate: async (input) => {
try {
await validateDomain(input);
return true;
} catch (error) {
return error.message;
}
},
},
]);
// Get frame name from user
const { frameName } = await inquirer.prompt([
{
type: "input",
name: "frameName",
message: "Enter the name for your mini app (e.g., My Cool Mini App):",
default: process.env.NEXT_PUBLIC_MINI_APP_NAME,
validate: (input) => {
if (input.trim() === "") {
return "Mini app name cannot be empty";
}
return true;
},
},
]);
// Get button text from user
const { buttonText } = await inquirer.prompt([
{
type: "input",
name: "buttonText",
message: "Enter the text for your mini app button:",
default:
process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT || "Launch Mini App",
validate: (input) => {
if (input.trim() === "") {
return "Button text cannot be empty";
}
return true;
},
},
]);
// Get Neynar configuration
let neynarApiKey = process.env.NEYNAR_API_KEY;
let neynarClientId = process.env.NEYNAR_CLIENT_ID;
let useNeynar = true;
while (useNeynar) {
if (!neynarApiKey) {
const { neynarApiKey: inputNeynarApiKey } = await inquirer.prompt([
{
type: "password",
name: "neynarApiKey",
message:
"Enter your Neynar API key (optional - leave blank to skip):",
default: null,
},
]);
neynarApiKey = inputNeynarApiKey;
} else {
console.log("Using existing Neynar API key from .env");
}
if (!neynarApiKey) {
useNeynar = false;
break;
}
// Try to get client ID from API
if (!neynarClientId) {
const appInfo = await queryNeynarApp(neynarApiKey);
if (appInfo) {
neynarClientId = appInfo.app_uuid;
console.log("✅ Fetched Neynar app client ID");
break;
}
}
// We have a client ID (either from .env or fetched from API), so we can break out of the loop
if (neynarClientId) {
break;
}
// If we get here, the API key was invalid
console.log(
"\n⚠ Could not find Neynar app information. The API key may be incorrect."
);
const { retry } = await inquirer.prompt([
{
type: "confirm",
name: "retry",
message: "Would you like to try a different API key?",
default: true,
},
]);
// Reset for retry
neynarApiKey = null;
neynarClientId = null;
if (!retry) {
useNeynar = false;
break;
}
}
// Generate manifest
console.log("\n🔨 Generating mini app manifest...");
// Determine webhook URL based on environment variables
const webhookUrl =
neynarApiKey && neynarClientId
? `https://api.neynar.com/f/app/${neynarClientId}/event`
: `https://${domain}/api/webhook`;
const metadata = await generateFarcasterMetadata(domain, webhookUrl);
console.log("\n✅ Mini app manifest generated");
// Read existing .env file or create new one
const envPath = path.join(projectRoot, ".env");
let envContent = fs.existsSync(envPath)
? fs.readFileSync(envPath, "utf8")
: "";
// Add or update environment variables
const newEnvVars = [
// Base URL
`NEXT_PUBLIC_URL=https://${domain}`,
// Mini app metadata
`NEXT_PUBLIC_MINI_APP_NAME="${frameName}"`,
`NEXT_PUBLIC_MINI_APP_DESCRIPTION="${
process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION || ""
}"`,
`NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY="${
process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY || ""
}"`,
`NEXT_PUBLIC_MINI_APP_TAGS="${
process.env.NEXT_PUBLIC_MINI_APP_TAGS || ""
}"`,
`NEXT_PUBLIC_MINI_APP_BUTTON_TEXT="${buttonText}"`,
// Analytics
`NEXT_PUBLIC_ANALYTICS_ENABLED="${
process.env.NEXT_PUBLIC_ANALYTICS_ENABLED || "false"
}"`,
// Neynar configuration (if it exists in current env)
...(process.env.NEYNAR_API_KEY
? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`]
: []),
...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []),
...(process.env.SPONSOR_SIGNER ?
[`SPONSOR_SIGNER="${process.env.SPONSOR_SIGNER}"`] : []),
// FID (if it exists in current env)
...(process.env.FID ? [`FID="${process.env.FID}"`] : []),
`NEXT_PUBLIC_USE_WALLET="${
process.env.NEXT_PUBLIC_USE_WALLET || "false"
}"`,
// NextAuth configuration
`NEXTAUTH_SECRET="${
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString("hex")
}"`,
`NEXTAUTH_URL="https://${domain}"`,
// Mini app manifest with signature
`MINI_APP_METADATA=${JSON.stringify(metadata)}`,
];
// Filter out empty values and join with newlines
const validEnvVars = newEnvVars.filter((line) => {
const [, value] = line.split("=");
return value && value !== '""';
});
// Update or append each environment variable
validEnvVars.forEach((varLine) => {
const [key] = varLine.split("=");
if (envContent.includes(`${key}=`)) {
envContent = envContent.replace(new RegExp(`${key}=.*`), varLine);
} else {
envContent += `\n${varLine}`;
}
});
// Write updated .env file
fs.writeFileSync(envPath, envContent);
console.log("\n✅ Environment variables updated");
// Run next build
console.log("\nBuilding Next.js application...");
const nextBin = path.normalize(
path.join(projectRoot, "node_modules", ".bin", "next")
);
execSync(`"${nextBin}" build`, {
cwd: projectRoot,
stdio: "inherit",
shell: process.platform === "win32",
});
console.log(
"\n✨ Build complete! Your mini app is ready for deployment. 🪐"
);
console.log(
"📝 Make sure to configure the environment variables from .env in your hosting provider"
);
} catch (error) {
console.error("\n❌ Error:", error.message);
process.exit(1);
}
}
main();

View File

@@ -7,6 +7,7 @@ import inquirer from 'inquirer';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import crypto from 'crypto'; import crypto from 'crypto';
import { Vercel } from '@vercel/sdk'; import { Vercel } from '@vercel/sdk';
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..'); const projectRoot = path.join(__dirname, '..');
@@ -14,32 +15,10 @@ const projectRoot = path.join(__dirname, '..');
// Load environment variables in specific order // Load environment variables in specific order
dotenv.config({ path: '.env' }); dotenv.config({ path: '.env' });
async function generateFarcasterMetadata(domain, webhookUrl) { async function loadEnvLocal(): Promise<void> {
const trimmedDomain = domain.trim();
const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(',');
return {
frame: {
version: '1',
name: process.env.NEXT_PUBLIC_MINI_APP_NAME,
iconUrl: `https://${trimmedDomain}/icon.png`,
homeUrl: `https://${trimmedDomain}`,
imageUrl: `https://${trimmedDomain}/api/opengraph-image`,
buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT,
splashImageUrl: `https://${trimmedDomain}/splash.png`,
splashBackgroundColor: '#f7f7f7',
webhookUrl: webhookUrl?.trim(),
description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION,
primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY,
tags,
},
};
}
async function loadEnvLocal() {
try { try {
if (fs.existsSync('.env.local')) { if (fs.existsSync('.env.local')) {
const { loadLocal } = await inquirer.prompt([ const { loadLocal }: { loadLocal: boolean } = await inquirer.prompt([
{ {
type: 'confirm', type: 'confirm',
name: 'loadLocal', name: 'loadLocal',
@@ -54,12 +33,7 @@ async function loadEnvLocal() {
const localEnv = dotenv.parse(fs.readFileSync('.env.local')); const localEnv = dotenv.parse(fs.readFileSync('.env.local'));
const allowedVars = [ const allowedVars = [
'NEXT_PUBLIC_MINI_APP_NAME', 'SEED_PHRASE',
'NEXT_PUBLIC_MINI_APP_DESCRIPTION',
'NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY',
'NEXT_PUBLIC_MINI_APP_TAGS',
'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
'NEXT_PUBLIC_ANALYTICS_ENABLED',
'NEYNAR_API_KEY', 'NEYNAR_API_KEY',
'NEYNAR_CLIENT_ID', 'NEYNAR_CLIENT_ID',
'SPONSOR_SIGNER', 'SPONSOR_SIGNER',
@@ -83,12 +57,12 @@ async function loadEnvLocal() {
console.log('✅ Values from .env.local have been written to .env'); console.log('✅ Values from .env.local have been written to .env');
} }
} }
} catch (error) { } catch (error: unknown) {
console.log('Note: No .env.local file found'); console.log('Note: No .env.local file found');
} }
} }
async function checkRequiredEnvVars() { async function checkRequiredEnvVars(): Promise<void> {
console.log('\n📝 Checking environment variables...'); console.log('\n📝 Checking environment variables...');
console.log('Loading values from .env...'); console.log('Loading values from .env...');
@@ -98,21 +72,21 @@ async function checkRequiredEnvVars() {
{ {
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: process.env.NEXT_PUBLIC_MINI_APP_NAME, default: APP_NAME,
validate: (input) => validate: (input: string) =>
input.trim() !== '' || 'Mini app name cannot be empty', 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: default: APP_BUTTON_TEXT ?? 'Launch Mini App',
process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? 'Launch Mini App', validate: (input: string) =>
validate: (input) => input.trim() !== '' || 'Button text cannot be empty', 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) {
@@ -138,7 +112,7 @@ async function checkRequiredEnvVars() {
const newLine = envContent ? '\n' : ''; const newLine = envContent ? '\n' : '';
fs.appendFileSync( fs.appendFileSync(
'.env', '.env',
`${newLine}${varConfig.name}="${value.trim()}"` `${newLine}${varConfig.name}="${value.trim()}"`,
); );
} }
@@ -161,7 +135,7 @@ async function checkRequiredEnvVars() {
if (storeSeedPhrase) { if (storeSeedPhrase) {
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');
} }
@@ -182,39 +156,43 @@ async function checkRequiredEnvVars() {
} }
} }
async function getGitRemote() { async function getGitRemote(): Promise<string | null> {
try { try {
const remoteUrl = execSync('git remote get-url origin', { const remoteUrl = execSync('git remote get-url origin', {
cwd: projectRoot, cwd: projectRoot,
encoding: 'utf8', encoding: 'utf8',
}).trim(); }).trim();
return remoteUrl; return remoteUrl;
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
return null; return null;
} }
throw error;
}
} }
async function checkVercelCLI() { async function checkVercelCLI(): Promise<boolean> {
try { try {
execSync('vercel --version', { execSync('vercel --version', {
stdio: 'ignore', stdio: 'ignore',
shell: process.platform === 'win32',
}); });
return true; return true;
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
return false; return false;
} }
throw error;
}
} }
async function installVercelCLI() { 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',
shell: process.platform === 'win32',
}); });
} }
async function getVercelToken() { async function getVercelToken(): Promise<string | null> {
try { try {
// Try to get token from Vercel CLI config // Try to get token from Vercel CLI config
const configPath = path.join(os.homedir(), '.vercel', 'auth.json'); const configPath = path.join(os.homedir(), '.vercel', 'auth.json');
@@ -222,9 +200,11 @@ async function getVercelToken() {
const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return authConfig.token; return authConfig.token;
} }
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn('Could not read Vercel token from config file'); console.warn('Could not read Vercel token from config file');
} }
}
// Try environment variable // Try environment variable
if (process.env.VERCEL_TOKEN) { if (process.env.VERCEL_TOKEN) {
@@ -242,14 +222,17 @@ async function getVercelToken() {
// The token isn't directly exposed, so we'll need to use CLI for some operations // The token isn't directly exposed, so we'll need to use CLI for some operations
console.log('✅ Verified Vercel CLI authentication'); console.log('✅ Verified Vercel CLI authentication');
return null; // We'll fall back to CLI operations return null; // We'll fall back to CLI operations
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
throw new Error( throw new Error(
'Not logged in to Vercel CLI. Please run this script again to login.' 'Not logged in to Vercel CLI. Please run this script again to login.',
); );
} }
throw error;
}
} }
async function loginToVercel() { async function loginToVercel(): Promise<boolean> {
console.log('\n🔑 Vercel Login'); console.log('\n🔑 Vercel Login');
console.log('You can either:'); console.log('You can either:');
console.log('1. Log in to an existing Vercel account'); console.log('1. Log in to an existing Vercel account');
@@ -260,22 +243,22 @@ async function loginToVercel() {
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'], {
stdio: 'inherit', stdio: 'inherit',
}); });
await new Promise((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++) {
@@ -283,11 +266,14 @@ async function loginToVercel() {
execSync('vercel whoami', { stdio: 'ignore' }); execSync('vercel whoami', { stdio: 'ignore' });
console.log('✅ Successfully logged in to Vercel!'); console.log('✅ Successfully logged in to Vercel!');
return true; return true;
} catch (error) { } catch (error: unknown) {
if (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));
} }
} }
@@ -298,9 +284,14 @@ async function loginToVercel() {
return false; return false;
} }
async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { async function setVercelEnvVarSDK(
vercelClient: Vercel,
projectId: string,
key: string,
value: string | object,
): Promise<boolean> {
try { try {
let processedValue; let processedValue: string;
if (typeof value === 'object') { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
@@ -313,7 +304,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
}); });
const existingVar = existingVars.envs?.find( const existingVar = existingVars.envs?.find(
(env) => env.key === key && env.target?.includes('production') (env: any) => env.key === key && env.target?.includes('production'),
); );
if (existingVar) { if (existingVar) {
@@ -342,16 +333,23 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
} }
return true; return true;
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn( console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`, `⚠️ Warning: Failed to set environment variable ${key}:`,
error.message error.message,
); );
return false; return false;
} }
throw error;
}
} }
async function setVercelEnvVarCLI(key, value, projectRoot) { async function setVercelEnvVarCLI(
key: string,
value: string | object,
projectRoot: string,
): Promise<boolean> {
try { try {
// Remove existing env var // Remove existing env var
try { try {
@@ -360,11 +358,11 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
stdio: 'ignore', stdio: 'ignore',
env: process.env, env: process.env,
}); });
} catch (error) { } catch (error: unknown) {
// Ignore errors from removal // Ignore errors from removal
} }
let processedValue; let processedValue: string;
if (typeof value === 'object') { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
@@ -376,7 +374,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
fs.writeFileSync(tempFilePath, processedValue, 'utf8'); fs.writeFileSync(tempFilePath, processedValue, 'utf8');
// Use appropriate command based on platform // Use appropriate command based on platform
let command; let command: string;
if (process.platform === 'win32') { if (process.platform === 'win32') {
command = `type "${tempFilePath}" | vercel env add ${key} production`; command = `type "${tempFilePath}" | vercel env add ${key} production`;
} else { } else {
@@ -386,35 +384,37 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
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
shell: true,
env: process.env, env: process.env,
}); });
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
console.log(`✅ Set environment variable: ${key}`); console.log(`✅ Set environment variable: ${key}`);
return true; return true;
} catch (error) { } catch (error: unknown) {
const tempFilePath = path.join(projectRoot, `${key}_temp.txt`); const tempFilePath = path.join(projectRoot, `${key}_temp.txt`);
if (fs.existsSync(tempFilePath)) { if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
} }
if (error instanceof Error) {
console.warn( console.warn(
`⚠️ Warning: Failed to set environment variable ${key}:`, `⚠️ Warning: Failed to set environment variable ${key}:`,
error.message error.message,
); );
return false; return false;
} }
throw error;
}
} }
async function setEnvironmentVariables( async function setEnvironmentVariables(
vercelClient, vercelClient: Vercel | null,
projectId, projectId: string | null,
envVars, envVars: Record<string, string | object>,
projectRoot projectRoot: string,
) { ): Promise<Array<{ key: string; success: boolean }>> {
console.log('\n📝 Setting up environment variables...'); console.log('\n📝 Setting up environment variables...');
const results = []; const results: Array<{ key: string; success: boolean }> = [];
for (const [key, value] of Object.entries(envVars)) { for (const [key, value] of Object.entries(envVars)) {
if (!value) continue; if (!value) continue;
@@ -435,12 +435,12 @@ async function setEnvironmentVariables(
} }
// 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.',
); );
} }
@@ -448,22 +448,22 @@ async function setEnvironmentVariables(
} }
async function waitForDeployment( async function waitForDeployment(
vercelClient, vercelClient: Vercel | null,
projectId, projectId: string,
maxWaitTime = 300000 maxWaitTime = 300000,
) { ): Promise<any> {
// 5 minutes // 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();
while (Date.now() - startTime < maxWaitTime) { while (Date.now() - startTime < maxWaitTime) {
try { try {
const deployments = await vercelClient.deployments.list({ const deployments = await vercelClient?.deployments.list({
projectId: projectId, projectId: projectId,
limit: 1, limit: 1,
}); });
if (deployments.deployments?.[0]) { if (deployments?.deployments?.[0]) {
const deployment = deployments.deployments[0]; const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`); console.log(`📊 Deployment status: ${deployment.state}`);
@@ -477,21 +477,24 @@ async function waitForDeployment(
} }
// 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) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not check deployment status:', error.message); console.warn('⚠️ Could not check deployment status:', error.message);
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
}
throw error;
} }
} }
throw new Error('Deployment timed out after 5 minutes'); throw new Error('Deployment timed out after 5 minutes');
} }
async function deployToVercel(useGitHub = false) { async function deployToVercel(useGitHub = false): Promise<void> {
try { try {
console.log('\n🚀 Deploying to Vercel...'); console.log('\n🚀 Deploying to Vercel...');
@@ -507,18 +510,18 @@ async function deployToVercel(useGitHub = false) {
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
@@ -526,11 +529,11 @@ async function deployToVercel(useGitHub = false) {
const vercelSetup = spawn('vercel', [], { const vercelSetup = spawn('vercel', [], {
cwd: projectRoot, cwd: projectRoot,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32' ? true : undefined,
}); });
await new Promise((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();
@@ -540,30 +543,33 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
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; 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) { } catch (error: unknown) {
if (error instanceof Error) {
throw new Error( throw new Error(
'Failed to load project info. Please ensure the Vercel project was created successfully.' 'Failed to load project info. Please ensure the Vercel project was created successfully.',
); );
} }
throw error;
}
// Get Vercel token and initialize SDK client // Get Vercel token and initialize SDK client
let vercelClient = null; let vercelClient: Vercel | null = null;
try { try {
const token = await getVercelToken(); const token = await getVercelToken();
if (token) { if (token) {
@@ -572,16 +578,19 @@ async function deployToVercel(useGitHub = false) {
}); });
console.log('✅ Initialized Vercel SDK client'); console.log('✅ Initialized Vercel SDK client');
} }
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn( console.warn(
'⚠️ Could not initialize Vercel SDK, falling back to CLI operations' '⚠️ Could not initialize Vercel SDK, falling back to CLI operations',
); );
} }
throw error;
}
// Get project details // Get project details
console.log('\n🔍 Getting project details...'); console.log('\n🔍 Getting project details...');
let domain; let domain: string | undefined;
let projectName; let projectName: string | undefined;
if (vercelClient) { if (vercelClient) {
try { try {
@@ -591,11 +600,14 @@ async function deployToVercel(useGitHub = false) {
projectName = project.name; projectName = project.name;
domain = `${projectName}.vercel.app`; domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain); console.log('🌐 Using project name for domain:', domain);
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn( console.warn(
'⚠️ Could not get project details via SDK, using CLI fallback' '⚠️ Could not get project details via SDK, using CLI fallback',
); );
} }
throw error;
}
} }
// Fallback to CLI method if SDK failed // Fallback to CLI method if SDK failed
@@ -606,7 +618,7 @@ async function deployToVercel(useGitHub = false) {
{ {
cwd: projectRoot, cwd: projectRoot,
encoding: 'utf8', encoding: 'utf8',
} },
); );
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@@ -622,31 +634,23 @@ async function deployToVercel(useGitHub = false) {
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`;
console.log('🌐 Using fallback domain:', domain); console.log('🌐 Using fallback domain:', domain);
} }
} }
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn('⚠️ Could not inspect project, using fallback domain'); console.warn('⚠️ Could not inspect project, using fallback domain');
// 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`;
console.log('🌐 Using fallback domain:', domain); console.log('🌐 Using fallback domain:', domain);
} }
throw error;
}
} }
// Generate mini app metadata
console.log('\n🔨 Generating mini app metadata...');
const webhookUrl =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `https://${domain}/api/webhook`;
const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl);
console.log('✅ Mini app metadata generated');
// Prepare environment variables // Prepare environment variables
const nextAuthSecret = const nextAuthSecret =
@@ -666,12 +670,11 @@ async function deployToVercel(useGitHub = false) {
...(process.env.SPONSOR_SIGNER && { ...(process.env.SPONSOR_SIGNER && {
SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, SPONSOR_SIGNER: process.env.SPONSOR_SIGNER,
}), }),
...(miniAppMetadata && { MINI_APP_METADATA: miniAppMetadata }),
...Object.fromEntries( ...Object.fromEntries(
Object.entries(process.env).filter(([key]) => Object.entries(process.env).filter(([key]) =>
key.startsWith('NEXT_PUBLIC_') key.startsWith('NEXT_PUBLIC_'),
) ),
), ),
}; };
@@ -680,7 +683,7 @@ async function deployToVercel(useGitHub = false) {
vercelClient, vercelClient,
projectId, projectId,
vercelEnv, vercelEnv,
projectRoot projectRoot,
); );
// Deploy the project // Deploy the project
@@ -703,8 +706,8 @@ async function deployToVercel(useGitHub = false) {
env: process.env, env: process.env,
}); });
await new Promise((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();
@@ -714,24 +717,27 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
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);
}); });
}); });
// Wait for deployment to actually complete // Wait for deployment to actually complete
let deployment; let deployment: any;
if (vercelClient) { if (vercelClient) {
try { try {
deployment = await waitForDeployment(vercelClient, projectId); deployment = await waitForDeployment(vercelClient, projectId);
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn( console.warn(
'⚠️ Could not verify deployment completion:', '⚠️ Could not verify deployment completion:',
error.message error.message,
); );
console.log(' Proceeding with domain verification...'); console.log(' Proceeding with domain verification...');
} }
throw error;
}
} }
// Verify actual domain after deployment // Verify actual domain after deployment
@@ -742,43 +748,30 @@ async function deployToVercel(useGitHub = false) {
try { try {
actualDomain = deployment.url || domain; actualDomain = deployment.url || domain;
console.log('🌐 Verified actual domain:', actualDomain); console.log('🌐 Verified actual domain:', actualDomain);
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.warn( console.warn(
'⚠️ Could not verify domain via SDK, using assumed domain' '⚠️ Could not verify domain via SDK, using assumed domain',
); );
} }
throw error;
}
} }
// Update environment variables if domain changed // Update environment variables if domain changed
if (actualDomain !== domain) { if (actualDomain !== domain) {
console.log('🔄 Updating environment variables with correct domain...'); console.log('🔄 Updating environment variables with correct domain...');
const webhookUrl = const updatedEnv: Record<string, string | object> = {
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `https://${actualDomain}/api/webhook`;
const updatedEnv = {
NEXTAUTH_URL: `https://${actualDomain}`, NEXTAUTH_URL: `https://${actualDomain}`,
NEXT_PUBLIC_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`,
}; };
if (miniAppMetadata) {
const updatedMetadata = await generateFarcasterMetadata(
actualDomain,
fid,
await validateSeedPhrase(process.env.SEED_PHRASE),
process.env.SEED_PHRASE,
webhookUrl
);
updatedEnv.MINI_APP_METADATA = updatedMetadata;
}
await setEnvironmentVariables( await setEnvironmentVariables(
vercelClient, vercelClient,
projectId, projectId,
updatedEnv, updatedEnv,
projectRoot projectRoot,
); );
console.log('\n📦 Redeploying with correct domain...'); console.log('\n📦 Redeploying with correct domain...');
@@ -788,8 +781,8 @@ async function deployToVercel(useGitHub = false) {
env: process.env, env: process.env,
}); });
await new Promise((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();
@@ -799,7 +792,7 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
vercelRedeploy.on('error', (error) => { vercelRedeploy.on('error', error => {
console.error('❌ Redeployment error:', error.message); console.error('❌ Redeployment error:', error.message);
reject(error); reject(error);
}); });
@@ -811,19 +804,71 @@ async function deployToVercel(useGitHub = false) {
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( console.log(
'\n📝 You can manage your project at https://vercel.com/dashboard' '\n📝 You can manage your project at https://vercel.com/dashboard',
); );
} catch (error) {
// Prompt user to sign manifest in browser and paste accountAssociation
console.log(
`\n⚠ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`,
);
console.log(
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' +
domain,
);
console.log(
'2. Click "Transfer Ownership" and follow the instructions to sign the manifest.',
);
console.log(
'3. Copy the resulting accountAssociation JSON from the browser.',
);
console.log('4. Paste it below when prompted.');
const { userAccountAssociation } = await inquirer.prompt([
{
type: 'editor',
name: 'userAccountAssociation',
message: 'Paste the accountAssociation JSON here:',
validate: (input: string) => {
try {
const parsed = JSON.parse(input);
if (parsed.header && parsed.payload && parsed.signature) {
return true;
}
return 'Invalid accountAssociation: must have header, payload, and signature';
} catch (e) {
return 'Invalid JSON';
}
},
},
]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation);
// Write APP_ACCOUNT_ASSOCIATION to src/lib/constants.ts
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_ACCOUNT_ASSOCIATION line using a robust, anchored, multiline regex
const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
constantsContent = constantsContent.replace(
/^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
newAccountAssociation,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts');
} catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Deployment failed:', error.message); console.error('\n❌ Deployment failed:', error.message);
process.exit(1); process.exit(1);
} }
throw error;
}
} }
async function main() { 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');
@@ -834,7 +879,8 @@ async function main() {
// Check if @vercel/sdk is installed // Check if @vercel/sdk is installed
try { try {
await import('@vercel/sdk'); await import('@vercel/sdk');
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.log('📦 Installing @vercel/sdk...'); console.log('📦 Installing @vercel/sdk...');
execSync('npm install @vercel/sdk', { execSync('npm install @vercel/sdk', {
cwd: projectRoot, cwd: projectRoot,
@@ -842,6 +888,8 @@ async function main() {
}); });
console.log('✅ @vercel/sdk installed successfully'); console.log('✅ @vercel/sdk installed successfully');
} }
throw error;
}
await checkRequiredEnvVars(); await checkRequiredEnvVars();
@@ -896,10 +944,13 @@ async function main() {
} }
await deployToVercel(useGitHub); await deployToVercel(useGitHub);
} catch (error) { } catch (error: unknown) {
if (error instanceof Error) {
console.error('\n❌ Error:', error.message); console.error('\n❌ Error:', error.message);
process.exit(1); process.exit(1);
} }
throw error;
}
} }
main(); main();

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,19 +54,22 @@ 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
.toString()
.split('\n')
.forEach(pid => {
if (pid) { if (pid) {
try { try {
process.kill(parseInt(pid), 'SIGKILL'); process.kill(parseInt(pid), 'SIGKILL');
@@ -76,7 +79,7 @@ async function killProcessOnPort(port) {
} }
}); });
}); });
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(
`Port ${port} is already in use. To find and kill the process using this port:\n\n` +
(process.platform === 'win32' (process.platform === 'win32'
? `1. Run: netstat -ano | findstr :${port}\n` + ? `1. Run: netstat -ano | findstr :${port}\n` +
'2. Note the PID (Process ID) from the output\n' + '2. Note the PID (Process ID) from the output\n' +
'3. Run: taskkill /PID <PID> /F\n' '3. Run: taskkill /PID <PID> /F\n'
: `On macOS/Linux, run:\nnpm run cleanup\n`) + : 'On macOS/Linux, run:\nnpm run cleanup\n') +
'\nThen try running this command again.'); '\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,19 @@ 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, NEXTAUTH_URL: miniAppUrl }, env: {
...process.env,
NEXT_PUBLIC_URL: miniAppUrl,
NEXTAUTH_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

@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getFarcasterMetadata } from '../../../lib/utils'; import { getFarcasterDomainManifest } from '~/lib/utils';
export async function GET() { export async function GET() {
try { try {
const config = await getFarcasterMetadata(); const config = await getFarcasterDomainManifest();
return NextResponse.json(config); return NextResponse.json(config);
} catch (error) { } catch (error) {
console.error('Error generating metadata:', error); console.error('Error generating metadata:', error);

View File

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

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

@@ -9,7 +9,7 @@ export async function POST(request: Request) {
if (!session?.user?.fid) { if (!session?.user?.fid) {
return NextResponse.json( return NextResponse.json(
{ error: 'No authenticated session found' }, { error: 'No authenticated session found' },
{ status: 401 } { status: 401 },
); );
} }
@@ -19,7 +19,7 @@ export async function POST(request: Request) {
if (!signers || !user) { if (!signers || !user) {
return NextResponse.json( return NextResponse.json(
{ error: 'Signers and user are required' }, { error: 'Signers and user are required' },
{ status: 400 } { status: 400 },
); );
} }
@@ -40,7 +40,7 @@ export async function POST(request: Request) {
console.error('Error preparing session update:', error); console.error('Error preparing session update:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to prepare session update' }, { error: 'Failed to prepare session update' },
{ 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,9 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import '~/app/globals.css';
import { getSession } from "~/auth" import { Providers } from '~/app/providers';
import "~/app/globals.css"; import { getSession } from '~/auth';
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,
@@ -15,7 +14,7 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await getSession() const session = await getSession();
return ( return (
<html lang="en"> <html lang="en">

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

@@ -1,18 +1,18 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { AuthKitProvider } from '@farcaster/auth-kit';
import { MiniAppProvider } from '@neynar/react';
import type { Session } from 'next-auth'; import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import { MiniAppProvider } from '@neynar/react';
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
import { ANALYTICS_ENABLED } from '~/lib/constants'; import { ANALYTICS_ENABLED } from '~/lib/constants';
import { AuthKitProvider } from '@farcaster/auth-kit';
const WagmiProvider = dynamic( const WagmiProvider = dynamic(
() => import('~/components/providers/WagmiProvider'), () => import('~/components/providers/WagmiProvider'),
{ {
ssr: false, ssr: false,
} },
); );
export function Providers({ export function Providers({

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,6 +1,6 @@
import { createAppClient, viemConnector } from '@farcaster/auth-client';
import { AuthOptions, getServerSession } from 'next-auth'; import { AuthOptions, getServerSession } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials'; import CredentialsProvider from 'next-auth/providers/credentials';
import { createAppClient, viemConnector } from '@farcaster/auth-client';
declare module 'next-auth' { declare module 'next-auth' {
interface Session { interface Session {
@@ -401,7 +401,7 @@ export const authOptions: AuthOptions = {
}, },
cookies: { cookies: {
sessionToken: { sessionToken: {
name: `next-auth.session-token`, name: 'next-auth.session-token',
options: { options: {
httpOnly: true, httpOnly: true,
sameSite: 'none', sameSite: 'none',
@@ -410,7 +410,7 @@ export const authOptions: AuthOptions = {
}, },
}, },
callbackUrl: { callbackUrl: {
name: `next-auth.callback-url`, name: 'next-auth.callback-url',
options: { options: {
sameSite: 'none', sameSite: 'none',
path: '/', path: '/',
@@ -418,7 +418,7 @@ export const authOptions: AuthOptions = {
}, },
}, },
csrfToken: { csrfToken: {
name: `next-auth.csrf-token`, name: 'next-auth.csrf-token',
options: { options: {
httpOnly: true, httpOnly: true,
sameSite: 'none', sameSite: 'none',

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

@@ -118,7 +118,7 @@ export function AuthDialog({
const content = getStepContent(); const content = getStepContent();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-md shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] sm:max-h-[90vh] flex flex-col"> <div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-md shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] sm:max-h-[90vh] flex flex-col">
<div className="flex justify-between items-center p-4 sm:p-6 pb-3 sm:pb-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0"> <div className="flex justify-between items-center p-4 sm:p-6 pb-3 sm:pb-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -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"
@@ -191,20 +191,22 @@ export function AuthDialog({
{content.showOpenButton && content.qrUrl && ( {content.showOpenButton && content.qrUrl && (
<button <button
onClick={() => onClick={() => {
if (content.qrUrl) {
window.open( window.open(
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/', 'https://client.farcaster.xyz/deeplinks/signed-key-request',
'farcaster://' 'https://farcaster.xyz/~/connect',
), ),
'_blank' '_blank',
) );
} }
}}
className="btn btn-outline flex items-center justify-center gap-2 w-full" className="btn btn-outline flex items-center justify-center gap-2 w-full"
> >
I&apos;m using my phone I&apos;m using my phone

View File

@@ -27,7 +27,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 +35,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 +46,7 @@ export function ProfileButton({
<svg <svg
className={cn( className={cn(
'w-4 h-4 transition-transform flex-shrink-0', 'w-4 h-4 transition-transform flex-shrink-0',
showDropdown && 'rotate-180' showDropdown && 'rotate-180',
)} )}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"

View File

@@ -1,21 +1,20 @@
'use client'; 'use client';
import '@farcaster/auth-kit/styles.css'; import '@farcaster/auth-kit/styles.css';
import { useSignIn } from '@farcaster/auth-kit'; import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useSignIn, UseSignInData } from '@farcaster/auth-kit';
import { cn } from '~/lib/utils'; import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
import { Button } from '~/components/ui/Button';
import { isMobile } from '~/lib/devices';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { getItem, removeItem, setItem } from '~/lib/localStorage';
import { useMiniApp } from '@neynar/react'; import { useMiniApp } from '@neynar/react';
import { import {
signIn as backendSignIn, signIn as backendSignIn,
signOut as backendSignOut, signOut as backendSignOut,
useSession, useSession,
} from 'next-auth/react'; } from 'next-auth/react';
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; import { Button } from '~/components/ui/Button';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { getItem, removeItem, setItem } from '~/lib/localStorage';
import { cn } from '~/lib/utils';
type User = { type User = {
fid: number; fid: number;
@@ -103,16 +102,18 @@ 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);
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
const signerFlowStartedRef = useRef(false);
// Determine which flow to use based on context // Determine which flow to use based on context
const useBackendFlow = context !== undefined; const useBackendFlow = context !== undefined;
@@ -140,7 +141,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;
@@ -163,7 +164,7 @@ export function NeynarAuthButton() {
console.error('❌ Error updating session with signers:', error); console.error('❌ Error updating session with signers:', error);
} }
}, },
[useBackendFlow, message, signature, nonce] [useBackendFlow, message, signature, nonce],
); );
// Helper function to fetch user data from Neynar API // Helper function to fetch user data from Neynar API
@@ -181,7 +182,7 @@ export function NeynarAuthButton() {
return null; return null;
} }
}, },
[] [],
); );
// Helper function to generate signed key request // Helper function to generate signed key request
@@ -209,7 +210,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}`,
); );
} }
@@ -221,7 +222,7 @@ export function NeynarAuthButton() {
// throw error; // throw error;
} }
}, },
[] [],
); );
// Helper function to fetch all signers // Helper function to fetch all signers
@@ -232,10 +233,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);
@@ -257,7 +258,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;
} }
@@ -284,20 +285,52 @@ 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
const startPolling = useCallback( const startPolling = useCallback(
(signerUuid: string, message: string, signature: string) => { (signerUuid: string, message: string, signature: string) => {
// Clear any existing polling interval before starting a new one
if (pollingInterval) {
clearInterval(pollingInterval);
}
let retryCount = 0;
const maxRetries = 10; // Maximum 10 retries (20 seconds total)
const maxPollingTime = 60000; // Maximum 60 seconds of polling
const startTime = Date.now();
const interval = setInterval(async () => { const interval = setInterval(async () => {
// Check if we've been polling too long
if (Date.now() - startTime > maxPollingTime) {
clearInterval(interval);
setPollingInterval(null);
return;
}
try { try {
const response = await fetch( const response = await fetch(
`/api/auth/signer?signerUuid=${signerUuid}` `/api/auth/signer?signerUuid=${signerUuid}`,
); );
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to poll signer status'); // Check if it's a rate limit error
if (response.status === 429) {
clearInterval(interval);
setPollingInterval(null);
return;
}
// Increment retry count for other errors
retryCount++;
if (retryCount >= maxRetries) {
clearInterval(interval);
setPollingInterval(null);
return;
}
throw new Error(`Failed to poll signer status: ${response.status}`);
} }
const signerData = await response.json(); const signerData = await response.json();
@@ -319,7 +352,7 @@ export function NeynarAuthButton() {
setPollingInterval(interval); setPollingInterval(interval);
}, },
[fetchAllSigners] [fetchAllSigners, pollingInterval],
); );
// Cleanup polling on unmount // Cleanup polling on unmount
@@ -328,6 +361,7 @@ export function NeynarAuthButton() {
if (pollingInterval) { if (pollingInterval) {
clearInterval(pollingInterval); clearInterval(pollingInterval);
} }
signerFlowStartedRef.current = false;
}; };
}, [pollingInterval]); }, [pollingInterval]);
@@ -362,11 +396,11 @@ export function NeynarAuthButton() {
// Success callback - this is critical! // Success callback - this is critical!
const onSuccessCallback = useCallback( const onSuccessCallback = useCallback(
async (res: unknown) => { async (res: UseSignInData) => {
if (!useBackendFlow) { if (!useBackendFlow) {
// Only handle localStorage for frontend flow // Only handle localStorage for frontend flow
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY); const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const user = await fetchUserData(res.fid); const user = res.fid ? await fetchUserData(res.fid) : null;
const authState: StoredAuthState = { const authState: StoredAuthState = {
...existingAuth, ...existingAuth,
isAuthenticated: true, isAuthenticated: true,
@@ -378,7 +412,7 @@ export function NeynarAuthButton() {
} }
// For backend flow, the session will be handled by NextAuth // For backend flow, the session will be handled by NextAuth
}, },
[useBackendFlow, fetchUserData] [useBackendFlow, fetchUserData],
); );
// Error callback // Error callback
@@ -409,6 +443,11 @@ export function NeynarAuthButton() {
useEffect(() => { useEffect(() => {
setMessage(data?.message || null); setMessage(data?.message || null);
setSignature(data?.signature || null); setSignature(data?.signature || null);
// Reset the signer flow flag when message/signature change
if (data?.message && data?.signature) {
signerFlowStartedRef.current = false;
}
}, [data?.message, data?.signature]); }, [data?.message, data?.signature]);
// Connect for frontend flow when nonce is available // Connect for frontend flow when nonce is available
@@ -420,8 +459,16 @@ export function NeynarAuthButton() {
// Handle fetching signers after successful authentication // Handle fetching signers after successful authentication
useEffect(() => { useEffect(() => {
if (message && signature) { if (
message &&
signature &&
!isSignerFlowRunning &&
!signerFlowStartedRef.current
) {
signerFlowStartedRef.current = true;
const handleSignerFlow = async () => { const handleSignerFlow = async () => {
setIsSignerFlowRunning(true);
try { try {
const clientContext = context?.client as Record<string, unknown>; const clientContext = context?.client as Record<string, unknown>;
const isMobileContext = const isMobileContext =
@@ -437,6 +484,7 @@ export function NeynarAuthButton() {
// First, fetch existing signers // First, fetch existing signers
const signers = await fetchAllSigners(message, signature); const signers = await fetchAllSigners(message, signature);
if (useBackendFlow && isMobileContext) setSignersLoading(true); if (useBackendFlow && isMobileContext) setSignersLoading(true);
// Check if no signers exist or if we have empty signers // Check if no signers exist or if we have empty signers
@@ -447,7 +495,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
@@ -457,9 +505,9 @@ export function NeynarAuthButton() {
setShowDialog(false); setShowDialog(false);
await sdk.actions.openUrl( await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace( signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/', 'https://client.farcaster.xyz/deeplinks/signed-key-request',
'farcaster://' 'https://farcaster.xyz/~/connect',
) ),
); );
} else { } else {
setShowDialog(true); // Ensure dialog is shown during loading setShowDialog(true); // Ensure dialog is shown during loading
@@ -481,21 +529,14 @@ export function NeynarAuthButton() {
setSignersLoading(false); setSignersLoading(false);
setShowDialog(false); setShowDialog(false);
setSignerApprovalUrl(null); setSignerApprovalUrl(null);
} finally {
setIsSignerFlowRunning(false);
} }
}; };
handleSignerFlow(); handleSignerFlow();
} }
}, [ }, [message, signature]); // Simplified dependencies
message,
signature,
fetchAllSigners,
createSigner,
generateSignedKeyRequest,
startPolling,
context,
useBackendFlow,
]);
// Backend flow using NextAuth // Backend flow using NextAuth
const handleBackendSignIn = useCallback(async () => { const handleBackendSignIn = useCallback(async () => {
@@ -524,7 +565,7 @@ export function NeynarAuthButton() {
} }
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
console.log(' Sign-in rejected by user'); console.error(' Sign-in rejected by user');
} else { } else {
console.error('❌ Backend sign-in error:', e); console.error('❌ Backend sign-in error:', e);
} }
@@ -568,6 +609,9 @@ export function NeynarAuthButton() {
clearInterval(pollingInterval); clearInterval(pollingInterval);
setPollingInterval(null); setPollingInterval(null);
} }
// Reset signer flow flag
signerFlowStartedRef.current = false;
} catch (error) { } catch (error) {
console.error('❌ Error during sign out:', error); console.error('❌ Error during sign out:', error);
// Optionally handle error state // Optionally handle error state
@@ -624,7 +668,7 @@ export function NeynarAuthButton() {
'btn btn-primary flex items-center gap-3', 'btn btn-primary flex items-center gap-3',
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
'transform transition-all duration-200 active:scale-[0.98]', 'transform transition-all duration-200 active:scale-[0.98]',
!url && !useBackendFlow && 'cursor-not-allowed' !url && !useBackendFlow && 'cursor-not-allowed',
)} )}
> >
{!useBackendFlow && !url ? ( {!useBackendFlow && !url ? (

View File

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

View File

@@ -1,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,8 +157,10 @@ export function WalletTab() {
isPending: isEvmTransactionPending, isPending: isEvmTransactionPending,
} = useSendTransaction(); } = useSendTransaction();
const { isLoading: isEvmTransactionConfirming, isSuccess: isEvmTransactionConfirmed } = const {
useWaitForTransactionReceipt({ isLoading: isEvmTransactionConfirming,
isSuccess: isEvmTransactionConfirmed,
} = useWaitForTransactionReceipt({
hash: evmContractTransactionHash as `0x${string}`, hash: evmContractTransactionHash as `0x${string}`,
}); });
@@ -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) { if (
console.log("Attempting auto-connection with Farcaster context..."); context?.user?.fid &&
console.log("- User FID:", context.user.fid); !isConnected &&
console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`)); connectors.length > 0 &&
console.log("- Using connector:", connectors[0].name); isInFarcasterClient
console.log("- In Farcaster client:", 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,8 +40,10 @@ export function SendEth() {
isPending: isEthTransactionPending, isPending: isEthTransactionPending,
} = useSendTransaction(); } = useSendTransaction();
const { isLoading: isEthTransactionConfirming, isSuccess: isEthTransactionConfirmed } = const {
useWaitForTransactionReceipt({ isLoading: isEthTransactionConfirming,
isSuccess: isEthTransactionConfirmed,
} = useWaitForTransactionReceipt({
hash: ethTransactionHash, hash: ethTransactionHash,
}); });
@@ -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,10 +1,10 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { signIn, signOut, getCsrfToken } from "next-auth/react"; import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk"; import { signIn, signOut, getCsrfToken } from 'next-auth/react';
import { useSession } from "next-auth/react"; import { useSession } from 'next-auth/react';
import { Button } from "../Button"; import { Button } from '../Button';
/** /**
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF). * SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
@@ -72,7 +72,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 nonce = await getNonce(); const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce }); const result = await sdk.actions.signIn({ nonce });
@@ -89,7 +89,7 @@ export function SignIn() {
} }
setSignInFailure('Unknown error'); setSignInFailure('Unknown error');
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingIn: false })); setAuthState(prev => ({ ...prev, signingIn: false }));
} }
}, [getNonce]); }, [getNonce]);
@@ -103,14 +103,14 @@ export function SignIn() {
*/ */
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingOut: true })); setAuthState(prev => ({ ...prev, signingOut: true }));
// Only sign out if the current session is from Farcaster provider // Only sign out if the current session is from Farcaster provider
if (session?.provider === 'farcaster') { if (session?.provider === 'farcaster') {
await signOut({ redirect: false }); await signOut({ redirect: false });
} }
setSignInResult(undefined); setSignInResult(undefined);
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingOut: false })); setAuthState(prev => ({ ...prev, signingOut: false }));
} }
}, [session]); }, [session]);
@@ -132,7 +132,9 @@ export function SignIn() {
{/* Session Information */} {/* Session Information */}
{session && ( {session && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <div className="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">Session</div> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Session
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(session, null, 2)} {JSON.stringify(session, null, 2)}
</div> </div>
@@ -142,15 +144,21 @@ 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">SIWF Result</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> SIWF Result
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{signInFailure}
</div>
</div> </div>
)} )}
{/* Success Result Display */} {/* Success Result Display */}
{signInResult && !authState.signingIn && ( {signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
SIWF Result
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(signInResult, null, 2)} {JSON.stringify(signInResult, null, 2)}
</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

@@ -1,3 +1,5 @@
import { type AccountAssociation } from '@farcaster/miniapp-node';
/** /**
* Application constants and configuration values. * Application constants and configuration values.
* *
@@ -14,63 +16,71 @@
* The base URL of the application. * The base URL of the application.
* Used for generating absolute URLs for assets and API endpoints. * Used for generating absolute URLs for assets and API endpoints.
*/ */
export const APP_URL = process.env.NEXT_PUBLIC_URL!; export const APP_URL: string = process.env.NEXT_PUBLIC_URL!;
/** /**
* The name of the mini app as displayed to users. * The name of the mini app as displayed to users.
* Used in titles, headers, and app store listings. * Used in titles, headers, and app store listings.
*/ */
export const APP_NAME = 'shreyas-testing-mini-app'; export const APP_NAME: string = 'Starter Kit';
/** /**
* A brief description of the mini app's functionality. * A brief description of the mini app's functionality.
* Used in app store listings and metadata. * Used in app store listings and metadata.
*/ */
export const APP_DESCRIPTION = 'A Farcaster mini app created with Neynar'; export const APP_DESCRIPTION: string = 'A demo of the Neynar Starter Kit';
/** /**
* The primary category for the mini app. * The primary category for the mini app.
* Used for app store categorization and discovery. * Used for app store categorization and discovery.
*/ */
export const APP_PRIMARY_CATEGORY = ''; export const APP_PRIMARY_CATEGORY: string = 'developer-tools';
/** /**
* Tags associated with the mini app. * Tags associated with the mini app.
* Used for search and discovery in app stores. * Used for search and discovery in app stores.
*/ */
export const APP_TAGS = ['neynar', 'starter-kit', 'demo']; export const APP_TAGS: string[] = ['neynar', 'starter-kit', 'demo'];
// --- Asset URLs --- // --- Asset URLs ---
/** /**
* URL for the app's icon image. * URL for the app's icon image.
* Used in app store listings and UI elements. * Used in app store listings and UI elements.
*/ */
export const APP_ICON_URL = `${APP_URL}/icon.png`; export const APP_ICON_URL: string = `${APP_URL}/icon.png`;
/** /**
* URL for the app's Open Graph image. * URL for the app's Open Graph image.
* Used for social media sharing and previews. * Used for social media sharing and previews.
*/ */
export const APP_OG_IMAGE_URL = `${APP_URL}/api/opengraph-image`; export const APP_OG_IMAGE_URL: string = `${APP_URL}/api/opengraph-image`;
/** /**
* URL for the app's splash screen image. * URL for the app's splash screen image.
* Displayed during app loading. * Displayed during app loading.
*/ */
export const APP_SPLASH_URL = `${APP_URL}/splash.png`; 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 = '#f7f7f7'; export const APP_SPLASH_BACKGROUND_COLOR: string = '#f7f7f7';
/**
* Account association for the mini app.
* Used to associate the mini app with a Farcaster account.
* If not provided, the mini app will be unsigned and have limited capabilities.
*/
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
undefined;
// --- UI Configuration --- // --- UI Configuration ---
/** /**
* Text displayed on the main action button. * Text displayed on the main action button.
* Used for the primary call-to-action in the mini app. * Used for the primary call-to-action in the mini app.
*/ */
export const APP_BUTTON_TEXT = 'Launch Mini App'; export const APP_BUTTON_TEXT: string = 'Launch NSK';
// --- Integration Configuration --- // --- Integration Configuration ---
/** /**
@@ -80,7 +90,7 @@ export const APP_BUTTON_TEXT = 'Launch Mini App';
* 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 = export const APP_WEBHOOK_URL: string =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID 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`;
@@ -92,7 +102,7 @@ export const APP_WEBHOOK_URL =
* When false, wallet functionality is completely hidden from the UI. * When false, wallet functionality is completely hidden from the UI.
* Useful for mini apps that don't require wallet integration. * Useful for mini apps that don't require wallet integration.
*/ */
export const USE_WALLET = true; export const USE_WALLET: boolean = true;
/** /**
* Flag to enable/disable analytics tracking. * Flag to enable/disable analytics tracking.
@@ -101,7 +111,7 @@ export const USE_WALLET = true;
* When false, analytics collection is disabled. * When false, analytics collection is disabled.
* Useful for privacy-conscious users or development environments. * Useful for privacy-conscious users or development environments.
*/ */
export const ANALYTICS_ENABLED = true; export const ANALYTICS_ENABLED: boolean = true;
// PLEASE DO NOT UPDATE THIS // PLEASE DO NOT UPDATE THIS
export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {

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
? new Redis({
url: process.env.KV_REST_API_URL!, url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!, token: process.env.KV_REST_API_TOKEN!,
}) : null; })
: 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 ClassValue, clsx } from "clsx"; import { type Manifest } from '@farcaster/miniapp-node';
import { twMerge } from "tailwind-merge"; import { type ClassValue, clsx } from 'clsx';
import { mnemonicToAccount } from "viem/accounts"; import { twMerge } from 'tailwind-merge';
import { import {
APP_BUTTON_TEXT, APP_BUTTON_TEXT,
APP_DESCRIPTION, APP_DESCRIPTION,
@@ -9,35 +9,12 @@ import {
APP_OG_IMAGE_URL, APP_OG_IMAGE_URL,
APP_PRIMARY_CATEGORY, APP_PRIMARY_CATEGORY,
APP_SPLASH_BACKGROUND_COLOR, APP_SPLASH_BACKGROUND_COLOR,
APP_SPLASH_URL,
APP_TAGS, APP_TAGS,
APP_URL, APP_URL,
APP_WEBHOOK_URL, APP_WEBHOOK_URL,
} from "./constants"; APP_ACCOUNT_ASSOCIATION,
import { APP_SPLASH_URL } from "./constants"; } from './constants';
interface MiniAppMetadata {
version: string;
name: string;
iconUrl: string;
homeUrl: string;
imageUrl?: string;
buttonTitle?: string;
splashImageUrl?: string;
splashBackgroundColor?: string;
webhookUrl?: string;
description?: string;
primaryCategory?: string;
tags?: string[];
}
interface MiniAppManifest {
accountAssociation?: {
header: string;
payload: string;
signature: string;
};
frame: MiniAppMetadata;
}
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -45,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,
@@ -64,42 +41,16 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
}; };
} }
export async function getFarcasterMetadata(): Promise<MiniAppManifest> { export async function getFarcasterDomainManifest(): Promise<Manifest> {
// First check for MINI_APP_METADATA in .env and use that if it exists
if (process.env.MINI_APP_METADATA) {
try {
const metadata = JSON.parse(process.env.MINI_APP_METADATA);
console.log("Using pre-signed mini app metadata from environment");
return metadata;
} catch (error) {
console.warn(
"Failed to parse MINI_APP_METADATA from environment:",
error
);
}
}
if (!APP_URL) {
throw new Error("NEXT_PUBLIC_URL not configured");
}
// Get the domain from the URL (without https:// prefix)
const domain = new URL(APP_URL).hostname;
console.log("Using domain for manifest:", domain);
return { return {
accountAssociation: { accountAssociation: APP_ACCOUNT_ASSOCIATION,
header: "", miniapp: {
payload: "", version: '1',
signature: "", name: APP_NAME ?? 'Neynar Starter Kit',
},
frame: {
version: "1",
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;