Merge pull request #15 from neynarxyz/shreyas-formatting

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

29
.editorconfig Normal file
View File

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

95
.eslintignore Normal file
View File

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

View File

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

View File

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

22
.gitignore vendored
View File

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

4
.lintstagedrc Normal file
View File

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

204
.prettierignore Normal file
View File

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

17
.prettierrc Normal file
View File

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

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

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

View File

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

View File

@ -15,48 +15,48 @@ if (yIndex !== -1) {
args.splice(yIndex, 1); // Remove -y from args
}
// Parse other arguments
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) {
projectName = args[i + 1];
if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)');
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');
// Parse other arguments
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) {
projectName = args[i + 1];
if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)');
process.exit(1);
}
} else if (arg === '-k' || arg === '--api-key') {
if (i + 1 < args.length) {
apiKey = args[i + 1];
if (apiKey.startsWith('-')) {
console.error('Error: API key cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -k/--api-key requires an API key');
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -p/--project requires a project name');
process.exit(1);
}
} else if (arg === '-k' || arg === '--api-key') {
if (i + 1 < args.length) {
apiKey = args[i + 1];
if (apiKey.startsWith('-')) {
console.error('Error: API key cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -k/--api-key requires an API key');
process.exit(1);
}
}
}
// Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) {
console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.');
console.error(
'Error: -y flag requires a project name. Use -p/--project to specify the project name.',
);
process.exit(1);
}
init(projectName, autoAcceptDefaults, apiKey).catch((err) => {
init(projectName, autoAcceptDefaults, apiKey).catch(err => {
console.error('Error:', err);
process.exit(1);
});

View File

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

View File

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

2
index.d.ts vendored
View File

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

View File

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

4978
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,25 +34,25 @@ interface UseQuickAuthReturn {
/**
* 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>
@ -63,17 +63,20 @@ interface UseQuickAuthReturn {
*/
export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(null);
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> => {
const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
try {
const validationResponse = await fetch('/api/auth/validate', {
method: 'POST',
@ -85,7 +88,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
const responseData = await validationResponse.json();
return responseData.user;
}
return null;
} catch (error) {
console.error('Token validation failed:', error);
@ -102,11 +105,11 @@ export function useQuickAuth(): UseQuickAuthReturn {
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);
@ -130,24 +133,24 @@ export function useQuickAuth(): UseQuickAuthReturn {
/**
* 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);
@ -155,7 +158,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
return true;
}
}
// Authentication failed, clear user state
setStatus('unauthenticated');
return false;
@ -168,7 +171,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
/**
* 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.
@ -181,7 +184,7 @@ export function useQuickAuth(): UseQuickAuthReturn {
/**
* 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> => {
@ -201,4 +204,4 @@ export function useQuickAuth(): UseQuickAuthReturn {
signOut,
getToken,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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