Compare commits

...

61 Commits

Author SHA1 Message Date
Shreyaschorge
20c6113b8f fix qoutes 2025-09-18 21:01:36 +05:30
Shreyaschorge
343d64a77c Enfore resolutions 2025-09-18 20:58:47 +05:30
veganbeef
c5ca1618ed bump @neynar/react version 2025-09-09 23:36:13 -07:00
veganbeef
db735d7568 fix: frame_* to miniapp_* 2025-09-09 23:20:35 -07:00
veganbeef
ccd27f53b3 fix: build failure 2025-09-09 23:07:53 -07:00
veganbeef
f14493e35b fix: deploy script dependency issue 2025-09-09 22:53:03 -07:00
veganbeef
3af6ee0e71 upgrade to @neynar/react v1.2.12 2025-09-09 22:25:23 -07:00
veganbeef
54646a5035 fix: support yarn and pnpm 2025-09-08 16:23:24 -07:00
veganbeef
be7d6b76ae fix: update @neynar/react version and add overrides for qix vulnerable packages 2025-09-08 16:00:14 -07:00
veganbeef
09ef2e374e feat: bump @neynar/react version 2025-08-21 11:31:40 -07:00
veganbeef
1cce5835d4 feat: bump neynar/react version 2025-08-11 17:21:09 -07:00
veganbeef
55c7c4b129 feat: udpate script to support --return-url flag [NEYN-6533] 2025-08-07 16:11:45 -07:00
veganbeef
055dc4adbd feat: integrate returnUrl to config [NEYN-6533] 2025-08-07 16:02:46 -07:00
veganbeef
98579bcea9 fix: constants format 2025-07-23 19:29:00 -07:00
veganbeef
ea7ee37e71 fix: siwn csrf config 2025-07-23 19:23:03 -07:00
veganbeef
feb9f3e161 fix: authkit provider 2025-07-23 18:57:24 -07:00
veganbeef
8c5b8d8329 move conditional next-auth code to alternate files 2025-07-23 18:54:08 -07:00
Shreyaschorge
d0f8c75a2e Merge pull request #24 from neynarxyz/sc/fix-401-for-siwn-auth
fix:401 for SIWN and remove farcaster login mechanism
2025-07-21 16:24:59 +05:30
Shreyaschorge
e115520aa7 fix 401 2025-07-21 16:23:09 +05:30
veganbeef
7dff4cd81a fix: remove NeynarAuthButton import conditionally 2025-07-18 18:57:31 -07:00
veganbeef
d1ec161f47 fix: sponsor signer depends on seed phrase 2025-07-18 17:29:51 -07:00
Shreyaschorge
572ab9aa44 Merge pull request #23 from neynarxyz/fix-deploy-and-manifest-issue
fix-deploy-and-dynamic-import-issue
2025-07-19 04:20:59 +05:30
Shreyaschorge
501bc84512 Merge branch 'main' into fix-deploy-and-manifest-issue 2025-07-19 03:52:05 +05:30
Shreyaschorge
85b39a6397 return fragment 2025-07-19 03:48:49 +05:30
Shreyaschorge
61df6d6a64 fix: Unknown file extension '.ts' issue 2025-07-19 03:12:54 +05:30
Shreyaschorge
9ee370628d if button exists then render it 2025-07-19 02:53:18 +05:30
Shreyaschorge
882e4f166f fix imports 2025-07-19 02:43:31 +05:30
veganbeef
e8fa822638 feat: add more flags to script 2025-07-18 10:56:16 -07:00
Shreyaschorge
bade04b785 fix-deploy-and-manifest-issue 2025-07-18 22:55:10 +05:30
veganbeef
d9c74f163b fix: conditional imports 2025-07-17 14:29:24 -07:00
Shreyaschorge
2edd1bd2ae Merge pull request #22 from neynarxyz/shreyas/neyn-5928-resolve-customer-issue-with-broken-nsk-script
Handle session provider rendering
2025-07-17 03:04:45 +05:30
Shreyaschorge
76ad200a22 Handle session provider rendering 2025-07-17 03:02:54 +05:30
veganbeef
86b79e7f3f fix: use session provider whenever next auth is included 2025-07-16 13:12:48 -07:00
Shreyaschorge
aac3a739cd Merge pull request #21 from neynarxyz/veganbeef/reapply-quick-auth
feat: reapply quickauth changes conditionally
2025-07-17 00:08:18 +05:30
Shreyaschorge
f20f65f966 Bump up patch version 2025-07-17 00:07:47 +05:30
Shreyaschorge
a287d55641 set SPONSOR_SIGNER=true 2025-07-16 23:54:44 +05:30
Shreyaschorge
53d6ce6a94 sponsoredSigner -> useSponsoredSigner 2025-07-16 23:27:05 +05:30
Shreyaschorge
ebb8068ade remove update-session route 2025-07-16 23:03:01 +05:30
veganbeef
8124fe5f6c feat: add back og properties 2025-07-16 09:49:19 -07:00
veganbeef
9ea40a5f92 feat: add requiredChains support 2025-07-16 09:46:35 -07:00
veganbeef
e61bc88aaa feat: reapply quickauth changes conditionally 2025-07-16 09:16:58 -07:00
veganbeef
8eabbd3ba1 bump version number for revert 2025-07-16 08:44:39 -07:00
Lucas Myers
f7392dc3cb Merge pull request #20 from neynarxyz/sc/revert-to-5fbd9
revert to 5fbd9
2025-07-16 08:43:43 -07:00
Shreyaschorge
bf563154ca Fix merge conflict issues 2025-07-16 17:50:03 +05:30
Shreyaschorge
196378daeb Revert "format"
This reverts commit 5fbd9c5c09.
2025-07-16 17:33:02 +05:30
Shreyaschorge
89f82253ca Revert "feat: replace next-auth with quick auth"
This reverts commit 86029b2bd9.
2025-07-16 17:32:21 +05:30
Shreyaschorge
c786cabe84 Revert "fix: add back auth-kit"
This reverts commit 16c433a13c.
2025-07-16 17:31:34 +05:30
Shreyaschorge
760efdb96b Revert "format"
This reverts commit 378ea65154.
2025-07-16 17:30:47 +05:30
Shreyaschorge
5fd0e21532 Revert "fix: SIWN dependencies"
This reverts commit 4ba9480832.
2025-07-16 17:28:36 +05:30
Shreyaschorge
0d43b35c28 Revert "Merge pull request #15 from neynarxyz/shreyas-formatting"
This reverts commit b1fdfc19a9, reversing
changes made to b9e2087bd8.
2025-07-16 17:21:12 +05:30
Shreyaschorge
349cdea489 Revert "Merge pull request #18 from neynarxyz/veganbeef/fix-siwn"
This reverts commit 78626c2dc7, reversing
changes made to b1fdfc19a9.
2025-07-16 17:20:36 +05:30
veganbeef
181c364de4 fix: sponsor signer env var 2025-07-15 13:08:55 -07:00
Lucas Myers
78626c2dc7 Merge pull request #18 from neynarxyz/veganbeef/fix-siwn
fix: add back auth-kit
2025-07-15 09:34:40 -07:00
veganbeef
b72198575a Merge branch 'main' into veganbeef/fix-siwn 2025-07-15 09:29:35 -07:00
Lucas Myers
b1fdfc19a9 Merge pull request #15 from neynarxyz/shreyas-formatting
Formatting
2025-07-15 09:25:35 -07:00
veganbeef
4ba9480832 fix: SIWN dependencies 2025-07-15 09:25:08 -07:00
Shreyaschorge
378ea65154 format 2025-07-15 21:38:47 +05:30
Shreyaschorge
b366d97b53 Merge branch 'main' into shreyas-formatting 2025-07-15 21:35:11 +05:30
veganbeef
16c433a13c fix: add back auth-kit 2025-07-14 13:24:55 -07:00
veganbeef
b9e2087bd8 fix: remove duplicate app directory 2025-07-14 13:12:47 -07:00
veganbeef
86029b2bd9 feat: replace next-auth with quick auth 2025-07-14 13:01:46 -07:00
81 changed files with 2220 additions and 6466 deletions

View File

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

View File

@@ -4,8 +4,3 @@ KV_REST_API_TOKEN=''
KV_REST_API_URL='' KV_REST_API_URL=''
NEXT_PUBLIC_URL='http://localhost:3000' NEXT_PUBLIC_URL='http://localhost:3000'
NEXTAUTH_URL='http://localhost:3000' NEXTAUTH_URL='http://localhost:3000'
NEXTAUTH_SECRET=""
NEYNAR_API_KEY=""
NEYNAR_CLIENT_ID=""
USE_TUNNEL="false"

View File

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

View File

@@ -1,113 +1,32 @@
{ {
"root": true, "extends": ["next/core-web-vitals", "next/typescript"],
"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": { "rules": {
// Disable img warnings since you're using them intentionally in specific contexts
"@next/next/no-img-element": "off",
// Allow @ts-ignore comments (though @ts-expect-error is preferred)
"@typescript-eslint/ban-ts-comment": "off",
// Allow explicit any types (sometimes necessary for dynamic imports and APIs)
"@typescript-eslint/no-explicit-any": "off",
// Allow unused variables that start with underscore
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "warn",
{ {
"argsIgnorePattern": "^_", "argsIgnorePattern": "^_",
"varsIgnorePattern": "^_", "varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_" "caughtErrorsIgnorePattern": "^_"
} }
], ],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off", // Make display name warnings instead of errors for dynamic components
"@typescript-eslint/no-explicit-any": "warn", "react/display-name": "warn",
"prefer-const": "error",
"@typescript-eslint/no-floating-promises": "warn", // Allow module assignment for dynamic imports
"@typescript-eslint/await-thenable": "error", "@next/next/no-assign-module-variable": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/require-await": "warn", // Make exhaustive deps a warning instead of error for complex hooks
"react/display-name": "off", "react-hooks/exhaustive-deps": "warn"
"react/prop-types": "off", }
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"no-console": ["warn", { "allow": ["warn", "error"] }],
"no-var": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error",
"eol-last": "error",
"semi": ["error", "always"],
"quotes": ["error", "single", { "avoidEscape": true }],
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"pathGroups": [
{
"pattern": "react",
"group": "external",
"position": "before"
},
{
"pattern": "next/**",
"group": "external",
"position": "before"
},
{
"pattern": "~/**",
"group": "internal"
}
],
"pathGroupsExcludedImportTypes": ["react"],
"newlines-between": "never",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
]
},
"overrides": [
{
"files": ["*.js", "*.jsx"],
"parserOptions": {
"project": null
},
"rules": {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-console": "off"
}
},
{
"files": ["*.config.js", "*.config.ts", "next.config.*"],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-require-imports": "off"
}
}
]
} }

View File

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

22
.gitignore vendored
View File

@@ -39,25 +39,3 @@ 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/

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,51 +11,24 @@ Check out [this Neynar docs page](https://docs.neynar.com/docs/create-farcaster-
## Getting Started ## 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
``` ```
@@ -63,7 +36,6 @@ 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
``` ```
@@ -79,13 +51,12 @@ This section is only for working on the script and template. If you simply want
To iterate on the CLI and test changes in a generated app without publishing to npm: 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
npx @neynar/create-farcaster-mini-app npx @neynar/create-farcaster-mini-app
@@ -105,3 +76,4 @@ However, this does not fully replicate the npx install flow and may not catch al
### Environment Variables and Scripts ### 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

@@ -7,6 +7,11 @@ const args = process.argv.slice(2);
let projectName = null; let projectName = null;
let autoAcceptDefaults = false; let autoAcceptDefaults = false;
let apiKey = null; let apiKey = null;
let noWallet = false;
let noTunnel = false;
let sponsoredSigner = false;
let seedPhrase = null;
let returnUrl = null;
// Check for -y flag // Check for -y flag
const yIndex = args.indexOf('-y'); const yIndex = args.indexOf('-y');
@@ -15,48 +20,86 @@ if (yIndex !== -1) {
args.splice(yIndex, 1); // Remove -y from args args.splice(yIndex, 1); // Remove -y from args
} }
// Parse other arguments // Parse other arguments
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
if (arg === '-p' || arg === '--project') { if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) { if (i + 1 < args.length) {
projectName = args[i + 1]; projectName = args[i + 1];
if (projectName.startsWith('-')) { if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)'); console.error('Error: Project name cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -p/--project requires a project name');
process.exit(1); process.exit(1);
} }
args.splice(i, 2); // Remove both the flag and its value } else if (arg === '-k' || arg === '--api-key') {
i--; // Adjust index since we removed 2 elements if (i + 1 < args.length) {
} else { apiKey = args[i + 1];
console.error('Error: -p/--project requires a project name'); if (apiKey.startsWith('-')) {
process.exit(1); console.error('Error: API key cannot start with a dash (-)');
} process.exit(1);
} else if (arg === '-k' || arg === '--api-key') { }
if (i + 1 < args.length) { args.splice(i, 2); // Remove both the flag and its value
apiKey = args[i + 1]; i--; // Adjust index since we removed 2 elements
if (apiKey.startsWith('-')) { } else {
console.error('Error: API key cannot start with a dash (-)'); console.error('Error: -k/--api-key requires an API key');
process.exit(1);
}
} else if (arg === '--no-wallet') {
noWallet = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--no-tunnel') {
noTunnel = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--sponsored-signer') {
sponsoredSigner = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--seed-phrase') {
if (i + 1 < args.length) {
seedPhrase = args[i + 1];
if (seedPhrase.startsWith('-')) {
console.error('Error: Seed phrase 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: --seed-phrase requires a seed phrase');
process.exit(1);
}
} else if (arg === '-r' || arg === '--return-url') {
if (i + 1 < args.length) {
returnUrl = args[i + 1];
if (returnUrl.startsWith('-')) {
console.error('Error: Return URL 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: -r/--return-url requires a return URL');
process.exit(1); process.exit(1);
} }
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -k/--api-key requires an API key');
process.exit(1);
} }
} }
}
// Validate that if -y is used, a project name must be provided // Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) { if (autoAcceptDefaults && !projectName) {
console.error( console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.');
'Error: -y flag requires a project name. Use -p/--project to specify the project name.',
);
process.exit(1); process.exit(1);
} }
init(projectName, autoAcceptDefaults, apiKey).catch(err => { init(projectName, autoAcceptDefaults, apiKey, noWallet, noTunnel, sponsoredSigner, seedPhrase, returnUrl).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 { execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import { dirname } from 'path';
import path from 'path';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const __filename = fileURLToPath(import.meta.url); const __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;
@@ -67,6 +67,11 @@ export async function init(
projectName = null, projectName = null,
autoAcceptDefaults = false, autoAcceptDefaults = false,
apiKey = null, apiKey = null,
noWallet = false,
noTunnel = false,
sponsoredSigner = false,
seedPhrase = null,
returnUrl = null
) { ) {
printWelcomeMessage(); printWelcomeMessage();
@@ -111,7 +116,7 @@ export async function init(
} else { } else {
if (!autoAcceptDefaults) { if (!autoAcceptDefaults) {
console.log( console.log(
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n', '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'
); );
} }
@@ -148,13 +153,13 @@ export async function init(
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';
} }
@@ -167,7 +172,7 @@ export async function init(
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([
{ {
@@ -223,17 +228,34 @@ export async function init(
let answers; let answers;
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
// Handle SIWN logic for autoAcceptDefaults
let seedPhraseValue = null;
let useSponsoredSignerValue = false;
// Only set seed phrase and sponsored signer if explicitly provided via flags
if (seedPhrase) {
// Validate the provided seed phrase
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
console.error('Error: Seed phrase must be at least 12 words');
process.exit(1);
}
seedPhraseValue = seedPhrase;
// If sponsoredSigner flag is provided, enable it; otherwise default to false
useSponsoredSignerValue = sponsoredSigner;
}
answers = { answers = {
projectName: projectName || defaultMiniAppName || 'my-farcaster-mini-app', projectName: projectName || defaultMiniAppName || 'my-farcaster-mini-app',
description: 'A Farcaster mini app created with Neynar', description: 'A Farcaster mini app created with Neynar',
primaryCategory: null, primaryCategory: null,
tags: [], tags: [],
buttonText: 'Launch Mini App', buttonText: 'Launch Mini App',
useWallet: true, useWallet: !noWallet,
useTunnel: true, useTunnel: true,
enableAnalytics: true, enableAnalytics: true,
seedPhrase: null, seedPhrase: seedPhraseValue,
sponsorSigner: false, useSponsoredSigner: useSponsoredSignerValue,
returnUrl: returnUrl,
}; };
} else { } else {
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options
@@ -243,7 +265,7 @@ export async function init(
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';
} }
@@ -290,13 +312,13 @@ export async function init(
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);
}, },
}, },
{ {
@@ -304,7 +326,7 @@ export async function init(
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';
} }
@@ -316,75 +338,102 @@ export async function init(
// Merge project name from the first prompt // Merge project name from the first prompt
answers.projectName = projectNamePrompt.projectName; answers.projectName = projectNamePrompt.projectName;
// Ask about wallet and transaction tooling // Ask about wallet and transaction tooling (skip if --no-wallet flag is used)
const walletAnswer = await inquirer.prompt([ if (noWallet) {
{ answers.useWallet = false;
type: 'confirm', } else {
name: 'useWallet', const walletAnswer = await inquirer.prompt([
message:
'Would you like to include wallet and transaction tooling in your mini app?\n' +
'This includes:\n' +
'- EVM wallet connection\n' +
'- Transaction signing\n' +
'- Message signing\n' +
'- Chain switching\n' +
'- Solana support\n\n' +
'Include wallet and transaction features?',
default: true,
},
]);
answers.useWallet = walletAnswer.useWallet;
// Ask about localhost vs tunnel
const hostingAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useTunnel',
message:
'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
`⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` +
'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
default: true,
},
]);
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', type: 'confirm',
name: 'seedPhrase', name: 'useWallet',
message: message:
'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):', 'Would you like to include wallet and transaction tooling in your mini app?\n' +
validate: input => { 'This includes:\n' +
if (!input || input.trim().split(' ').length < 12) { '- EVM wallet connection\n' +
return 'Seed phrase must be at least 12 words'; '- Transaction signing\n' +
} '- Message signing\n' +
return true; '- Chain switching\n' +
}, '- Solana support\n\n' +
'Include wallet and transaction features?',
default: true,
}, },
]); ]);
answers.useWallet = walletAnswer.useWallet;
}
// Ask about localhost vs tunnel
if (noTunnel) {
answers.useTunnel = false;
} else {
const hostingAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useTunnel',
message:
'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
`⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` +
'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
default: true,
},
]);
answers.useTunnel = hostingAnswer.useTunnel;
}
// Ask about Sign In With Neynar (SIWN) - requires seed phrase
if (seedPhrase) {
// If --seed-phrase flag is used, validate it
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
console.error('Error: Seed phrase must be at least 12 words');
process.exit(1);
}
answers.seedPhrase = seedPhrase; answers.seedPhrase = seedPhrase;
// If --sponsored-signer flag is also provided, enable it
answers.useSponsoredSigner = sponsoredSigner;
} else {
const siwnAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useSIWN',
message:
'Would you like to enable Sign In With Neynar (SIWN)? This allows your mini app to write data to Farcaster on behalf of users.\n' +
'\n⚠ A seed phrase is required for this option.\n',
default: false,
},
]);
if (siwnAnswer.useSIWN) {
const { seedPhrase } = await inquirer.prompt([
{
type: 'password',
name: 'seedPhrase',
message: 'Enter your Farcaster custody account seed phrase (required for 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 sponsor signer if seed phrase is provided
const { sponsorSigner } = await inquirer.prompt([
{
type: 'confirm',
name: 'sponsorSigner',
message:
'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
default: false,
},
]);
answers.useSponsoredSigner = sponsorSigner;
} else {
answers.useSponsoredSigner = false;
}
} }
// Ask about analytics opt-out // Ask about analytics opt-out
@@ -444,7 +493,7 @@ export async function init(
// 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');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
packageJson.name = finalProjectName; packageJson.name = finalProjectName;
packageJson.version = '0.1.0'; packageJson.version = '0.1.0';
@@ -458,14 +507,15 @@ export async function init(
delete packageJson.devDependencies; delete packageJson.devDependencies;
// Add dependencies // Add dependencies
// question: remove auth-client?
packageJson.dependencies = { packageJson.dependencies = {
'@farcaster/auth-client': '>=0.3.0 <1.0.0', '@farcaster/auth-client': '>=0.3.0 <1.0.0',
'@farcaster/auth-kit': '>=0.6.0 <1.0.0',
'@farcaster/miniapp-node': '>=0.1.5 <1.0.0', '@farcaster/miniapp-node': '>=0.1.5 <1.0.0',
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
'@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/miniapp-wagmi-connector': '^1.0.0',
'@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
'@neynar/react': '^1.2.5', '@farcaster/quick-auth': '>=0.0.7 <1.0.0',
'@neynar/react': '^1.2.13',
'@radix-ui/react-label': '^2.1.1', '@radix-ui/react-label': '^2.1.1',
'@solana/wallet-adapter-react': '^0.15.38', '@solana/wallet-adapter-react': '^0.15.38',
'@tanstack/react-query': '^5.61.0', '@tanstack/react-query': '^5.61.0',
@@ -476,7 +526,6 @@ export async function init(
'lucide-react': '^0.469.0', 'lucide-react': '^0.469.0',
mipd: '^0.0.7', mipd: '^0.0.7',
next: '^15', next: '^15',
'next-auth': '^4.24.11',
react: '^19', react: '^19',
'react-dom': '^19', 'react-dom': '^19',
'tailwind-merge': '^2.6.0', 'tailwind-merge': '^2.6.0',
@@ -488,19 +537,22 @@ export async function init(
}; };
packageJson.devDependencies = { packageJson.devDependencies = {
'@types/node': '^20', "@types/inquirer": "^9.0.8",
'@types/react': '^19', "@types/node": "^20",
'@types/react-dom': '^19', "@types/react": "^19",
'@vercel/sdk': '^1.9.0', "@types/react-dom": "^19",
crypto: '^1.0.1', "@vercel/sdk": "^1.9.0",
eslint: '^8', "crypto": "^1.0.1",
'eslint-config-next': '15.0.3', "eslint": "^8",
localtunnel: '^2.0.2', "eslint-config-next": "15.0.3",
'pino-pretty': '^13.0.0', "inquirer": "^10.2.2",
postcss: '^8', "localtunnel": "^2.0.2",
tailwindcss: '^3.4.1', "pino-pretty": "^13.0.0",
typescript: '^5', "postcss": "^8",
'ts-node': '^10.9.2', "tailwindcss": "^3.4.1",
"ts-node": "^10.9.2",
"tsx": "^4.20.5",
"typescript": "^5"
}; };
// Add Neynar SDK if selected // Add Neynar SDK if selected
@@ -508,6 +560,46 @@ export async function init(
packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0'; packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0';
} }
// Add auth-kit and next-auth dependencies if SIWN is enabled (seed phrase is present)
if (answers.seedPhrase) {
packageJson.dependencies['@farcaster/auth-kit'] = '>=0.6.0 <1.0.0';
packageJson.dependencies['next-auth'] = '^4.24.11';
}
// Add security overrides for vulnerable packages (compatible with npm, Yarn, and pnpm)
const securityOverrides = {
"backslash": "0.2.0",
"chalk-template": "1.1.0",
"supports-hyperlinks": "4.1.0",
"has-ansi": "6.0.0",
"simple-swizzle": "0.2.2",
"color-string": "2.1.0",
"error-ex": "1.3.2",
"color-name": "2.0.0",
"is-arrayish": "0.3.2",
"slice-ansi": "7.1.0",
"color-convert": "3.1.0",
"wrap-ansi": "9.0.0",
"ansi-regex": "6.2.0",
"supports-color": "10.2.0",
"strip-ansi": "7.1.0",
"chalk": "5.6.0",
"ansi-styles": "6.2.1",
"axios@^1": ">=1 <2",
"axios@^0": ">=0 <1",
};
// npm v8.3+ overrides
packageJson.overrides = securityOverrides;
// Yarn (v1 and Berry) resolutions
packageJson.resolutions = securityOverrides;
// pnpm overrides (namespaced)
packageJson.pnpm = {
overrides: securityOverrides
};
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Handle .env file // Handle .env file
@@ -528,21 +620,21 @@ export async function init(
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);
@@ -553,8 +645,7 @@ export async function init(
// Regex patterns that match whole lines with export const (with TypeScript types) // Regex patterns that match whole lines with export const (with TypeScript types)
const patterns = { const patterns = {
APP_NAME: APP_NAME: /^export const APP_NAME\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
/^export const APP_NAME\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION: APP_DESCRIPTION:
/^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY: APP_PRIMARY_CATEGORY:
@@ -562,76 +653,85 @@ export async function init(
APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m, APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
APP_BUTTON_TEXT: APP_BUTTON_TEXT:
/^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
USE_WALLET: USE_WALLET: /^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
/^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
ANALYTICS_ENABLED: ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m, /^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
RETURN_URL: /^export const RETURN_URL\s*:\s*string\s*\|\s*undefined\s*=\s*(undefined|['"`][^'"`]*['"`]);$/m,
}; };
// Update APP_NAME // Update APP_NAME
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_NAME, patterns.APP_NAME,
`export const APP_NAME = '${escapeString(answers.projectName)}';`, `export const APP_NAME: string = '${escapeString(answers.projectName)}';`,
'APP_NAME', 'APP_NAME'
); );
// Update APP_DESCRIPTION // Update APP_DESCRIPTION
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_DESCRIPTION, patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString( `export const APP_DESCRIPTION: string = '${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)
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_PRIMARY_CATEGORY, patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString( `export const APP_PRIMARY_CATEGORY: string = '${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: string[] = ${tagsString};`,
'APP_TAGS', 'APP_TAGS'
); );
// Update APP_BUTTON_TEXT (always update, use answers value) // Update APP_BUTTON_TEXT (always update, use answers value)
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.APP_BUTTON_TEXT, patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString( `export const APP_BUTTON_TEXT: string = '${escapeString(
answers.buttonText || '', answers.buttonText || ''
)}';`, )}';`,
'APP_BUTTON_TEXT', 'APP_BUTTON_TEXT'
); );
// Update USE_WALLET // Update USE_WALLET
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.USE_WALLET, patterns.USE_WALLET,
`export const USE_WALLET = ${answers.useWallet};`, `export const USE_WALLET: boolean = ${answers.useWallet};`,
'USE_WALLET', 'USE_WALLET'
); );
// Update ANALYTICS_ENABLED // Update ANALYTICS_ENABLED
constantsContent = safeReplace( constantsContent = safeReplace(
constantsContent, constantsContent,
patterns.ANALYTICS_ENABLED, patterns.ANALYTICS_ENABLED,
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`, `export const ANALYTICS_ENABLED: boolean = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED', 'ANALYTICS_ENABLED'
);
// Update RETURN_URL
const returnUrlValue = answers.returnUrl ? `'${escapeString(answers.returnUrl)}'` : 'undefined';
constantsContent = safeReplace(
constantsContent,
patterns.RETURN_URL,
`export const RETURN_URL: string | undefined = ${returnUrlValue};`,
'RETURN_URL'
); );
fs.writeFileSync(constantsPath, constantsContent); fs.writeFileSync(constantsPath, constantsContent);
@@ -639,27 +739,33 @@ export async function init(
console.log('⚠️ constants.ts not found, skipping constants update'); console.log('⚠️ constants.ts not found, skipping constants update');
} }
fs.appendFileSync(
envPath,
`\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) {
console.log('✅ Writing SEED_PHRASE and NEXTAUTH_SECRET to .env.local');
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
// Add NextAuth secret for SIWN
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
);
} }
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
if (answers.useSponsoredSigner) {
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="true"`);
}
fs.unlinkSync(envExamplePath); fs.unlinkSync(envExamplePath);
} else { } else {
console.log( console.log(
'\n.env.example does not exist, skipping copy and remove operations', '\n.env.example does not exist, skipping copy and remove operations'
); );
} }
@@ -698,13 +804,94 @@ export async function init(
fs.rmSync(binPath, { recursive: true, force: true }); fs.rmSync(binPath, { recursive: true, force: true });
} }
// Handle SIWN-related files based on whether seed phrase is provided
if (!answers.seedPhrase) {
// Remove SIWN-related files when SIWN is not enabled (no seed phrase)
console.log('\nRemoving SIWN-related files (SIWN not enabled)...');
// Remove NeynarAuthButton directory
const neynarAuthButtonPath = path.join(projectPath, 'src', 'components', 'ui', 'NeynarAuthButton');
if (fs.existsSync(neynarAuthButtonPath)) {
fs.rmSync(neynarAuthButtonPath, { recursive: true, force: true });
}
// Remove NextAuth API routes
const nextAuthRoutePath = path.join(projectPath, 'src', 'app', 'api', 'auth', '[...nextauth]', 'route.ts');
if (fs.existsSync(nextAuthRoutePath)) {
fs.rmSync(nextAuthRoutePath, { force: true });
// Remove the directory if it's empty
const nextAuthDir = path.dirname(nextAuthRoutePath);
if (fs.readdirSync(nextAuthDir).length === 0) {
fs.rmSync(nextAuthDir, { recursive: true, force: true });
}
}
// Remove src/auth.ts file
const authFilePath = path.join(projectPath, 'src', 'auth.ts');
if (fs.existsSync(authFilePath)) {
fs.rmSync(authFilePath, { force: true });
}
// Remove SIWN-specific files
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
if (fs.existsSync(actionsTabNeynarAuthPath)) {
fs.rmSync(actionsTabNeynarAuthPath, { force: true });
}
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
if (fs.existsSync(layoutNeynarAuthPath)) {
fs.rmSync(layoutNeynarAuthPath, { force: true });
}
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
if (fs.existsSync(providersNeynarAuthPath)) {
fs.rmSync(providersNeynarAuthPath, { force: true });
}
} else {
// Move SIWN-specific files to replace the regular versions when SIWN is enabled
console.log('\nMoving SIWN-specific files to replace regular versions (SIWN enabled)...');
// Move ActionsTab.NeynarAuth.tsx to ActionsTab.tsx
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
const actionsTabPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.tsx');
if (fs.existsSync(actionsTabNeynarAuthPath)) {
if (fs.existsSync(actionsTabPath)) {
fs.rmSync(actionsTabPath, { force: true }); // Delete original
}
fs.renameSync(actionsTabNeynarAuthPath, actionsTabPath);
console.log('✅ Moved ActionsTab.NeynarAuth.tsx to ActionsTab.tsx');
}
// Move layout.NeynarAuth.tsx to layout.tsx
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
const layoutPath = path.join(projectPath, 'src', 'app', 'layout.tsx');
if (fs.existsSync(layoutNeynarAuthPath)) {
if (fs.existsSync(layoutPath)) {
fs.rmSync(layoutPath, { force: true }); // Delete original
}
fs.renameSync(layoutNeynarAuthPath, layoutPath);
console.log('✅ Moved layout.NeynarAuth.tsx to layout.tsx');
}
// Move providers.NeynarAuth.tsx to providers.tsx
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
const providersPath = path.join(projectPath, 'src', 'app', 'providers.tsx');
if (fs.existsSync(providersNeynarAuthPath)) {
if (fs.existsSync(providersPath)) {
fs.rmSync(providersPath, { force: true }); // Delete original
}
fs.renameSync(providersNeynarAuthPath, providersPath);
console.log('✅ Moved providers.NeynarAuth.tsx to providers.tsx');
}
}
// Initialize git repository // Initialize git repository
console.log('\nInitializing git repository...'); console.log('\nInitializing git repository...');
execSync('git init', { cwd: projectPath }); execSync('git init', { cwd: projectPath });
execSync('git add .', { cwd: projectPath }); execSync('git add .', { cwd: projectPath });
execSync( execSync(
'git commit -m "initial commit from @neynar/create-farcaster-mini-app"', 'git commit -m "initial commit from @neynar/create-farcaster-mini-app"',
{ cwd: projectPath }, { cwd: projectPath }
); );
// Calculate border length based on message length // Calculate border length based on message length

View File

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

2
index.d.ts vendored
View File

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

View File

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

5221
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.6.3", "version": "1.8.13",
"type": "module", "type": "module",
"private": false, "private": false,
"access": "public", "access": "public",
@@ -35,10 +35,7 @@
"build:raw": "next build", "build:raw": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix", "deploy:vercel": "tsx scripts/deploy.ts",
"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"
}, },
@@ -53,11 +50,18 @@
"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"
},
"overrides": {
"chalk": "5.3.0",
"strip-ansi": "6.0.1",
"wrap-ansi": "8.1.0",
"ansi-styles": "6.2.3",
"color-convert": "2.0.1",
"color-name": "1.1.4",
"is-core-module": "2.13.1",
"error-ex": "1.3.2",
"simple-swizzle": "0.2.2",
"has-ansi": "5.0.1"
} }
} }

View File

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

View File

@@ -7,7 +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'; import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants.js';
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, '..');
@@ -115,35 +115,147 @@ async function checkRequiredEnvVars(): Promise<void> {
`${newLine}${varConfig.name}="${value.trim()}"`, `${newLine}${varConfig.name}="${value.trim()}"`,
); );
} }
}
// Ask about sponsor signer if SEED_PHRASE is provided // Ask about SIWN if SEED_PHRASE is provided (moved outside the loop)
if (!process.env.SPONSOR_SIGNER) { if (process.env.SEED_PHRASE && !process.env.SPONSOR_SIGNER) {
const { sponsorSigner } = await inquirer.prompt([ const { sponsorSigner } = await inquirer.prompt([
{ {
type: 'confirm', type: 'confirm',
name: 'sponsorSigner', name: 'sponsorSigner',
message: message:
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' + 'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' + 'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers', 'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
default: false, 'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
default: false,
},
]);
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
fs.appendFileSync(
'.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"`,
);
console.log('✅ Sponsor signer preference stored in .env.local');
}
// Ask about required chains (moved outside the loop)
const { useRequiredChains } = await inquirer.prompt([
{
type: 'confirm',
name: 'useRequiredChains',
message:
'Does your mini app require support for specific blockchains?\n' +
'If yes, the host will only render your mini app if it supports all the chains you specify.\n' +
'If no, the mini app will be rendered regardless of chain support.',
default: false,
},
]);
let requiredChains: string[] = [];
if (useRequiredChains) {
const { selectedChains } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedChains',
message: 'Select the required chains (CAIP-2 identifiers):',
choices: [
{ name: 'Ethereum Mainnet (eip155:1)', value: 'eip155:1' },
{ name: 'Polygon (eip155:137)', value: 'eip155:137' },
{ name: 'Arbitrum One (eip155:42161)', value: 'eip155:42161' },
{ name: 'Optimism (eip155:10)', value: 'eip155:10' },
{ name: 'Base (eip155:8453)', value: 'eip155:8453' },
{ name: 'Solana (solana:mainnet)', value: 'solana:mainnet' },
{ name: 'Solana Devnet (solana:devnet)', value: 'solana:devnet' },
],
},
]);
requiredChains = selectedChains;
}
// Update constants.ts with required chains
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_REQUIRED_CHAINS line
const requiredChainsString = JSON.stringify(requiredChains);
constantsContent = constantsContent.replace(
/^export const APP_REQUIRED_CHAINS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
`export const APP_REQUIRED_CHAINS: string[] = ${requiredChainsString};`,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('✅ Required chains updated in constants.ts');
}
// Ask for account association
console.log(
`\n⚠ To complete your mini app manifest, you need to sign it using the Farcaster developer portal.`,
);
console.log(
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest',
);
console.log(
'2. Enter your app domain (you\'ll get this after deployment)',
);
console.log(
'3. Click "Transfer Ownership" and follow the instructions to sign the manifest.',
);
console.log(
'4. Copy the resulting accountAssociation JSON.',
);
console.log('5. Paste it below when prompted.');
console.log(
'\nNote: If you don\'t have the accountAssociation yet, you can press Ctrl+C to skip and add it later.',
);
try {
const { userAccountAssociation } = await inquirer.prompt([
{
type: 'editor',
name: 'userAccountAssociation',
message: 'Paste the accountAssociation JSON here (or press Ctrl+C to skip):',
validate: (input: string) => {
if (!input.trim()) {
return 'You can press Ctrl+C to skip this step';
}
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);
process.env.SPONSOR_SIGNER = sponsorSigner.toString(); // Write APP_ACCOUNT_ASSOCIATION to constants.ts
if (fs.existsSync(constantsPath)) {
if (storeSeedPhrase) { let constantsContent = fs.readFileSync(constantsPath, 'utf8');
fs.appendFileSync(
'.env.local', // Replace the APP_ACCOUNT_ASSOCIATION line
`\nSPONSOR_SIGNER="${sponsorSigner}"`, const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`;
); constantsContent = constantsContent.replace(
console.log('✅ Sponsor signer preference stored in .env.local'); /^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m,
} newAccountAssociation,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('✅ APP_ACCOUNT_ASSOCIATION updated in constants.ts');
} }
} catch (error) {
console.log('\n Skipping account association for now. You can add it later by updating APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts');
} }
} }
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't // Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists (SIWN enabled) but SPONSOR_SIGNER doesn't
if ( if (
process.env.SEED_PHRASE && process.env.SEED_PHRASE &&
!process.env.SPONSOR_SIGNER && !process.env.SPONSOR_SIGNER &&
@@ -299,17 +411,26 @@ async function setVercelEnvVarSDK(
} }
// Get existing environment variables // Get existing environment variables
const existingVars = await vercelClient.projects.getEnvironmentVariables({ const existingVars = await vercelClient.projects.filterProjectEnvs({
idOrName: projectId, idOrName: projectId,
}); });
const existingVar = existingVars.envs?.find( // Handle different response types
let envs: any[] = [];
if ('envs' in existingVars && Array.isArray(existingVars.envs)) {
envs = existingVars.envs;
} else if ('target' in existingVars && 'key' in existingVars) {
// Single environment variable response
envs = [existingVars];
}
const existingVar = envs.find(
(env: any) => env.key === key && env.target?.includes('production'), (env: any) => env.key === key && env.target?.includes('production'),
); );
if (existingVar) { if (existingVar && existingVar.id) {
// Update existing variable // Update existing variable
await vercelClient.projects.editEnvironmentVariable({ await vercelClient.projects.editProjectEnv({
idOrName: projectId, idOrName: projectId,
id: existingVar.id, id: existingVar.id,
requestBody: { requestBody: {
@@ -320,7 +441,7 @@ async function setVercelEnvVarSDK(
console.log(`✅ Updated environment variable: ${key}`); console.log(`✅ Updated environment variable: ${key}`);
} else { } else {
// Create new variable // Create new variable
await vercelClient.projects.createEnvironmentVariable({ await vercelClient.projects.createProjectEnv({
idOrName: projectId, idOrName: projectId,
requestBody: { requestBody: {
key: key, key: key,
@@ -458,7 +579,7 @@ async function waitForDeployment(
while (Date.now() - startTime < maxWaitTime) { while (Date.now() - startTime < maxWaitTime) {
try { try {
const deployments = await vercelClient?.deployments.list({ const deployments = await vercelClient?.deployments.getDeployments({
projectId: projectId, projectId: projectId,
limit: 1, limit: 1,
}); });
@@ -594,12 +715,17 @@ async function deployToVercel(useGitHub = false): Promise<void> {
if (vercelClient) { if (vercelClient) {
try { try {
const project = await vercelClient.projects.get({ const projects = await vercelClient.projects.getProjects({});
idOrName: projectId, const project = projects.projects.find(
}); (p: any) => p.id === projectId || p.name === projectId,
projectName = project.name; );
domain = `${projectName}.vercel.app`; if (project) {
console.log('🌐 Using project name for domain:', domain); projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} else {
throw new Error('Project not found');
}
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
console.warn( console.warn(
@@ -656,9 +782,6 @@ async function deployToVercel(useGitHub = false): Promise<void> {
const nextAuthSecret = const nextAuthSecret =
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
const vercelEnv = { const vercelEnv = {
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
NEXT_PUBLIC_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`,
...(process.env.NEYNAR_API_KEY && { ...(process.env.NEYNAR_API_KEY && {
@@ -671,6 +794,14 @@ async function deployToVercel(useGitHub = false): Promise<void> {
SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, SPONSOR_SIGNER: process.env.SPONSOR_SIGNER,
}), }),
// Include NextAuth environment variables if SEED_PHRASE is present (SIWN enabled)
...(process.env.SEED_PHRASE && {
SEED_PHRASE: process.env.SEED_PHRASE,
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
}),
...Object.fromEntries( ...Object.fromEntries(
Object.entries(process.env).filter(([key]) => Object.entries(process.env).filter(([key]) =>
key.startsWith('NEXT_PUBLIC_'), key.startsWith('NEXT_PUBLIC_'),
@@ -763,10 +894,14 @@ async function deployToVercel(useGitHub = false): Promise<void> {
console.log('🔄 Updating environment variables with correct domain...'); console.log('🔄 Updating environment variables with correct domain...');
const updatedEnv: Record<string, string | object> = { const updatedEnv: Record<string, string | object> = {
NEXTAUTH_URL: `https://${actualDomain}`,
NEXT_PUBLIC_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`,
}; };
// Include NextAuth URL if SEED_PHRASE is present (SIWN enabled)
if (process.env.SEED_PHRASE) {
updatedEnv.NEXTAUTH_URL = `https://${actualDomain}`;
}
await setEnvironmentVariables( await setEnvironmentVariables(
vercelClient, vercelClient,
projectId, projectId,
@@ -806,55 +941,17 @@ async function deployToVercel(useGitHub = false): Promise<void> {
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',
); );
// Prompt user to sign manifest in browser and paste accountAssociation // Remind user about account association if not already set
console.log( console.log(
`\n⚠️ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`, `\n💡 Remember: If you haven't already signed your manifest, go to:`,
); );
console.log( console.log(
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + ` https://farcaster.xyz/~/developers/mini-apps/manifest?domain=${domain}`,
domain,
); );
console.log( console.log(
'2. Click "Transfer Ownership" and follow the instructions to sign the manifest.', ' to complete the ownership transfer and update APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts',
); );
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) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
console.error('\n❌ Deployment failed:', error.message); console.error('\n❌ Deployment failed:', error.message);
@@ -953,4 +1050,4 @@ async function main(): Promise<void> {
} }
} }
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,18 +33,18 @@ args.forEach((arg, index) => {
}); });
async function checkPort(port) { async function checkPort(port) {
return new Promise(resolve => { return new Promise((resolve) => {
const server = createServer(); const server = createServer();
server.once('error', () => { server.once('error', () => {
resolve(true); // Port is in use resolve(true); // Port is in use
}); });
server.once('listening', () => { server.once('listening', () => {
server.close(); server.close();
resolve(false); // Port is free resolve(false); // Port is free
}); });
server.listen(port); server.listen(port);
}); });
} }
@@ -54,32 +54,29 @@ 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 data.toString().split('\n').forEach(pid => {
.toString() if (pid) {
.split('\n') try {
.forEach(pid => { process.kill(parseInt(pid), 'SIGKILL');
if (pid) { } catch (e) {
try { if (e.code !== 'ESRCH') throw e;
process.kill(parseInt(pid), 'SIGKILL');
} catch (e) {
if (e.code !== 'ESRCH') throw e;
}
} }
}); }
});
}); });
await new Promise(resolve => lsof.on('close', resolve)); await new Promise((resolve) => lsof.on('close', resolve));
} }
} catch (e) { } catch (e) {
// Ignore errors if no process found // Ignore errors if no process found
@@ -90,15 +87,13 @@ 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( console.error(`Port ${port} is already in use. To find and kill the process using this port:\n\n` +
`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);
} }
@@ -110,9 +105,7 @@ 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') ip = await fetch('https://ipv4.icanhazip.com').then(res => res.text()).then(ip => ip.trim());
.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);
} }
@@ -150,21 +143,15 @@ async function startDev() {
4. Click "Preview" to test your mini app (note that it may take ~5 seconds to load the first time) 4. Click "Preview" to test your mini app (note that it may take ~5 seconds to load the first time)
`); `);
} }
// Start next dev with appropriate configuration // Start next dev with appropriate configuration
const nextBin = path.normalize( const nextBin = path.normalize(path.join(projectRoot, 'node_modules', '.bin', 'next'));
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: { env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl },
...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
@@ -194,7 +181,7 @@ async function startDev() {
console.log('Note: Next.js process already terminated'); console.log('Note: Next.js process already terminated');
} }
} }
if (tunnel) { if (tunnel) {
try { try {
await tunnel.close(); await tunnel.close();
@@ -222,4 +209,4 @@ async function startDev() {
} }
} }
startDev().catch(console.error); startDev().catch(console.error);

View File

@@ -7,6 +7,7 @@ export async function GET() {
return NextResponse.json(config); return NextResponse.json(config);
} catch (error) { } catch (error) {
console.error('Error generating metadata:', error); console.error('Error generating metadata:', error);
return NextResponse.json({ error: error.message }, { status: 500 }); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return NextResponse.json({ error: errorMessage }, { status: 500 });
} }
} }

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

@@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/auth';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.fid) {
return NextResponse.json(
{ error: 'No authenticated session found' },
{ status: 401 },
);
}
const body = await request.json();
const { signers, user } = body;
if (!signers || !user) {
return NextResponse.json(
{ error: 'Signers and user are required' },
{ status: 400 },
);
}
// For NextAuth to update the session, we need to trigger the JWT callback
// This is typically done by calling the session endpoint with updated data
// However, we can't directly modify the session token from here
// Instead, we'll store the data temporarily and let the client refresh the session
// The session will be updated when the JWT callback is triggered
return NextResponse.json({
success: true,
message: 'Session update prepared',
signers,
user,
});
} catch (error) {
console.error('Error preparing session update:', error);
return NextResponse.json(
{ error: 'Failed to prepare session update' },
{ status: 500 },
);
}
}

View File

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

View File

@@ -4,21 +4,18 @@ export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY; const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fid = searchParams.get('fid'); const fid = searchParams.get('fid');
if (!apiKey) { if (!apiKey) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
error: { status: 500 }
'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 }
); );
} }
@@ -27,28 +24,23 @@ 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 { const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] };
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.' },
error: { status: 500 }
'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,24 +15,16 @@ export async function GET(request: NextRequest) {
<div tw="flex h-full w-full flex-col justify-center items-center relative bg-primary"> <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 <img src={user.pfp_url} alt="Profile" tw="w-full h-full object-cover" />
src={user.pfp_url}
alt="Profile"
tw="w-full h-full object-cover"
/>
</div> </div>
)} )}
<h1 tw="text-8xl text-white"> <h1 tw="text-8xl text-white">{user?.display_name ? `Hello from ${user.display_name ?? user.username}!` : 'Hello!'}</h1>
{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 { NextRequest } from 'next/server'; import { notificationDetailsSchema } from "@farcaster/miniapp-sdk";
import { notificationDetailsSchema } from '@farcaster/miniapp-sdk'; import { NextRequest } from "next/server";
import { z } from 'zod'; import { z } from "zod";
import { setUserNotificationDetails } from '~/lib/kv'; import { setUserNotificationDetails } from "~/lib/kv";
import { sendNeynarMiniAppNotification } from '~/lib/neynar'; import { sendMiniAppNotification } from "~/lib/notifs";
import { sendMiniAppNotification } from '~/lib/notifs'; import { sendNeynarMiniAppNotification } from "~/lib/neynar";
const requestSchema = z.object({ const requestSchema = z.object({
fid: z.number(), fid: z.number(),
@@ -13,8 +13,7 @@ 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 = const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
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);
@@ -22,7 +21,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 }
); );
} }
@@ -30,29 +29,27 @@ 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 const sendNotification = neynarEnabled ? sendNeynarMiniAppNotification : sendMiniAppNotification;
? 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,32 +1,29 @@
import { NextResponse } from 'next/server';
import { NeynarAPIClient } from '@neynar/nodejs-sdk'; import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { NextResponse } from 'next/server';
export async function GET(request: Request) { export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY; const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const fids = searchParams.get('fids'); const fids = searchParams.get('fids');
if (!apiKey) { if (!apiKey) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
error: { status: 500 }
'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.',
},
{ status: 500 },
); );
} }
if (!fids) { if (!fids) {
return NextResponse.json( return NextResponse.json(
{ error: 'FIDs parameter is required' }, { error: 'FIDs parameter is required' },
{ status: 400 }, { status: 400 }
); );
} }
try { try {
const neynar = new NeynarAPIClient({ apiKey }); const neynar = new NeynarAPIClient({ apiKey });
const fidsArray = fids.split(',').map(fid => parseInt(fid.trim())); const fidsArray = fids.split(',').map(fid => parseInt(fid.trim()));
const { users } = await neynar.fetchBulkUsers({ const { users } = await neynar.fetchBulkUsers({
fids: fidsArray, fids: fidsArray,
}); });
@@ -35,11 +32,8 @@ 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.' },
error: { status: 500 }
'Failed to fetch users. Please check your Neynar API key and try again.',
},
{ status: 500 },
); );
} }
} }

View File

@@ -1,21 +1,20 @@
import { NextRequest } from 'next/server';
import { import {
ParseWebhookEvent, ParseWebhookEvent,
parseWebhookEvent, parseWebhookEvent,
verifyAppKeyWithNeynar, verifyAppKeyWithNeynar,
} from '@farcaster/miniapp-node'; } from "@farcaster/miniapp-node";
import { APP_NAME } from '~/lib/constants'; import { NextRequest } from "next/server";
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 = const neynarEnabled = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID;
if (neynarEnabled) { if (neynarEnabled) {
return Response.json({ success: true }); return Response.json({ success: true });
} }
@@ -29,24 +28,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 }
); );
} }
} }
@@ -57,33 +56,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 "miniapp_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 "miniapp_removed":
await deleteUserNotificationDetails(fid); await deleteUserNotificationDetails(fid);
break; break;
case 'notifications_enabled': case "notifications_enabled":
await setUserNotificationDetails(fid, event.notificationDetails); await setUserNotificationDetails(fid, event.notificationDetails);
await sendMiniAppNotification({ await sendMiniAppNotification({
fid, fid,
title: `Welcome to ${APP_NAME}`, title: `Welcome to ${APP_NAME}`,
body: 'Notifications are now enabled', body: "Notifications are now enabled",
}); });
break; break;
case 'notifications_disabled': case "notifications_disabled":
await deleteUserNotificationDetails(fid); await deleteUserNotificationDetails(fid);
break; break;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Metadata } from 'next'; import { Metadata } from "next";
import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from '~/lib/constants'; import App from "./app";
import { getMiniAppEmbedMetadata } from '~/lib/utils'; import { APP_NAME, APP_DESCRIPTION, APP_OG_IMAGE_URL } from "~/lib/constants";
import App from './app'; import { getMiniAppEmbedMetadata } from "~/lib/utils";
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

@@ -0,0 +1,44 @@
'use client';
import dynamic from 'next/dynamic';
import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import { AuthKitProvider } from '@farcaster/auth-kit';
import { MiniAppProvider } from '@neynar/react';
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
import { ANALYTICS_ENABLED, RETURN_URL } from '~/lib/constants';
const WagmiProvider = dynamic(
() => import('~/components/providers/WagmiProvider'),
{
ssr: false,
}
);
export function Providers({
session,
children,
}: {
session: Session | null;
children: React.ReactNode;
}) {
const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
return (
<SessionProvider session={session}>
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
returnUrl={RETURN_URL}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<AuthKitProvider config={{}}>
{children}
</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
</SessionProvider>
);
}

View File

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

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'; import type { Metadata } from "next";
import type { Metadata } from 'next'; import { redirect } from "next/navigation";
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 {
@@ -217,74 +217,6 @@ function getDomainFromUrl(urlString: string | undefined): string {
export const authOptions: AuthOptions = { export const authOptions: AuthOptions = {
// Configure one or more authentication providers // Configure one or more authentication providers
providers: [ providers: [
CredentialsProvider({
id: 'farcaster',
name: 'Sign in with Farcaster',
credentials: {
message: {
label: 'Message',
type: 'text',
placeholder: '0x0',
},
signature: {
label: 'Signature',
type: 'text',
placeholder: '0x0',
},
nonce: {
label: 'Nonce',
type: 'text',
placeholder: 'Custom nonce (optional)',
},
// In a production app with a server, these should be fetched from
// your Farcaster data indexer rather than have them accepted as part
// of credentials.
// question: should these natively use the Neynar API?
name: {
label: 'Name',
type: 'text',
placeholder: '0x0',
},
pfp: {
label: 'Pfp',
type: 'text',
placeholder: '0x0',
},
},
async authorize(credentials, req) {
const nonce = req?.body?.csrfToken;
if (!nonce) {
console.error('No nonce or CSRF token provided');
return null;
}
const appClient = createAppClient({
ethereum: viemConnector(),
});
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
const verifyResponse = await appClient.verifySignInMessage({
message: credentials?.message as string,
signature: credentials?.signature as `0x${string}`,
domain,
nonce,
});
const { success, fid } = verifyResponse;
if (!success) {
return null;
}
return {
id: fid.toString(),
name: credentials?.name || `User ${fid}`,
image: credentials?.pfp || null,
provider: 'farcaster',
};
},
}),
CredentialsProvider({ CredentialsProvider({
id: 'neynar', id: 'neynar',
name: 'Sign in with Neynar', name: 'Sign in with Neynar',
@@ -333,10 +265,18 @@ export const authOptions: AuthOptions = {
try { try {
// Validate the signature using Farcaster's auth client (same as Farcaster provider) // Validate the signature using Farcaster's auth client (same as Farcaster provider)
const appClient = createAppClient({ const appClient = createAppClient({
// USE your own RPC URL or else you might get 401 error
ethereum: viemConnector(), ethereum: viemConnector(),
}); });
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); const baseUrl =
process.env.VERCEL_ENV === 'production'
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NEXTAUTH_URL || `http://localhost:${process.env.PORT ?? 3000}`;
const domain = getDomainFromUrl(baseUrl);
const verifyResponse = await appClient.verifySignInMessage({ const verifyResponse = await appClient.verifySignInMessage({
message: credentials?.message as string, message: credentials?.message as string,
@@ -377,12 +317,7 @@ export const authOptions: AuthOptions = {
// Set provider at the root level // Set provider at the root level
session.provider = token.provider as string; session.provider = token.provider as string;
if (token.provider === 'farcaster') { if (token.provider === 'neynar') {
// For Farcaster, simple structure
session.user = {
fid: parseInt(token.sub ?? ''),
};
} else if (token.provider === 'neynar') {
// For Neynar, use full user data structure from user // For Neynar, use full user data structure from user
session.user = token.user as typeof session.user; session.user = token.user as typeof session.user;
session.signers = token.signers as typeof session.signers; session.signers = token.signers as typeof session.signers;
@@ -401,29 +336,29 @@ 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: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/', path: '/',
secure: true, secure: process.env.NODE_ENV === 'production',
}, },
}, },
callbackUrl: { callbackUrl: {
name: 'next-auth.callback-url', name: `next-auth.callback-url`,
options: { options: {
sameSite: 'none', sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/', path: '/',
secure: true, secure: process.env.NODE_ENV === 'production',
}, },
}, },
csrfToken: { csrfToken: {
name: 'next-auth.csrf-token', name: `next-auth.csrf-token`,
options: { options: {
httpOnly: true, httpOnly: true,
sameSite: 'none', sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
path: '/', path: '/',
secure: true, secure: process.env.NODE_ENV === 'production',
}, },
}, },
}, },

View File

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

View File

@@ -1,13 +1,10 @@
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),
import('@farcaster/mini-app-solana').then( { ssr: false }
mod => mod.FarcasterSolanaProvider,
),
{ ssr: false },
); );
type SafeFarcasterSolanaProviderProps = { type SafeFarcasterSolanaProviderProps = {
@@ -15,15 +12,10 @@ type SafeFarcasterSolanaProviderProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ hasSolanaProvider: false });
hasSolanaProvider: false,
});
export function SafeFarcasterSolanaProvider({ export function SafeFarcasterSolanaProvider({ endpoint, children }: SafeFarcasterSolanaProviderProps) {
endpoint, const isClient = typeof window !== "undefined";
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);
@@ -56,8 +48,8 @@ export function SafeFarcasterSolanaProvider({
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 React from 'react'; import { createConfig, http, WagmiProvider } from "wagmi";
import { useEffect, useState } from 'react'; import { base, degen, mainnet, optimism, unichain, celo } from "wagmi/chains";
import { farcasterFrame } from '@farcaster/miniapp-wagmi-connector'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { farcasterFrame } from "@farcaster/miniapp-wagmi-connector";
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,16 +17,15 @@ 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 = const isInCoinbaseWallet = window.ethereum?.isCoinbaseWallet ||
window.ethereum?.isCoinbaseWallet ||
window.ethereum?.isCoinbaseWalletExtension || window.ethereum?.isCoinbaseWalletExtension ||
window.ethereum?.isCoinbaseWalletBrowser; window.ethereum?.isCoinbaseWalletBrowser;
setIsCoinbaseWallet(!!isInCoinbaseWallet); setIsCoinbaseWallet(!!isInCoinbaseWallet);
}; };
checkCoinbaseWallet(); checkCoinbaseWallet();
window.addEventListener('ethereum#initialized', checkCoinbaseWallet); window.addEventListener('ethereum#initialized', checkCoinbaseWallet);
return () => { return () => {
window.removeEventListener('ethereum#initialized', checkCoinbaseWallet); window.removeEventListener('ethereum#initialized', checkCoinbaseWallet);
}; };
@@ -71,11 +70,7 @@ 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({ function CoinbaseWalletAutoConnect({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
useCoinbaseWalletAutoConnect(); useCoinbaseWalletAutoConnect();
return <>{children}</>; return <>{children}</>;
} }
@@ -84,7 +79,9 @@ export default function Provider({ children }: { children: React.ReactNode }) {
return ( return (
<WagmiProvider config={config}> <WagmiProvider config={config}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<CoinbaseWalletAutoConnect>{children}</CoinbaseWalletAutoConnect> <CoinbaseWalletAutoConnect>
{children}
</CoinbaseWalletAutoConnect>
</QueryClientProvider> </QueryClientProvider>
</WagmiProvider> </WagmiProvider>
); );

View File

@@ -5,40 +5,43 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
} }
export function Button({ export function Button({
children, children,
className = '', className = "",
isLoading = false, isLoading = false,
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
...props ...props
}: ButtonProps) { }: ButtonProps) {
const baseClasses = 'btn'; const baseClasses = "btn";
const variantClasses = { const variantClasses = {
primary: 'btn-primary', primary: "btn-primary",
secondary: 'btn-secondary', secondary: "btn-secondary",
outline: 'btn-outline', outline: "btn-outline"
}; };
const sizeClasses = { 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 className={combinedClasses} {...props}> <button
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,19 +7,13 @@ interface FooterProps {
showWallet?: boolean; showWallet?: boolean;
} }
export const Footer: React.FC<FooterProps> = ({ export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWallet = false }) => (
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 activeTab === Tab.Home ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
}`} }`}
> >
<span className="text-xl">🏠</span> <span className="text-xl">🏠</span>
@@ -28,9 +22,7 @@ export const Footer: React.FC<FooterProps> = ({
<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 activeTab === Tab.Actions ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
}`} }`}
> >
<span className="text-xl"></span> <span className="text-xl"></span>
@@ -39,9 +31,7 @@ export const Footer: React.FC<FooterProps> = ({
<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 activeTab === Tab.Context ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
? 'text-primary dark:text-primary-light'
: 'text-gray-500 dark:text-gray-400'
}`} }`}
> >
<span className="text-xl">📋</span> <span className="text-xl">📋</span>
@@ -51,9 +41,7 @@ export const Footer: React.FC<FooterProps> = ({
<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 activeTab === Tab.Wallet ? 'text-primary dark:text-primary-light' : 'text-gray-500 dark:text-gray-400'
? '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,10 +1,9 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import Image from 'next/image'; import { APP_NAME } from "~/lib/constants";
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?: {
@@ -19,19 +18,23 @@ export function Header({ neynarUser }: HeaderProps) {
return ( return (
<div className="relative"> <div className="relative">
<div className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary"> <div
<div className="text-lg font-light">Welcome to {APP_NAME}!</div> className="mt-4 mb-4 mx-4 px-2 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-primary"
>
<div className="text-lg font-light">
Welcome to {APP_NAME}!
</div>
{context?.user && ( {context?.user && (
<div <div
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
setIsUserDropdownOpen(!isUserDropdownOpen); setIsUserDropdownOpen(!isUserDropdownOpen);
}} }}
> >
{context.user.pfpUrl && ( {context.user.pfpUrl && (
<Image <img
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"
/> />
)} )}
@@ -39,16 +42,14 @@ export function Header({ neynarUser }: HeaderProps) {
)} )}
</div> </div>
{context?.user && ( {context?.user && (
<> <>
{isUserDropdownOpen && ( {isUserDropdownOpen && (
<div className="absolute top-full right-0 z-50 w-fit mt-1 mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"> <div className="absolute top-full right-0 z-50 w-fit mt-1 mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
<div className="text-right"> <div className="text-right">
<h3 <h3
className="font-bold text-sm hover:underline cursor-pointer inline-block" className="font-bold text-sm hover:underline cursor-pointer inline-block"
onClick={() => onClick={() => sdk.actions.viewProfile({ fid: context.user.fid })}
sdk.actions.viewProfile({ fid: context.user.fid })
}
> >
{context.user.displayName || context.user.username} {context.user.displayName || context.user.username}
</h3> </h3>

View File

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

View File

@@ -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,20 +1,20 @@
'use client'; 'use client';
import '@farcaster/auth-kit/styles.css'; import '@farcaster/auth-kit/styles.css';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; import { useSignIn, UseSignInData } from '@farcaster/auth-kit';
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; import { useCallback, useEffect, useState, useRef } from 'react';
import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/Button';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { getItem, removeItem, setItem } from '~/lib/localStorage';
import { useMiniApp } from '@neynar/react'; import { 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 { Button } from '~/components/ui/Button'; import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
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;
@@ -102,13 +102,13 @@ export function NeynarAuthButton() {
// New state for unified dialog flow // New state for unified dialog flow
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>(
'loading', 'loading'
); );
const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>( const [signerApprovalUrl, setSignerApprovalUrl] = useState<string | null>(
null, null
); );
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>( const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(
null, null
); );
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(null); const [signature, setSignature] = useState<string | null>(null);
@@ -141,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;
@@ -164,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
@@ -182,7 +182,7 @@ export function NeynarAuthButton() {
return null; return null;
} }
}, },
[], []
); );
// Helper function to generate signed key request // Helper function to generate signed key request
@@ -210,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}`
); );
} }
@@ -222,7 +222,7 @@ export function NeynarAuthButton() {
// throw error; // throw error;
} }
}, },
[], []
); );
// Helper function to fetch all signers // Helper function to fetch all signers
@@ -233,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);
@@ -258,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;
} }
@@ -285,7 +285,7 @@ export function NeynarAuthButton() {
setSignersLoading(false); setSignersLoading(false);
} }
}, },
[useBackendFlow, fetchUserData, updateSessionWithSigners], [useBackendFlow, fetchUserData, updateSessionWithSigners]
); );
// Helper function to poll signer status // Helper function to poll signer status
@@ -311,7 +311,7 @@ export function NeynarAuthButton() {
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) {
@@ -352,7 +352,7 @@ export function NeynarAuthButton() {
setPollingInterval(interval); setPollingInterval(interval);
}, },
[fetchAllSigners, pollingInterval], [fetchAllSigners, pollingInterval]
); );
// Cleanup polling on unmount // Cleanup polling on unmount
@@ -412,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
@@ -495,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
@@ -506,8 +506,8 @@ export function NeynarAuthButton() {
await sdk.actions.openUrl( await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace( signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/signed-key-request', 'https://client.farcaster.xyz/deeplinks/signed-key-request',
'https://farcaster.xyz/~/connect', 'https://farcaster.xyz/~/connect'
), )
); );
} else { } else {
setShowDialog(true); // Ensure dialog is shown during loading setShowDialog(true); // Ensure dialog is shown during loading
@@ -565,10 +565,12 @@ export function NeynarAuthButton() {
} }
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
console.error(' Sign-in rejected by user'); console.log(' Sign-in rejected by user');
} else { } else {
console.error('❌ Backend sign-in error:', e); console.error('❌ Backend sign-in error:', e);
} }
} finally {
setSignersLoading(false);
} }
}, [nonce]); }, [nonce]);
@@ -668,7 +670,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,10 +1,10 @@
'use client'; 'use client';
import { useCallback, useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { type ComposeCast } from '@farcaster/miniapp-sdk';
import { useMiniApp } from '@neynar/react';
import { APP_URL } from '~/lib/constants';
import { Button } from './Button'; import { Button } from './Button';
import { useMiniApp } from '@neynar/react';
import { type ComposeCast } from "@farcaster/miniapp-sdk";
import { APP_URL } from '~/lib/constants';
interface EmbedConfig { interface EmbedConfig {
path?: string; path?: string;
@@ -24,16 +24,9 @@ interface ShareButtonProps {
isLoading?: boolean; isLoading?: boolean;
} }
export function ShareButton({ export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) {
buttonText,
cast,
className = '',
isLoading = false,
}: ShareButtonProps) {
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [bestFriends, setBestFriends] = useState< const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null);
{ fid: number; username: string }[] | null
>(null);
const [isLoadingBestFriends, setIsLoadingBestFriends] = useState(false); const [isLoadingBestFriends, setIsLoadingBestFriends] = useState(false);
const { context, actions } = useMiniApp(); const { context, actions } = useMiniApp();
@@ -59,7 +52,7 @@ export function ShareButton({
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) {
@@ -75,7 +68,7 @@ export function ShareButton({
// 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;
} }
@@ -84,10 +77,7 @@ export function ShareButton({
const url = new URL(`${baseUrl}${embed.path}`); const url = new URL(`${baseUrl}${embed.path}`);
// Add UTM parameters // Add UTM parameters
url.searchParams.set( url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`);
'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) {
@@ -98,7 +88,7 @@ export function ShareButton({
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,21 +1,22 @@
import * as React from 'react'; import * as React from "react"
import { cn } from '~/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>( import { cn } from "~/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ 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,13 +1,14 @@
'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>,
@@ -19,7 +20,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

@@ -0,0 +1,208 @@
'use client';
import { useCallback, useState } from 'react';
import { useMiniApp } from '@neynar/react';
import { ShareButton } from '../Share';
import { Button } from '../Button';
import { SignIn } from '../wallet/SignIn';
import { type Haptics } from '@farcaster/miniapp-sdk';
import { APP_URL } from '~/lib/constants';
import { NeynarAuthButton } from '../NeynarAuthButton';
/**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
*
* This component provides the main interaction interface for users to:
* - Share the mini app with others
* - Sign in with Farcaster
* - Send notifications to their account
* - Trigger haptic feedback
* - Add the mini app to their client
* - Copy share URLs
*
* The component uses the useMiniApp hook to access Farcaster context and actions.
* All state is managed locally within this component.
*
* @example
* ```tsx
* <ActionsTab />
* ```
*/
export function ActionsTab() {
// --- Hooks ---
const { actions, added, notificationDetails, haptics, context } =
useMiniApp();
// --- State ---
const [notificationState, setNotificationState] = useState({
sendStatus: '',
shareUrlCopied: false,
});
const [selectedHapticIntensity, setSelectedHapticIntensity] =
useState<Haptics.ImpactOccurredType>('medium');
// --- Handlers ---
/**
* Sends a notification to the current user's Farcaster account.
*
* This function makes a POST request to the /api/send-notification endpoint
* with the user's FID and notification details. It handles different response
* statuses including success (200), rate limiting (429), and errors.
*
* @returns Promise that resolves when the notification is sent or fails
*/
const sendFarcasterNotification = useCallback(async () => {
setNotificationState((prev) => ({ ...prev, sendStatus: '' }));
if (!notificationDetails || !context) {
return;
}
try {
const response = await fetch('/api/send-notification', {
method: 'POST',
mode: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fid: context.user.fid,
notificationDetails,
}),
});
if (response.status === 200) {
setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' }));
return;
} else if (response.status === 429) {
setNotificationState((prev) => ({
...prev,
sendStatus: 'Rate limited',
}));
return;
}
const responseText = await response.text();
setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${responseText}`,
}));
} catch (error) {
setNotificationState((prev) => ({
...prev,
sendStatus: `Error: ${error}`,
}));
}
}, [context, notificationDetails]);
/**
* Copies the share URL for the current user to the clipboard.
*
* This function generates a share URL using the user's FID and copies it
* to the clipboard. It shows a temporary "Copied!" message for 2 seconds.
*/
const copyUserShareUrl = useCallback(async () => {
if (context?.user?.fid) {
const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl);
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
setTimeout(
() =>
setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })),
2000
);
}
}, [context?.user?.fid]);
/**
* Triggers haptic feedback with the selected intensity.
*
* This function calls the haptics.impactOccurred method with the current
* selectedHapticIntensity setting. It handles errors gracefully by logging them.
*/
const triggerHapticFeedback = useCallback(async () => {
try {
await haptics.impactOccurred(selectedHapticIntensity);
} catch (error) {
console.error('Haptic feedback failed:', error);
}
}, [haptics, selectedHapticIntensity]);
// --- Render ---
return (
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
{/* Share functionality */}
<ShareButton
buttonText="Share Mini App"
cast={{
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
bestFriends: true,
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`],
}}
className="w-full"
/>
{/* Authentication */}
<SignIn />
{/* Neynar Authentication */}
<NeynarAuthButton />
{/* Mini app actions */}
<Button
onClick={() =>
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
}
className="w-full"
>
Open Link
</Button>
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
Add Mini App to Client
</Button>
{/* Notification functionality */}
{notificationState.sendStatus && (
<div className="text-sm w-full">
Send notification result: {notificationState.sendStatus}
</div>
)}
<Button
onClick={sendFarcasterNotification}
disabled={!notificationDetails}
className="w-full"
>
Send notification
</Button>
{/* Share URL copying */}
<Button
onClick={copyUserShareUrl}
disabled={!context?.user?.fid}
className="w-full"
>
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
</Button>
{/* Haptic feedback controls */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Haptic Intensity
</label>
<select
value={selectedHapticIntensity}
onChange={(e) =>
setSelectedHapticIntensity(
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"
>
<option value={'light'}>Light</option>
<option value={'medium'}>Medium</option>
<option value={'heavy'}>Heavy</option>
<option value={'soft'}>Soft</option>
<option value={'rigid'}>Rigid</option>
</select>
<Button onClick={triggerHapticFeedback} className="w-full">
Trigger Haptic Feedback
</Button>
</div>
</div>
);
}

View File

@@ -1,13 +1,12 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { type Haptics } from '@farcaster/miniapp-sdk';
import { useMiniApp } from '@neynar/react'; import { useMiniApp } from '@neynar/react';
import { APP_URL } from '~/lib/constants';
import { Button } from '../Button';
import { NeynarAuthButton } from '../NeynarAuthButton/index';
import { ShareButton } from '../Share'; import { ShareButton } from '../Share';
import { Button } from '../Button';
import { SignIn } from '../wallet/SignIn'; import { SignIn } from '../wallet/SignIn';
import { type Haptics } from '@farcaster/miniapp-sdk';
import { APP_URL } from '~/lib/constants';
/** /**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
@@ -52,7 +51,7 @@ export function ActionsTab() {
* @returns Promise that resolves when the notification is sent or fails * @returns Promise that resolves when the notification is sent or fails
*/ */
const sendFarcasterNotification = useCallback(async () => { const sendFarcasterNotification = useCallback(async () => {
setNotificationState(prev => ({ ...prev, sendStatus: '' })); setNotificationState((prev) => ({ ...prev, sendStatus: '' }));
if (!notificationDetails || !context) { if (!notificationDetails || !context) {
return; return;
} }
@@ -67,22 +66,22 @@ export function ActionsTab() {
}), }),
}); });
if (response.status === 200) { if (response.status === 200) {
setNotificationState(prev => ({ ...prev, sendStatus: 'Success' })); setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' }));
return; return;
} else if (response.status === 429) { } else if (response.status === 429) {
setNotificationState(prev => ({ setNotificationState((prev) => ({
...prev, ...prev,
sendStatus: 'Rate limited', sendStatus: 'Rate limited',
})); }));
return; return;
} }
const responseText = await response.text(); const responseText = await response.text();
setNotificationState(prev => ({ setNotificationState((prev) => ({
...prev, ...prev,
sendStatus: `Error: ${responseText}`, sendStatus: `Error: ${responseText}`,
})); }));
} catch (error) { } catch (error) {
setNotificationState(prev => ({ setNotificationState((prev) => ({
...prev, ...prev,
sendStatus: `Error: ${error}`, sendStatus: `Error: ${error}`,
})); }));
@@ -99,11 +98,11 @@ export function ActionsTab() {
if (context?.user?.fid) { if (context?.user?.fid) {
const userShareUrl = `${APP_URL}/share/${context.user.fid}`; const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl); await navigator.clipboard.writeText(userShareUrl);
setNotificationState(prev => ({ ...prev, shareUrlCopied: true })); setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
setTimeout( setTimeout(
() => () =>
setNotificationState(prev => ({ ...prev, shareUrlCopied: false })), setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })),
2000, 2000
); );
} }
}, [context?.user?.fid]); }, [context?.user?.fid]);
@@ -139,9 +138,6 @@ export function ActionsTab() {
{/* Authentication */} {/* Authentication */}
<SignIn /> <SignIn />
{/* Neynar Authentication */}
<NeynarAuthButton />
{/* Mini app actions */} {/* Mini app actions */}
<Button <Button
onClick={() => onClick={() =>
@@ -186,9 +182,9 @@ export function ActionsTab() {
</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"

View File

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

View File

@@ -1,12 +1,12 @@
'use client'; "use client";
/** /**
* HomeTab component displays the main landing content for the mini app. * HomeTab component displays the main landing content for the mini app.
* *
* This is the default tab that users see when they first open the mini app. * This is the default tab that users see when they first open the mini app.
* It provides a simple welcome message and placeholder content that can be * It provides a simple welcome message and placeholder content that can be
* customized for specific use cases. * customized for specific use cases.
* *
* @example * @example
* ```tsx * ```tsx
* <HomeTab /> * <HomeTab />
@@ -17,10 +17,8 @@ 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"> <p className="text-sm text-gray-500 dark:text-gray-400">Powered by Neynar 🪐</p>
Powered by Neynar 🪐
</p>
</div> </div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,20 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk'; import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import { signIn, signOut, getCsrfToken } from 'next-auth/react'; import { useQuickAuth } from '~/hooks/useQuickAuth';
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 QuickAuth.
* *
* This component provides a complete authentication flow for Farcaster users: * This component provides a complete authentication flow for Farcaster users:
* - Generates nonces for secure authentication * - Uses the built-in QuickAuth functionality from the Farcaster SDK
* - Handles the SIWF flow using the Farcaster SDK * - Manages authentication state in memory (no persistence)
* - Manages NextAuth session state
* - Provides sign-out functionality * - Provides sign-out functionality
* - Displays authentication status and results * - Displays authentication status and results
* *
* The component integrates with both the Farcaster Frame SDK and NextAuth * The component integrates with the Farcaster Frame SDK and QuickAuth
* to provide seamless authentication within mini apps. * to provide seamless authentication within mini apps.
* *
* @example * @example
@@ -36,37 +34,19 @@ export function SignIn() {
signingIn: false, signingIn: false,
signingOut: false, signingOut: false,
}); });
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
const [signInFailure, setSignInFailure] = useState<string>(); const [signInFailure, setSignInFailure] = useState<string>();
// --- Hooks --- // --- Hooks ---
const { data: session, status } = useSession(); const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
// --- Handlers --- // --- Handlers ---
/** /**
* Generates a nonce for the sign-in process. * Handles the sign-in process using QuickAuth.
* *
* This function retrieves a CSRF token from NextAuth to use as a nonce * This function uses the built-in QuickAuth functionality:
* for the SIWF authentication flow. The nonce ensures the authentication * 1. Gets a token from QuickAuth (handles SIWF flow automatically)
* request is fresh and prevents replay attacks. * 2. Validates the token with our server
* * 3. Updates the session state
* @returns Promise<string> - The generated nonce token
* @throws Error if unable to generate nonce
*/
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error('Unable to generate nonce');
return nonce;
}, []);
/**
* Handles the sign-in process using Farcaster SDK.
*
* This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
@@ -74,14 +54,12 @@ export function SignIn() {
try { try {
setAuthState(prev => ({ ...prev, signingIn: true })); setAuthState(prev => ({ ...prev, signingIn: true }));
setSignInFailure(undefined); setSignInFailure(undefined);
const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce }); const success = await signIn();
setSignInResult(result);
await signIn('farcaster', { if (!success) {
message: result.message, setSignInFailure('Authentication failed');
signature: result.signature, }
redirect: false,
});
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure('Rejected by user'); setSignInFailure('Rejected by user');
@@ -91,52 +69,47 @@ export function SignIn() {
} finally { } finally {
setAuthState(prev => ({ ...prev, signingIn: false })); setAuthState(prev => ({ ...prev, signingIn: false }));
} }
}, [getNonce]); }, [signIn]);
/** /**
* Handles the sign-out process. * Handles the sign-out process.
* *
* This function clears the NextAuth session only if the current session * This function clears the QuickAuth session and resets the local state.
* is using the Farcaster provider, and resets the local sign-in result state.
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
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 await signOut();
if (session?.provider === 'farcaster') {
await signOut({ redirect: false });
}
setSignInResult(undefined);
} finally { } finally {
setAuthState(prev => ({ ...prev, signingOut: false })); setAuthState(prev => ({ ...prev, signingOut: false }));
} }
}, [session]); }, [signOut]);
// --- Render --- // --- Render ---
return ( return (
<> <>
{/* Authentication Buttons */} {/* Authentication Buttons */}
{(status !== 'authenticated' || session?.provider !== 'farcaster') && ( {status !== 'authenticated' && (
<Button onClick={handleSignIn} disabled={authState.signingIn}> <Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster Sign In with Farcaster
</Button> </Button>
)} )}
{status === 'authenticated' && session?.provider === 'farcaster' && ( {status === 'authenticated' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}> <Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out Sign out
</Button> </Button>
)} )}
{/* Session Information */} {/* Session Information */}
{session && ( {authenticatedUser && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1"> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Session Authenticated User
</div> </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(authenticatedUser, null, 2)}
</div> </div>
</div> </div>
)} )}
@@ -145,25 +118,13 @@ export function SignIn() {
{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"> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
SIWF Result Authentication Error
</div> </div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{signInFailure} {signInFailure}
</div> </div>
</div> </div>
)} )}
{/* Success Result Display */}
{signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
SIWF Result
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{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 { renderError } from '../../../lib/errorUtils'; import { Button } from "../Button";
import { Button } from '../Button'; import { renderError } from "../../../lib/errorUtils";
interface SignSolanaMessageProps { interface SignSolanaMessageProps {
signMessage?: (message: Uint8Array) => Promise<Uint8Array>; signMessage?: (message: Uint8Array) => Promise<Uint8Array>;
@@ -10,20 +10,20 @@ interface SignSolanaMessageProps {
/** /**
* SignSolanaMessage component handles signing messages on Solana. * SignSolanaMessage component handles signing messages on Solana.
* *
* This component provides a simple interface for users to sign messages using * This component provides a simple interface for users to sign messages using
* their connected Solana wallet. It accepts a signMessage function as a prop * their connected Solana wallet. It accepts a signMessage function as a prop
* and handles the complete signing flow including error handling. * and handles the complete signing flow including error handling.
* *
* Features: * Features:
* - Message signing with Solana wallet * - Message signing with Solana wallet
* - Error handling and display * - Error handling and display
* - Signature result display (base64 encoded) * - Signature result display (base64 encoded)
* - Loading state management * - Loading state management
* *
* @param props - Component props * @param props - Component props
* @param props.signMessage - Function to sign messages with Solana wallet * @param props.signMessage - Function to sign messages with Solana wallet
* *
* @example * @example
* ```tsx * ```tsx
* <SignSolanaMessage signMessage={solanaWallet.signMessage} /> * <SignSolanaMessage signMessage={solanaWallet.signMessage} />
@@ -38,11 +38,11 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
// --- Handlers --- // --- Handlers ---
/** /**
* Handles the Solana message signing process. * Handles the Solana message signing process.
* *
* This function encodes a message as UTF-8 bytes, signs it using the provided * This function encodes a message as UTF-8 bytes, signs it using the provided
* signMessage function, and displays the base64-encoded signature result. * signMessage function, and displays the base64-encoded signature result.
* It includes comprehensive error handling and loading state management. * It includes comprehensive error handling and loading state management.
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
const handleSignMessage = useCallback(async () => { const handleSignMessage = useCallback(async () => {
@@ -51,7 +51,7 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
if (!signMessage) { if (!signMessage) {
throw new Error('no Solana signMessage'); throw new Error('no Solana signMessage');
} }
const input = new TextEncoder().encode('Hello from Solana!'); const input = new TextEncoder().encode("Hello from Solana!");
const signatureBytes = await signMessage(input); const signatureBytes = await signMessage(input);
const signature = btoa(String.fromCharCode(...signatureBytes)); const signature = btoa(String.fromCharCode(...signatureBytes));
setSignature(signature); setSignature(signature);
@@ -84,4 +84,4 @@ export function SignSolanaMessage({ signMessage }: SignSolanaMessageProps) {
)} )}
</> </>
); );
} }

View File

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

View File

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

View File

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

207
src/hooks/useQuickAuth.ts Normal file
View File

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

View File

@@ -1,4 +1,4 @@
import { type AccountAssociation } from '@farcaster/miniapp-node'; import { type AccountAssociation } from '@farcaster/miniapp-core/src/manifest';
/** /**
* Application constants and configuration values. * Application constants and configuration values.
@@ -80,7 +80,7 @@ export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
* 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: string = 'Launch NSK'; export const APP_BUTTON_TEXT: string = 'Launch Mini App';
// --- Integration Configuration --- // --- Integration Configuration ---
/** /**
@@ -102,7 +102,7 @@ export const APP_WEBHOOK_URL: string =
* 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: boolean = true; export const USE_WALLET: boolean = false;
/** /**
* Flag to enable/disable analytics tracking. * Flag to enable/disable analytics tracking.
@@ -113,6 +113,26 @@ export const USE_WALLET: boolean = true;
*/ */
export const ANALYTICS_ENABLED: boolean = true; export const ANALYTICS_ENABLED: boolean = true;
/**
* Required chains for the mini app.
*
* Contains an array of CAIP-2 identifiers for blockchains that the mini app requires.
* If the host does not support all chains listed here, it will not render the mini app.
* If empty or undefined, the mini app will be rendered regardless of chain support.
*
* Supported chains: eip155:1, eip155:137, eip155:42161, eip155:10, eip155:8453,
* solana:mainnet, solana:devnet
*/
export const APP_REQUIRED_CHAINS: string[] = [];
/**
* Return URL for the mini app.
*
* If provided, the mini app will be rendered with a return URL to be rendered if the
* back button is pressed from the home page.
*/
export const RETURN_URL: string | undefined = undefined;
// 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 = {
name: 'Farcaster SignedKeyRequestValidator', name: 'Farcaster SignedKeyRequestValidator',

View File

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

View File

@@ -1,9 +1,9 @@
import { FrameNotificationDetails } from '@farcaster/miniapp-sdk'; import { MiniAppNotificationDetails } 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, MiniAppNotificationDetails>();
// 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;
@@ -19,18 +19,18 @@ function getUserNotificationDetailsKey(fid: number): string {
} }
export async function getUserNotificationDetails( export async function getUserNotificationDetails(
fid: number, fid: number
): Promise<FrameNotificationDetails | null> { ): Promise<MiniAppNotificationDetails | null> {
const key = getUserNotificationDetailsKey(fid); const key = getUserNotificationDetailsKey(fid);
if (redis) { if (redis) {
return await redis.get<FrameNotificationDetails>(key); return await redis.get<MiniAppNotificationDetails>(key);
} }
return localStore.get(key) || null; return localStore.get(key) || null;
} }
export async function setUserNotificationDetails( export async function setUserNotificationDetails(
fid: number, fid: number,
notificationDetails: FrameNotificationDetails, notificationDetails: MiniAppNotificationDetails
): Promise<void> { ): Promise<void> {
const key = getUserNotificationDetailsKey(fid); const key = getUserNotificationDetailsKey(fid);
if (redis) { if (redis) {
@@ -41,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,15 +1,11 @@
import { import { NeynarAPIClient, Configuration, WebhookUserCreated } from '@neynar/nodejs-sdk';
NeynarAPIClient,
Configuration,
WebhookUserCreated,
} from '@neynar/nodejs-sdk';
import { APP_URL } from './constants'; import { APP_URL } from './constants';
let neynarClient: NeynarAPIClient | null = null; let neynarClient: NeynarAPIClient | null = null;
// Example usage: // Example usage:
// const client = getNeynarClient(); // const client = getNeynarClient();
// const user = await client.lookupUserByFid(fid); // const user = await client.lookupUserByFid(fid);
export function getNeynarClient() { export function getNeynarClient() {
if (!neynarClient) { if (!neynarClient) {
const apiKey = process.env.NEYNAR_API_KEY; const apiKey = process.env.NEYNAR_API_KEY;
@@ -37,12 +33,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,
@@ -62,19 +58,19 @@ export async function sendNeynarMiniAppNotification({
target_url: APP_URL, target_url: APP_URL,
}; };
const result = await client.publishFrameNotifications({ const result = await client.publishFrameNotifications({
targetFids, targetFids,
notification, notification
}); });
if (result.notification_deliveries.length > 0) { if (result.notification_deliveries.length > 0) {
return { state: 'success' }; return { state: "success" };
} else if (result.notification_deliveries.length === 0) { } else if (result.notification_deliveries.length === 0) {
return { state: 'no_token' }; return { state: "no_token" };
} else { } else {
return { state: 'error', error: result || 'Unknown error' }; return { state: "error", error: result || "Unknown error" };
} }
} catch (error) { } catch (error) {
return { state: 'error', error }; return { state: "error", error };
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { type Manifest } from '@farcaster/miniapp-node';
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Manifest } from '@farcaster/miniapp-core/src/manifest';
import { import {
APP_BUTTON_TEXT, APP_BUTTON_TEXT,
APP_DESCRIPTION, APP_DESCRIPTION,
@@ -24,6 +24,9 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
return { return {
version: 'next', version: 'next',
imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL, imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
ogTitle: APP_NAME,
ogDescription: APP_DESCRIPTION,
ogImageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
button: { button: {
title: APP_BUTTON_TEXT, title: APP_BUTTON_TEXT,
action: { action: {
@@ -43,20 +46,17 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
export async function getFarcasterDomainManifest(): Promise<Manifest> { export async function getFarcasterDomainManifest(): Promise<Manifest> {
return { return {
accountAssociation: APP_ACCOUNT_ASSOCIATION, accountAssociation: APP_ACCOUNT_ASSOCIATION!,
miniapp: { miniapp: {
version: '1', version: '1',
name: APP_NAME ?? 'Neynar Starter Kit', name: APP_NAME ?? 'Neynar Starter Kit',
iconUrl: APP_ICON_URL,
homeUrl: APP_URL, homeUrl: APP_URL,
iconUrl: APP_ICON_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,
description: APP_DESCRIPTION,
primaryCategory: APP_PRIMARY_CATEGORY,
tags: APP_TAGS,
}, },
}; };
} }

60
tailwind.config.ts Normal file
View File

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

View File

@@ -22,6 +22,13 @@
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
}, },
"ts-node": {
"esm": true,
"compilerOptions": {
"module": "ES2020",
"moduleResolution": "node"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

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