From b5f8bb7a50d8a4658c22805bcb0fee3c1fe5c603 Mon Sep 17 00:00:00 2001 From: hellno Date: Mon, 14 Jul 2025 21:25:25 +0200 Subject: [PATCH 01/12] feat: add unified onchain user search component - Transform profile-search into onchain-user-search with three-way resolution - Support searching by Farcaster username, ENS name, or Ethereum address - Add ENS forward/reverse resolution with address as primary identifier - Implement Neynar bulk-by-address API for address lookups - Create address-utils library for validation and formatting - Add customizable onUserClick handler for flexible integration --- app/component/onchain-user-search/page.tsx | 63 +++ lib/components-config.tsx | 8 +- public/r/address-utils.json | 18 + public/r/all-components.json | 2 +- public/r/chains.json | 2 +- public/r/nft-card.json | 2 +- public/r/nft-mint-flow.json | 2 +- public/r/onchain-user-search.json | 94 ++++ public/r/profile-search.json | 72 --- public/r/registry.json | 33 +- registry.json | 33 +- .../onchain-user-search.tsx} | 414 +++++++++++++++--- .../simulationHelper.tsx | 30 +- registry/mini-app/lib/address-utils.ts | 71 +++ registry/mini-app/lib/chains.ts | 13 + 15 files changed, 697 insertions(+), 160 deletions(-) create mode 100644 app/component/onchain-user-search/page.tsx create mode 100644 public/r/address-utils.json create mode 100644 public/r/onchain-user-search.json delete mode 100644 public/r/profile-search.json rename registry/mini-app/blocks/{profile-search/profile-search.tsx => onchain-user-search/onchain-user-search.tsx} (50%) rename registry/mini-app/blocks/{profile-search => onchain-user-search}/simulationHelper.tsx (79%) create mode 100644 registry/mini-app/lib/address-utils.ts diff --git a/app/component/onchain-user-search/page.tsx b/app/component/onchain-user-search/page.tsx new file mode 100644 index 00000000..592ec2bf --- /dev/null +++ b/app/component/onchain-user-search/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { OnchainUserSearch } from "@/registry/mini-app/blocks/onchain-user-search/onchain-user-search"; + +export default function OnchainUserSearchDemo() { + const neynarApiKey = process.env.NEXT_PUBLIC_NEYNAR_API_KEY || ""; + + return ( +
+
+
+

Onchain User Search

+

+ Search for users by Farcaster username, ENS name, or Ethereum + address. All results are unified with the onchain address as the + primary identifier. +

+
+ +
+

Try searching for:

+
    +
  • Farcaster username: "vitalik", "dwr"
  • +
  • ENS name: "vitalik.eth", "nick.eth"
  • +
  • + Ethereum address: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +
  • +
+
+ + { + // Example: Navigate to Farcaster profile + if (user.farcaster) { + window.open(`https://farcaster.xyz/${user.farcaster.username}`, "_blank"); + } else if (user.ensName) { + window.open(`https://app.ens.domains/${user.ensName}`, "_blank"); + } else { + window.open(`https://etherscan.io/address/${user.primaryAddress}`, "_blank"); + } + }} + /> + +
+

Features:

+
    +
  • Auto-detects input type (username, ENS, or address)
  • +
  • Resolves ENS names to addresses and vice versa
  • +
  • Finds Farcaster accounts associated with addresses
  • +
  • Shows all identities in unified cards
  • +
  • Supports pagination for username searches
  • +
+
+
+
+ ); +} diff --git a/lib/components-config.tsx b/lib/components-config.tsx index 89d00d65..97675af3 100644 --- a/lib/components-config.tsx +++ b/lib/components-config.tsx @@ -5,7 +5,7 @@ import { ShowCoinBalance } from "@/registry/mini-app/blocks/show-coin-balance/sh import { UserAvatar } from "@/registry/mini-app/blocks/avatar/avatar"; import { UserContext } from "@/registry/mini-app/blocks/user-context/user-context"; import * as React from "react"; -import { ProfileSearchSimulationDemo } from "@/registry/mini-app/blocks/profile-search/simulationHelper"; +import { OnchainUserSearchSimulationDemo } from "@/registry/mini-app/blocks/onchain-user-search/simulationHelper"; import { NFTMintExamples } from "@/components/nft-mint-examples"; import { NftCardExamples } from "@/components/nft-card-examples"; import { NFTShowcaseDemo } from "@/components/nft-showcase-demo"; @@ -118,13 +118,13 @@ export const componentGroups: ComponentGroup[] = [ installName: "user-context", }, { - title: "Profile Search", + title: "Onchain User Search", component: (
- +
), - installName: "profile-search", + installName: "onchain-user-search", }, ], }, diff --git a/public/r/address-utils.json b/public/r/address-utils.json new file mode 100644 index 00000000..8ab4d418 --- /dev/null +++ b/public/r/address-utils.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "address-utils", + "type": "registry:lib", + "title": "addressUtils", + "description": "Utility functions for Ethereum address formatting, validation, and input type detection", + "dependencies": [ + "viem" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/mini-app/lib/address-utils.ts", + "content": "import { isAddress, getAddress } from \"viem\";\n\n/**\n * Formats an Ethereum address to show first and last few characters\n * @param address - The address to format\n * @param chars - Number of characters to show at start and end (default: 4)\n * @returns Formatted address like \"0x1234...5678\"\n */\nexport function formatAddress(address: string, chars = 4): string {\n if (!address || address.length < chars * 2 + 2) return address;\n return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;\n}\n\n/**\n * Detects the type of input (address, ENS name, or username)\n * @param input - The search input\n * @returns The detected input type\n */\nexport type InputType = \"address\" | \"ens\" | \"username\";\n\nexport function detectInputType(input: string): InputType {\n if (!input || input.trim().length === 0) return \"username\";\n \n const trimmed = input.trim();\n \n // Check if it's a valid Ethereum address\n if (isAddress(trimmed)) {\n return \"address\";\n }\n \n // Check if it's an ENS name (contains . and not already an address)\n if (trimmed.includes(\".\") && trimmed.length > 3) {\n // Common ENS TLDs\n const ensPattern = /\\.(eth|xyz|luxe|kred|art|club|test)$/i;\n if (ensPattern.test(trimmed)) {\n return \"ens\";\n }\n }\n \n // Default to username (Farcaster username or FID)\n return \"username\";\n}\n\n/**\n * Validates and normalizes an Ethereum address\n * @param address - The address to validate\n * @returns The checksummed address or null if invalid\n */\nexport function normalizeAddress(address: string): string | null {\n try {\n if (!isAddress(address)) return null;\n return getAddress(address);\n } catch {\n return null;\n }\n}\n\n/**\n * Checks if two addresses are equal (case-insensitive)\n * @param addr1 - First address\n * @param addr2 - Second address\n * @returns True if addresses are equal\n */\nexport function addressesEqual(addr1: string | null | undefined, addr2: string | null | undefined): boolean {\n if (!addr1 || !addr2) return false;\n try {\n return getAddress(addr1).toLowerCase() === getAddress(addr2).toLowerCase();\n } catch {\n return false;\n }\n}", + "type": "registry:lib" + } + ] +} \ No newline at end of file diff --git a/public/r/all-components.json b/public/r/all-components.json index 3eb915e2..de4e428f 100644 --- a/public/r/all-components.json +++ b/public/r/all-components.json @@ -21,7 +21,7 @@ "https://hellno-mini-app-ui.vercel.app/r/avatar.json", "https://hellno-mini-app-ui.vercel.app/r/user-context.json", "https://hellno-mini-app-ui.vercel.app/r/nft-card.json", - "https://hellno-mini-app-ui.vercel.app/r/profile-search.json", + "https://hellno-mini-app-ui.vercel.app/r/onchain-user-search.json", "https://hellno-mini-app-ui.vercel.app/r/nft-mint-flow.json" ], "files": [] diff --git a/public/r/chains.json b/public/r/chains.json index 9fec230e..a8b33621 100644 --- a/public/r/chains.json +++ b/public/r/chains.json @@ -11,7 +11,7 @@ "files": [ { "path": "registry/mini-app/lib/chains.ts", - "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n", + "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n\n/**\n * Get Alchemy RPC endpoint URL for a specific chain\n */\nexport function getAlchemyEndpoint(chainId: number, apiKey: string): string | undefined {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n \n if (config?.alchemyPrefix && apiKey) {\n return `https://${config.alchemyPrefix}.g.alchemy.com/v2/${apiKey}`;\n }\n \n return undefined;\n}\n", "type": "registry:lib" } ] diff --git a/public/r/nft-card.json b/public/r/nft-card.json index 79dc63cb..ce564a79 100644 --- a/public/r/nft-card.json +++ b/public/r/nft-card.json @@ -29,7 +29,7 @@ }, { "path": "registry/mini-app/lib/chains.ts", - "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n", + "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n\n/**\n * Get Alchemy RPC endpoint URL for a specific chain\n */\nexport function getAlchemyEndpoint(chainId: number, apiKey: string): string | undefined {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n \n if (config?.alchemyPrefix && apiKey) {\n return `https://${config.alchemyPrefix}.g.alchemy.com/v2/${apiKey}`;\n }\n \n return undefined;\n}\n", "type": "registry:lib", "target": "" }, diff --git a/public/r/nft-mint-flow.json b/public/r/nft-mint-flow.json index ecadc6e7..4c45f5b8 100644 --- a/public/r/nft-mint-flow.json +++ b/public/r/nft-mint-flow.json @@ -80,7 +80,7 @@ }, { "path": "registry/mini-app/lib/chains.ts", - "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n", + "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n\n/**\n * Get Alchemy RPC endpoint URL for a specific chain\n */\nexport function getAlchemyEndpoint(chainId: number, apiKey: string): string | undefined {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n \n if (config?.alchemyPrefix && apiKey) {\n return `https://${config.alchemyPrefix}.g.alchemy.com/v2/${apiKey}`;\n }\n \n return undefined;\n}\n", "type": "registry:lib", "target": "" }, diff --git a/public/r/onchain-user-search.json b/public/r/onchain-user-search.json new file mode 100644 index 00000000..527bf65c --- /dev/null +++ b/public/r/onchain-user-search.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "onchain-user-search", + "type": "registry:component", + "title": "Onchain User Search", + "description": "Unified search for team members and friends across Farcaster usernames, ENS names, and Ethereum addresses. Resolves in all three directions with onchain address as primary interface.", + "dependencies": [ + "lucide-react", + "viem", + "@radix-ui/react-slot", + "class-variance-authority", + "clsx", + "tailwind-merge", + "@farcaster/frame-sdk", + "@farcaster/frame-core" + ], + "registryDependencies": [ + "button", + "input", + "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "https://hellno-mini-app-ui.vercel.app/r/utils.json", + "https://hellno-mini-app-ui.vercel.app/r/chains.json", + "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", + "https://hellno-mini-app-ui.vercel.app/r/address-utils.json" + ], + "files": [ + { + "path": "registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@/registry/mini-app/ui/button\";\nimport { Input } from \"@/registry/mini-app/ui/input\";\nimport { useMiniAppSdk } from \"@/registry/mini-app/hooks/use-miniapp-sdk\";\nimport { Search, User, Users, X, AtSign, Wallet } from \"lucide-react\";\nimport { formatLargeNumber } from \"@/registry/mini-app/lib/text-utils\";\nimport { cn } from \"@/registry/mini-app/lib/utils\";\nimport {\n detectInputType,\n formatAddress,\n normalizeAddress,\n addressesEqual,\n} from \"@/registry/mini-app/lib/address-utils\";\nimport { createPublicClient, http } from \"viem\";\nimport { mainnet } from \"viem/chains\";\nimport { getAlchemyEndpoint } from \"@/registry/mini-app/lib/chains\";\n\n// Types based on Neynar API response\nexport type FarcasterUser = {\n fid: number;\n username: string;\n display_name: string;\n pfp_url: string;\n follower_count: number;\n following_count: number;\n power_badge?: boolean;\n profile?: {\n bio?: {\n text?: string;\n };\n };\n verified_addresses?: {\n eth_addresses?: string[];\n };\n};\n\nexport type NeynarSearchResponse = {\n result: {\n users: FarcasterUser[];\n next?: {\n cursor: string;\n };\n };\n};\n\nexport type NeynarBulkAddressResponse = {\n \"0x...\": FarcasterUser[];\n};\n\n// Unified user type that combines all identities\nexport type UnifiedUser = {\n // Primary identifier is the onchain address\n primaryAddress: string;\n // ENS name if available\n ensName?: string;\n // Farcaster profile if available\n farcaster?: FarcasterUser;\n // Additional addresses associated with this user\n addresses: string[];\n // Source of the result\n source: \"farcaster\" | \"ens\" | \"address\";\n};\n\ntype OnchainUserSearchProps = {\n apiKey: string;\n alchemyApiKey?: string;\n placeholder?: string;\n variant?: \"destructive\" | \"secondary\" | \"ghost\" | \"default\";\n className?: string;\n inputClassName?: string;\n buttonClassName?: string;\n layout?: \"horizontal\" | \"vertical\";\n showIcon?: boolean;\n autoSearch?: boolean;\n maxResults?: number;\n searchFunction?: (\n query: string,\n apiKey: string,\n maxResults: number,\n cursor?: string,\n ) => Promise<{ users: UnifiedUser[]; nextCursor?: string }>;\n userCardComponent?: React.ComponentType;\n onError?: (error: string) => void;\n onUserClick?: (user: UnifiedUser) => void;\n showAddresses?: boolean;\n showENS?: boolean;\n};\n\nexport const calculateRelevanceScore = (\n user: FarcasterUser,\n query: string,\n): number => {\n const lowerQuery = query.toLowerCase();\n const username = user.username.toLowerCase();\n const displayName = user.display_name.toLowerCase();\n\n let score = 0;\n\n // Exact matches get highest score\n if (username === lowerQuery || username === `${lowerQuery}.eth`)\n score += 1000;\n else if (username.startsWith(lowerQuery)) score += 600;\n else if (username.includes(lowerQuery)) score += 500;\n\n // make these else ifs:\n if (displayName === lowerQuery) score += 500;\n else if (displayName.startsWith(lowerQuery)) score += 500;\n else if (displayName.includes(lowerQuery)) score += 400;\n\n // FID match\n if (user.fid.toString() === query) score += 950;\n\n // Bonus for shorter usernames (more relevant for short queries)\n if (username.includes(lowerQuery)) {\n score += Math.max(0, 100 - username.length);\n }\n\n if (user.verified_addresses?.eth_addresses?.length) score += 30;\n\n // Bonus for follower count (logarithmic to avoid overwhelming)\n score += Math.log(user.follower_count + 1) * 10;\n\n return score;\n};\n\nexport function OnchainUserSearch({\n apiKey,\n alchemyApiKey,\n onUserClick,\n placeholder = \"Search by username, ENS, or address...\",\n variant = \"default\",\n className,\n inputClassName,\n buttonClassName,\n layout = \"horizontal\",\n showIcon = true,\n autoSearch = false,\n maxResults = 5,\n searchFunction,\n userCardComponent: CustomUserCard,\n onError,\n showAddresses = true,\n showENS = true,\n}: OnchainUserSearchProps) {\n const [searchInput, setSearchInput] = React.useState(\"\");\n const [loading, setLoading] = React.useState(false);\n const [error, setError] = React.useState(\"\");\n const [searchResults, setSearchResults] = React.useState([]);\n const [nextCursor, setNextCursor] = React.useState();\n const [isLoadingMore, setIsLoadingMore] = React.useState(false);\n const debounceRef = React.useRef(undefined);\n\n const { sdk, isSDKLoaded, isMiniApp } = useMiniAppSdk();\n // Create viem client for ENS resolution\n const publicClient = React.useMemo(() => {\n const alchemyKey = alchemyApiKey || process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const rpcUrl = alchemyKey ? getAlchemyEndpoint(1, alchemyKey) : undefined;\n\n return createPublicClient({\n chain: mainnet,\n transport: http(rpcUrl),\n });\n }, [alchemyApiKey]);\n\n // Search for Farcaster users by username\n const searchFarcasterUsers = async (\n query: string,\n cursor?: string,\n ): Promise<{ users: FarcasterUser[]; nextCursor?: string }> => {\n let url = `https://api.neynar.com/v2/farcaster/user/search?q=${encodeURIComponent(query)}&limit=${maxResults}`;\n if (cursor) {\n url += `&cursor=${encodeURIComponent(cursor)}`;\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n accept: \"application/json\",\n api_key: apiKey,\n },\n });\n\n if (!response.ok) {\n const errorData = await response.json();\n throw new Error(errorData.message || `API Error: ${response.status}`);\n }\n\n const data: NeynarSearchResponse = await response.json();\n\n // Sort by relevance if multiple results (only for first page)\n const users = !cursor\n ? (data.result.users || [])\n .map((user) => ({\n user,\n score: calculateRelevanceScore(user, query),\n }))\n .sort((a, b) => b.score - a.score)\n .map((item) => item.user)\n : data.result.users || [];\n\n return {\n users,\n nextCursor: data.result.next?.cursor,\n };\n };\n\n // Search for Farcaster users by address\n const searchFarcasterByAddress = async (\n address: string,\n ): Promise => {\n const url = `https://api.neynar.com/v2/farcaster/user/bulk-by-address?addresses=${encodeURIComponent(address)}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n accept: \"application/json\",\n api_key: apiKey,\n },\n });\n\n if (!response.ok) {\n // If 404, it means no users found for this address\n if (response.status === 404) {\n return [];\n }\n const errorData = await response.json();\n throw new Error(errorData.message || `API Error: ${response.status}`);\n }\n\n const data: NeynarBulkAddressResponse = await response.json();\n\n // The response is an object with addresses as keys\n const users: FarcasterUser[] = [];\n for (const [addr, userList] of Object.entries(data)) {\n if (addressesEqual(addr, address) && Array.isArray(userList)) {\n users.push(...userList);\n }\n }\n\n return users;\n };\n\n // Resolve ENS name to address\n const resolveENSToAddress = async (\n ensName: string,\n ): Promise => {\n try {\n const address = await publicClient.getEnsAddress({\n name: ensName,\n });\n return address || null;\n } catch (error) {\n return null;\n }\n };\n\n // Reverse resolve address to ENS name\n const resolveAddressToENS = async (\n address: string,\n ): Promise => {\n try {\n const ensName = await publicClient.getEnsName({\n address: address as `0x${string}`,\n });\n\n // Verify the reverse resolution\n if (ensName) {\n const verifyAddress = await publicClient.getEnsAddress({\n name: ensName,\n });\n if (!addressesEqual(verifyAddress, address)) {\n return null;\n }\n }\n\n return ensName || null;\n } catch (error) {\n return null;\n }\n };\n\n // Default unified search function\n const defaultSearchFunction = async (\n query: string,\n apiKey: string,\n maxResults: number,\n cursor?: string,\n ): Promise<{\n users: UnifiedUser[];\n nextCursor?: string;\n }> => {\n const inputType = detectInputType(query);\n const results: UnifiedUser[] = [];\n\n if (inputType === \"address\") {\n // For addresses, do parallel lookups\n const normalizedAddr = normalizeAddress(query);\n if (!normalizedAddr) {\n throw new Error(\"Invalid Ethereum address\");\n }\n\n // Parallel lookups for address\n const [ensName, farcasterUsers] = await Promise.all([\n showENS ? resolveAddressToENS(normalizedAddr) : Promise.resolve(null),\n searchFarcasterByAddress(normalizedAddr),\n ]);\n\n if (farcasterUsers.length > 0) {\n // Group by primary address if multiple Farcaster accounts\n for (const fcUser of farcasterUsers) {\n const addresses = fcUser.verified_addresses?.eth_addresses || [];\n results.push({\n primaryAddress: normalizedAddr,\n ensName,\n farcaster: fcUser,\n addresses: addresses\n .filter((addr) => !addressesEqual(addr, normalizedAddr))\n .concat(normalizedAddr),\n source: \"address\",\n });\n }\n } else {\n // No Farcaster account, just show address info\n results.push({\n primaryAddress: normalizedAddr,\n ensName,\n addresses: [normalizedAddr],\n source: \"address\",\n });\n }\n } else if (inputType === \"ens\") {\n // For ENS names, resolve to address first\n const address = await resolveENSToAddress(query);\n if (!address) {\n throw new Error(\"ENS name could not be resolved\");\n }\n\n // Then search for Farcaster accounts\n const farcasterUsers = await searchFarcasterByAddress(address);\n\n if (farcasterUsers.length > 0) {\n for (const fcUser of farcasterUsers) {\n const addresses = fcUser.verified_addresses?.eth_addresses || [];\n results.push({\n primaryAddress: address,\n ensName: query,\n farcaster: fcUser,\n addresses: addresses\n .filter((addr) => !addressesEqual(addr, address))\n .concat(address),\n source: \"ens\",\n });\n }\n } else {\n // No Farcaster account\n results.push({\n primaryAddress: address,\n ensName: query,\n addresses: [address],\n source: \"ens\",\n });\n }\n } else {\n // Username search\n const { users: farcasterUsers, nextCursor } = await searchFarcasterUsers(\n query,\n cursor,\n );\n\n // Convert to unified format\n for (const fcUser of farcasterUsers) {\n const primaryAddr = fcUser.verified_addresses?.eth_addresses?.[0];\n if (!primaryAddr) {\n // Skip users without verified addresses\n continue;\n }\n\n // Look up ENS for primary address if enabled\n const ensName = showENS ? await resolveAddressToENS(primaryAddr) : null;\n\n results.push({\n primaryAddress: primaryAddr,\n ensName,\n farcaster: fcUser,\n addresses: fcUser.verified_addresses?.eth_addresses || [],\n source: \"farcaster\",\n });\n }\n\n return {\n users: results,\n nextCursor,\n };\n }\n\n return {\n users: results,\n nextCursor: undefined,\n };\n };\n\n const searchUsers = async (query: string, loadMore = false) => {\n if (!searchFunction && !apiKey.trim()) {\n const errorMsg = \"API key is required\";\n setError(errorMsg);\n onError?.(errorMsg);\n return;\n }\n\n if (!query.trim()) {\n const errorMsg = \"Please enter a search term\";\n setError(errorMsg);\n onError?.(errorMsg);\n return;\n }\n\n try {\n if (loadMore) {\n setIsLoadingMore(true);\n } else {\n setLoading(true);\n setSearchResults([]);\n setNextCursor(undefined);\n }\n setError(\"\");\n\n const searchFn = searchFunction || defaultSearchFunction;\n const cursor = loadMore ? nextCursor : undefined;\n\n const { users, nextCursor: newCursor } = await searchFn(\n query,\n apiKey,\n maxResults,\n cursor,\n );\n\n if (loadMore) {\n setSearchResults((prev) => [...prev, ...users]);\n } else {\n setSearchResults(users);\n }\n\n setNextCursor(newCursor);\n\n if (!loadMore && users.length === 0) {\n const errorMsg = \"No users found matching your search\";\n setError(errorMsg);\n onError?.(errorMsg);\n }\n } catch (err) {\n const errorMsg =\n err instanceof Error ? err.message : \"Failed to search users\";\n setError(errorMsg);\n if (!loadMore) {\n setSearchResults([]);\n setNextCursor(undefined);\n }\n onError?.(errorMsg);\n } finally {\n setLoading(false);\n setIsLoadingMore(false);\n }\n };\n\n const viewProfile = async (user: UnifiedUser) => {\n try {\n if (user.farcaster && isMiniApp) {\n await sdk.actions.viewProfile({ fid: user.farcaster.fid });\n } else if (user.farcaster) {\n window.open(\n `https://farcaster.xyz/${user.farcaster.username}`,\n \"_blank\",\n );\n } else if (user.ensName) {\n window.open(`https://app.ens.domains/${user.ensName}`, \"_blank\");\n } else {\n window.open(\n `https://etherscan.io/address/${user.primaryAddress}`,\n \"_blank\",\n );\n }\n } catch (err) {\n const errorMsg =\n err instanceof Error ? err.message : \"Failed to view profile\";\n setError(errorMsg);\n onError?.(errorMsg);\n }\n };\n\n const handleSearch = async () => {\n await searchUsers(searchInput);\n };\n\n const handleClear = () => {\n setSearchInput(\"\");\n setSearchResults([]);\n setNextCursor(undefined);\n setError(\"\");\n };\n\n const handleKeyPress = (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n handleSearch();\n }\n };\n\n const handleInputChange = (e: React.ChangeEvent) => {\n const value = e.target.value;\n\n setSearchInput(value);\n setError(\"\");\n\n // Clear previous timeout\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n\n if (value === \"\") {\n setSearchResults([]);\n setNextCursor(undefined);\n return;\n }\n\n // Auto-search with debounce\n if (autoSearch && value.length > 2) {\n debounceRef.current = setTimeout(() => {\n searchUsers(value);\n }, 500);\n }\n };\n\n const containerClasses = cn(\"flex flex-col gap-4 w-full\", className);\n\n const searchContainerClasses = cn(\n \"flex gap-2 w-full\",\n layout === \"vertical\" ? \"flex-col\" : \"flex-row\",\n );\n\n const hasMoreResults = !!nextCursor;\n\n return (\n
\n {/* Search Input */}\n
\n
\n \n {searchInput && (\n \n \n \n )}\n
\n\n {/* Clear Button - only show when there's a search term */}\n \n {loading ? (\n <>\n
\n Searching...\n \n ) : (\n <>\n {showIcon && }\n Search\n \n )}\n \n
\n\n {/* Error Message */}\n {error && (\n
\n {error}\n
\n )}\n\n {/* Search Results */}\n {searchResults.length > 0 && (\n
\n
\n
\n \n Showing {searchResults.length} user\n {searchResults.length !== 1 ? \"s\" : \"\"}\n
\n
\n\n
\n {searchResults.map((user) => {\n const UserCardComponent = CustomUserCard || UserCard;\n return (\n {\n if (onUserClick) {\n onUserClick(user);\n } else {\n viewProfile(user);\n }\n }}\n showAddresses={showAddresses}\n showENS={showENS}\n />\n );\n })}\n
\n\n {hasMoreResults && (\n
\n searchUsers(searchInput, true)}\n disabled={isLoadingMore || loading}\n className=\"w-full sm:w-auto\"\n >\n {isLoadingMore ? (\n <>\n
\n Loading more...\n \n ) : (\n <>Load More\n )}\n \n
\n )}\n
\n )}\n
\n );\n}\n\n// User Card Component\ntype UserCardProps = {\n user: UnifiedUser;\n onClick: () => void;\n showAddresses?: boolean;\n showENS?: boolean;\n};\n\nfunction UserCard({\n user,\n onClick,\n showAddresses = true,\n showENS = true,\n}: UserCardProps) {\n const hasFarcaster = !!user.farcaster;\n const displayName =\n user.farcaster?.display_name ||\n user.ensName ||\n formatAddress(user.primaryAddress);\n const username = user.farcaster?.username;\n const pfpUrl = user.farcaster?.pfp_url;\n\n return (\n \n {/* Top row with avatar, name, and identifiers */}\n
\n {/* Avatar */}\n
\n {pfpUrl ? (\n \n ) : (\n
\n {hasFarcaster ? (\n \n ) : (\n \n )}\n
\n )}\n
\n\n {/* Name and identifiers */}\n
\n
\n

\n {displayName}\n

\n {user.farcaster && (\n \n FID {user.farcaster.fid}\n \n )}\n
\n\n {/* Username or ENS */}\n {username && (\n

\n @{username}\n

\n )}\n\n {/* ENS name if different from display */}\n {showENS && user.ensName && user.ensName !== displayName && (\n

\n {user.ensName}\n

\n )}\n\n {/* Primary address */}\n {showAddresses && (\n

\n \n {formatAddress(user.primaryAddress)}\n

\n )}\n
\n
\n\n {/* Bio */}\n {user.farcaster?.profile?.bio?.text && (\n

\n {user.farcaster.profile.bio.text}\n

\n )}\n\n {/* Stats or additional addresses */}\n
\n {user.farcaster ? (\n <>\n \n {formatLargeNumber(user.farcaster.follower_count)} followers\n \n \n {formatLargeNumber(user.farcaster.following_count)} following\n \n \n ) : (\n <>\n {user.addresses.length > 1 && showAddresses && (\n \n +{user.addresses.length - 1} more address\n {user.addresses.length > 2 ? \"es\" : \"\"}\n \n )}\n {user.source === \"ens\" && (\n \n ENS\n \n )}\n {user.source === \"address\" && (\n \n Address\n \n )}\n \n )}\n
\n
\n );\n}\n", + "type": "registry:component" + }, + { + "path": "registry/mini-app/ui/button.tsx", + "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n {\n variants: {\n variant: {\n default:\n \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n destructive:\n \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n outline:\n \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n secondary:\n \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n);\n\nfunction Button({\n className,\n variant,\n size,\n asChild = false,\n ...props\n}: React.ComponentProps<\"button\"> &\n VariantProps & {\n asChild?: boolean;\n }) {\n const Comp = asChild ? Slot : \"button\";\n\n return (\n \n );\n}\n\nexport { Button, buttonVariants };\n", + "type": "registry:ui", + "target": "" + }, + { + "path": "registry/mini-app/lib/utils.ts", + "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}", + "type": "registry:lib", + "target": "" + }, + { + "path": "registry/mini-app/ui/input.tsx", + "content": "import * as React from \"react\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n return (\n \n );\n}\n\nexport { Input };\n", + "type": "registry:ui", + "target": "" + }, + { + "path": "registry/mini-app/hooks/use-miniapp-sdk.ts", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport sdk from \"@farcaster/frame-sdk\";\nimport type { Context } from \"@farcaster/frame-core\";\n\nexport function useMiniAppSdk() {\n const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);\n\n const [isSDKLoaded, setIsSDKLoaded] = useState(false);\n const [context, setContext] = useState();\n const [isMiniAppSaved, setIsMiniAppSaved] = useState(false);\n const [lastEvent, setLastEvent] = useState(\"\");\n const [pinFrameResponse, setPinFrameResponse] = useState(\"\");\n const [isMiniApp, setIsMiniApp] = useState(false);\n\n useEffect(() => {\n if (!sdk) return;\n\n sdk.on(\"frameAdded\", ({ notificationDetails }) => {\n setLastEvent(\n `frameAdded${notificationDetails ? \", notifications enabled\" : \"\"}`,\n );\n setIsMiniAppSaved(true);\n });\n\n sdk.on(\"frameAddRejected\", ({ reason }) => {\n setLastEvent(`frameAddRejected, reason ${reason}`);\n });\n\n sdk.on(\"frameRemoved\", () => {\n setLastEvent(\"frameRemoved\");\n setIsMiniAppSaved(false);\n });\n\n sdk.on(\"notificationsEnabled\", ({ notificationDetails }) => {\n setLastEvent(\"notificationsEnabled\");\n });\n\n sdk.on(\"notificationsDisabled\", () => {\n setLastEvent(\"notificationsDisabled\");\n });\n\n // CRITICAL TO LOAD MINI APP - DON'T REMOVE\n sdk.actions.ready({});\n setIsSDKLoaded(true);\n\n // Clean up on unmount\n return () => {\n sdk.removeAllListeners();\n };\n }, []);\n\n useEffect(() => {\n const updateContext = async () => {\n const frameContext = await sdk.context;\n if (frameContext) {\n setContext(frameContext);\n setIsMiniAppSaved(frameContext.client.added);\n }\n\n const miniAppStatus = await sdk.isInMiniApp();\n setIsMiniApp(miniAppStatus);\n };\n\n if (isSDKLoaded) {\n updateContext();\n }\n }, [isSDKLoaded]);\n\n const pinFrame = useCallback(async () => {\n try {\n const result = await sdk.actions.addFrame();\n console.log(\"addFrame result\", result);\n // @ts-expect-error - result type mixup\n if (result.added) {\n setPinFrameResponse(\n result.notificationDetails\n ? `Added, got notificaton token ${result.notificationDetails.token} and url ${result.notificationDetails.url}`\n : \"Added, got no notification details\",\n );\n }\n } catch (error) {\n setPinFrameResponse(`Error: ${error}`);\n }\n }, []);\n\n return {\n context,\n pinFrame,\n pinFrameResponse,\n isMiniAppSaved,\n lastEvent,\n sdk,\n isSDKLoaded,\n isAuthDialogOpen,\n setIsAuthDialogOpen,\n isMiniApp,\n };\n}\n", + "type": "registry:hook", + "target": "" + }, + { + "path": "registry/mini-app/lib/text-utils.ts", + "content": "export const formatLargeNumber = (num?: number): string => {\n if (!num) return \"0\";\n\n if (num >= 1000000) {\n return (num / 1000000).toFixed(1) + \"M\";\n } else if (num >= 2000) {\n return (num / 1000).toFixed(1) + \"K\";\n } else {\n return num.toString();\n }\n};\n", + "type": "registry:lib", + "target": "" + }, + { + "path": "registry/mini-app/lib/address-utils.ts", + "content": "import { isAddress, getAddress } from \"viem\";\n\n/**\n * Formats an Ethereum address to show first and last few characters\n * @param address - The address to format\n * @param chars - Number of characters to show at start and end (default: 4)\n * @returns Formatted address like \"0x1234...5678\"\n */\nexport function formatAddress(address: string, chars = 4): string {\n if (!address || address.length < chars * 2 + 2) return address;\n return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;\n}\n\n/**\n * Detects the type of input (address, ENS name, or username)\n * @param input - The search input\n * @returns The detected input type\n */\nexport type InputType = \"address\" | \"ens\" | \"username\";\n\nexport function detectInputType(input: string): InputType {\n if (!input || input.trim().length === 0) return \"username\";\n \n const trimmed = input.trim();\n \n // Check if it's a valid Ethereum address\n if (isAddress(trimmed)) {\n return \"address\";\n }\n \n // Check if it's an ENS name (contains . and not already an address)\n if (trimmed.includes(\".\") && trimmed.length > 3) {\n // Common ENS TLDs\n const ensPattern = /\\.(eth|xyz|luxe|kred|art|club|test)$/i;\n if (ensPattern.test(trimmed)) {\n return \"ens\";\n }\n }\n \n // Default to username (Farcaster username or FID)\n return \"username\";\n}\n\n/**\n * Validates and normalizes an Ethereum address\n * @param address - The address to validate\n * @returns The checksummed address or null if invalid\n */\nexport function normalizeAddress(address: string): string | null {\n try {\n if (!isAddress(address)) return null;\n return getAddress(address);\n } catch {\n return null;\n }\n}\n\n/**\n * Checks if two addresses are equal (case-insensitive)\n * @param addr1 - First address\n * @param addr2 - Second address\n * @returns True if addresses are equal\n */\nexport function addressesEqual(addr1: string | null | undefined, addr2: string | null | undefined): boolean {\n if (!addr1 || !addr2) return false;\n try {\n return getAddress(addr1).toLowerCase() === getAddress(addr2).toLowerCase();\n } catch {\n return false;\n }\n}", + "type": "registry:lib", + "target": "" + }, + { + "path": "registry/mini-app/lib/chains.ts", + "content": "import { http, type Chain, type PublicClient, createPublicClient } from \"viem\";\nimport * as chains from \"viem/chains\";\n\n/**\n * Supported chains configuration with Alchemy RPC support\n */\nexport const SUPPORTED_CHAINS = [\n { id: 1, chain: chains.mainnet, alchemyPrefix: \"eth-mainnet\" },\n { id: 8453, chain: chains.base, alchemyPrefix: \"base-mainnet\" },\n { id: 42161, chain: chains.arbitrum, alchemyPrefix: \"arb-mainnet\" },\n { id: 421614, chain: chains.arbitrumSepolia, alchemyPrefix: \"arb-sepolia\" },\n { id: 84532, chain: chains.baseSepolia, alchemyPrefix: \"base-sepolia\" },\n { id: 666666666, chain: chains.degen, alchemyPrefix: \"degen-mainnet\" },\n { id: 100, chain: chains.gnosis, alchemyPrefix: \"gnosis-mainnet\" },\n { id: 10, chain: chains.optimism, alchemyPrefix: \"opt-mainnet\" },\n { id: 11155420, chain: chains.optimismSepolia, alchemyPrefix: \"opt-sepolia\" },\n { id: 137, chain: chains.polygon, alchemyPrefix: \"polygon-mainnet\" },\n { id: 11155111, chain: chains.sepolia, alchemyPrefix: \"eth-sepolia\" },\n { id: 7777777, chain: chains.zora, alchemyPrefix: \"zora-mainnet\" },\n { id: 130, chain: chains.ham, alchemyPrefix: \"unichain-mainnet\" }, // Unichain\n {\n id: 10143,\n chain: {\n id: 10143,\n name: \"Monad Testnet\",\n network: \"monad-testnet\",\n nativeCurrency: { name: \"Monad\", symbol: \"MON\", decimals: 18 },\n rpcUrls: {\n default: { http: [\"https://testnet.monad.xyz\"] },\n public: { http: [\"https://testnet.monad.xyz\"] },\n },\n } as const,\n alchemyPrefix: null,\n },\n { id: 42220, chain: chains.celo, alchemyPrefix: null },\n] as const;\n\n/**\n * Get viem Chain object by ID\n */\nexport function getChainById(chainId: number): Chain {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n return config?.chain || chains.mainnet;\n}\n\n/**\n * Get HTTP transport with optional Alchemy RPC URL\n * Falls back to public RPC if no Alchemy key is available\n */\nexport function getTransport(chainId: number) {\n const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY;\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n\n if (config?.alchemyPrefix && alchemyKey) {\n return http(\n `https://${config.alchemyPrefix}.g.alchemy.com/v2/${alchemyKey}`,\n );\n }\n\n // Fallback to default public RPC\n return http();\n}\n\n/**\n * Create a public client for a specific chain with optimal transport\n */\nexport function getPublicClient(chainId: number): PublicClient {\n return createPublicClient({\n chain: getChainById(chainId),\n transport: getTransport(chainId),\n }) as PublicClient;\n}\n\n/**\n * Find chain by network name (case-insensitive)\n */\nexport function findChainByName(networkName: string): Chain | undefined {\n const normalizedName = networkName.toLowerCase().trim();\n \n // Direct name mappings\n const nameToId: Record = {\n ethereum: 1,\n mainnet: 1,\n base: 8453,\n arbitrum: 42161,\n \"arbitrum one\": 42161,\n \"arbitrum sepolia\": 421614,\n \"base sepolia\": 84532,\n degen: 666666666,\n gnosis: 100,\n optimism: 10,\n \"optimism sepolia\": 11155420,\n polygon: 137,\n sepolia: 11155111,\n \"ethereum sepolia\": 11155111,\n zora: 7777777,\n unichain: 130,\n ham: 130,\n \"monad testnet\": 10143,\n monad: 10143,\n celo: 42220,\n };\n \n const chainId = nameToId[normalizedName];\n return chainId ? getChainById(chainId) : undefined;\n}\n\n/**\n * Get Alchemy RPC endpoint URL for a specific chain\n */\nexport function getAlchemyEndpoint(chainId: number, apiKey: string): string | undefined {\n const config = SUPPORTED_CHAINS.find((c) => c.id === chainId);\n \n if (config?.alchemyPrefix && apiKey) {\n return `https://${config.alchemyPrefix}.g.alchemy.com/v2/${apiKey}`;\n }\n \n return undefined;\n}\n", + "type": "registry:lib", + "target": "" + } + ], + "meta": { + "use_cases": [ + "User Search", + "Profile Lookup", + "Address Resolution", + "ENS Resolution", + "Team Member Search" + ], + "keywords": [ + "search", + "profiles", + "neynar", + "farcaster", + "ens", + "address", + "ethereum", + "onchain" + ] + } +} \ No newline at end of file diff --git a/public/r/profile-search.json b/public/r/profile-search.json deleted file mode 100644 index 4d11ee34..00000000 --- a/public/r/profile-search.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "profile-search", - "type": "registry:component", - "title": "Profile Search", - "description": "Search Farcaster users with Neynar API and view profiles using mini app SDK. Shows results in clickable cards with user details.", - "dependencies": [ - "lucide-react", - "@radix-ui/react-slot", - "class-variance-authority", - "clsx", - "tailwind-merge", - "@farcaster/frame-sdk", - "@farcaster/frame-core" - ], - "registryDependencies": [ - "button", - "input", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", - "https://hellno-mini-app-ui.vercel.app/r/utils.json" - ], - "files": [ - { - "path": "registry/mini-app/blocks/profile-search/profile-search.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@/registry/mini-app/ui/button\";\nimport { Input } from \"@/registry/mini-app/ui/input\";\nimport { useMiniAppSdk } from \"@/registry/mini-app/hooks/use-miniapp-sdk\";\nimport { Search, User, Users, X } from \"lucide-react\";\nimport { formatLargeNumber } from \"@/registry/mini-app/lib/text-utils\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\n\n// Types based on Neynar API response\nexport type FarcasterUser = {\n fid: number;\n username: string;\n display_name: string;\n pfp_url: string;\n follower_count: number;\n following_count: number;\n power_badge?: boolean;\n profile?: {\n bio?: {\n text?: string;\n };\n };\n verified_addresses?: {\n eth_addresses?: string[];\n };\n};\n\nexport type NeynarSearchResponse = {\n result: {\n users: FarcasterUser[];\n next?: {\n cursor: string;\n };\n };\n};\n\ntype ProfileSearchProps = {\n apiKey: string;\n placeholder?: string;\n variant?: \"destructive\" | \"secondary\" | \"ghost\" | \"default\";\n className?: string;\n inputClassName?: string;\n buttonClassName?: string;\n layout?: \"horizontal\" | \"vertical\";\n showIcon?: boolean;\n autoSearch?: boolean;\n maxResults?: number;\n searchFunction?: (\n query: string,\n apiKey: string,\n maxResults: number,\n cursor?: string,\n ) => Promise<{ users: FarcasterUser[]; nextCursor?: string }>;\n userCardComponent?: React.ComponentType;\n onError?: (error: string) => void;\n onClick?: (user: FarcasterUser) => void;\n};\n\nexport const calculateRelevanceScore = (\n user: FarcasterUser,\n query: string,\n): number => {\n const lowerQuery = query.toLowerCase();\n const username = user.username.toLowerCase();\n const displayName = user.display_name.toLowerCase();\n\n let score = 0;\n\n // Exact matches get highest score\n if (username === lowerQuery || username === `${lowerQuery}.eth`)\n score += 1000;\n else if (username.startsWith(lowerQuery)) score += 600;\n else if (username.includes(lowerQuery)) score += 500;\n\n // make these else ifs:\n if (displayName === lowerQuery) score += 500;\n else if (displayName.startsWith(lowerQuery)) score += 500;\n else if (displayName.includes(lowerQuery)) score += 400;\n\n // FID match\n if (user.fid.toString() === query) score += 950;\n\n // Bonus for shorter usernames (more relevant for short queries)\n if (username.includes(lowerQuery)) {\n score += Math.max(0, 100 - username.length);\n }\n\n if (user.verified_addresses?.eth_addresses?.length) score += 30;\n\n // Bonus for follower count (logarithmic to avoid overwhelming)\n score += Math.log(user.follower_count + 1) * 10;\n\n return score;\n};\n\nexport function ProfileSearch({\n apiKey,\n onClick,\n placeholder = \"Search Farcaster users...\",\n variant = \"default\",\n className,\n inputClassName,\n buttonClassName,\n layout = \"horizontal\",\n showIcon = true,\n autoSearch = false,\n maxResults = 5,\n searchFunction,\n userCardComponent: CustomUserCard,\n onError,\n}: ProfileSearchProps) {\n const [searchInput, setSearchInput] = React.useState(\"\");\n const [loading, setLoading] = React.useState(false);\n const [error, setError] = React.useState(\"\");\n const [searchResults, setSearchResults] = React.useState([]);\n const [nextCursor, setNextCursor] = React.useState();\n const [isLoadingMore, setIsLoadingMore] = React.useState(false);\n const debounceRef = React.useRef(undefined);\n\n const { sdk, isSDKLoaded, isMiniApp } = useMiniAppSdk();\n const defaultSearchFunction = async (\n query: string,\n apiKey: string,\n maxResults: number,\n cursor?: string,\n ): Promise<{\n users: FarcasterUser[];\n nextCursor?: string;\n }> => {\n let url = `https://api.neynar.com/v2/farcaster/user/search?q=${encodeURIComponent(query)}&limit=${maxResults}`;\n if (cursor) {\n url += `&cursor=${encodeURIComponent(cursor)}`;\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n accept: \"application/json\",\n api_key: apiKey,\n },\n });\n\n if (!response.ok) {\n const errorData = await response.json();\n throw new Error(errorData.message || `API Error: ${response.status}`);\n }\n\n const data: NeynarSearchResponse = await response.json();\n\n // Sort by relevance if multiple results (only for first page to maintain API order for subsequent pages)\n const users = !cursor\n ? (data.result.users || [])\n .map((user) => ({\n user,\n score: calculateRelevanceScore(user, query),\n }))\n .sort((a, b) => b.score - a.score)\n .map((item) => item.user)\n : data.result.users || [];\n\n return {\n users,\n nextCursor: data.result.next?.cursor,\n };\n };\n\n const searchUsers = async (query: string, loadMore = false) => {\n if (!searchFunction && !apiKey.trim()) {\n const errorMsg = \"API key is required\";\n setError(errorMsg);\n onError?.(errorMsg);\n return;\n }\n\n if (!query.trim()) {\n const errorMsg = \"Please enter a search term\";\n setError(errorMsg);\n onError?.(errorMsg);\n return;\n }\n\n try {\n if (loadMore) {\n setIsLoadingMore(true);\n } else {\n setLoading(true);\n setSearchResults([]);\n setNextCursor(undefined);\n }\n setError(\"\");\n\n const searchFn = searchFunction || defaultSearchFunction;\n const cursor = loadMore ? nextCursor : undefined;\n\n const { users, nextCursor: newCursor } = await searchFn(\n query,\n apiKey,\n maxResults,\n cursor,\n );\n\n if (loadMore) {\n setSearchResults((prev) => [...prev, ...users]);\n } else {\n setSearchResults(users);\n }\n\n setNextCursor(newCursor);\n\n if (!loadMore && users.length === 0) {\n const errorMsg = \"No users found matching your search\";\n setError(errorMsg);\n onError?.(errorMsg);\n }\n } catch (err) {\n console.error(\"Error searching users:\", err);\n const errorMsg =\n err instanceof Error ? err.message : \"Failed to search users\";\n setError(errorMsg);\n if (!loadMore) {\n setSearchResults([]);\n setNextCursor(undefined);\n }\n onError?.(errorMsg);\n } finally {\n setLoading(false);\n setIsLoadingMore(false);\n }\n };\n\n const viewProfile = async (username: string, fid: number) => {\n try {\n if (isMiniApp) {\n await sdk.actions.viewProfile({ fid });\n } else {\n window.open(`https://farcaster.xyz/${username}`, \"_blank\");\n }\n } catch (err) {\n console.error(\"Error viewing profile:\", err);\n const errorMsg =\n err instanceof Error ? err.message : \"Failed to view profile\";\n setError(errorMsg);\n onError?.(errorMsg);\n }\n };\n\n const handleSearch = async () => {\n await searchUsers(searchInput);\n };\n\n const handleClear = () => {\n setSearchInput(\"\");\n setSearchResults([]);\n setNextCursor(undefined);\n setError(\"\");\n };\n\n const handleKeyPress = (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n handleSearch();\n }\n };\n\n const handleInputChange = (e: React.ChangeEvent) => {\n const value = e.target.value;\n\n setSearchInput(value);\n setError(\"\");\n\n // Clear previous timeout\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n\n if (value === \"\") {\n setSearchResults([]);\n setNextCursor(undefined);\n return;\n }\n\n // Auto-search with debounce\n if (autoSearch && value.length > 2) {\n debounceRef.current = setTimeout(() => {\n searchUsers(value);\n }, 500);\n }\n };\n\n const containerClasses = cn(\"flex flex-col gap-4 w-full\", className);\n\n const searchContainerClasses = cn(\n \"flex gap-2 w-full\",\n layout === \"vertical\" ? \"flex-col\" : \"flex-row\",\n );\n\n const hasMoreResults = !!nextCursor;\n\n return (\n
\n {/* Search Input */}\n
\n
\n \n {searchInput && (\n \n \n \n )}\n
\n\n {/* Clear Button - only show when there's a search term */}\n \n {loading ? (\n <>\n
\n Searching...\n \n ) : (\n <>\n {showIcon && }\n Search\n \n )}\n \n
\n\n {/* Error Message */}\n {error && (\n
\n {error}\n
\n )}\n\n {/* Search Results */}\n {searchResults.length > 0 && (\n
\n
\n
\n \n Showing {searchResults.length} user\n {searchResults.length !== 1 ? \"s\" : \"\"}\n
\n
\n\n
\n {searchResults.map((user) => {\n const UserCardComponent = CustomUserCard || UserCard;\n return (\n {\n console.log(\"User clicked:\", user, isSDKLoaded);\n if (onClick) {\n onClick(user);\n } else {\n viewProfile(user.username, user.fid);\n }\n }}\n />\n );\n })}\n
\n\n {hasMoreResults && (\n
\n searchUsers(searchInput, true)}\n disabled={isLoadingMore || loading}\n className=\"w-full sm:w-auto\"\n >\n {isLoadingMore ? (\n <>\n
\n Loading more...\n \n ) : (\n <>Load More\n )}\n \n
\n )}\n
\n )}\n
\n );\n}\n\n// User Card Component\ntype UserCardProps = {\n user: FarcasterUser;\n onClick: () => void;\n};\n\nfunction UserCard({ user, onClick }: UserCardProps) {\n return (\n \n {/* Top row with avatar, name, and FID */}\n
\n {/* Avatar */}\n
\n {user.pfp_url ? (\n \n ) : (\n
\n \n
\n )}\n
\n\n {/* Name and FID */}\n
\n
\n

\n {user.display_name || user.username}\n

\n \n FID {user.fid}\n \n
\n\n {/* Username */}\n

\n @{user.username}\n

\n
\n
\n\n {/* Bio */}\n {user.profile?.bio?.text && (\n

\n {user.profile.bio.text}\n

\n )}\n\n {/* Stats */}\n
\n \n {formatLargeNumber(user.follower_count)} followers\n \n \n {formatLargeNumber(user.following_count)} following\n \n
\n
\n );\n}\n", - "type": "registry:component" - }, - { - "path": "registry/mini-app/ui/button.tsx", - "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n {\n variants: {\n variant: {\n default:\n \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n destructive:\n \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n outline:\n \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n secondary:\n \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n ghost:\n \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n icon: \"size-9\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n);\n\nfunction Button({\n className,\n variant,\n size,\n asChild = false,\n ...props\n}: React.ComponentProps<\"button\"> &\n VariantProps & {\n asChild?: boolean;\n }) {\n const Comp = asChild ? Slot : \"button\";\n\n return (\n \n );\n}\n\nexport { Button, buttonVariants };\n", - "type": "registry:ui", - "target": "" - }, - { - "path": "registry/mini-app/lib/utils.ts", - "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}", - "type": "registry:lib", - "target": "" - }, - { - "path": "registry/mini-app/ui/input.tsx", - "content": "import * as React from \"react\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n return (\n \n );\n}\n\nexport { Input };\n", - "type": "registry:ui", - "target": "" - }, - { - "path": "registry/mini-app/hooks/use-miniapp-sdk.ts", - "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport sdk from \"@farcaster/frame-sdk\";\nimport type { Context } from \"@farcaster/frame-core\";\n\nexport function useMiniAppSdk() {\n const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(false);\n\n const [isSDKLoaded, setIsSDKLoaded] = useState(false);\n const [context, setContext] = useState();\n const [isMiniAppSaved, setIsMiniAppSaved] = useState(false);\n const [lastEvent, setLastEvent] = useState(\"\");\n const [pinFrameResponse, setPinFrameResponse] = useState(\"\");\n const [isMiniApp, setIsMiniApp] = useState(false);\n\n useEffect(() => {\n if (!sdk) return;\n\n sdk.on(\"frameAdded\", ({ notificationDetails }) => {\n setLastEvent(\n `frameAdded${notificationDetails ? \", notifications enabled\" : \"\"}`,\n );\n setIsMiniAppSaved(true);\n });\n\n sdk.on(\"frameAddRejected\", ({ reason }) => {\n setLastEvent(`frameAddRejected, reason ${reason}`);\n });\n\n sdk.on(\"frameRemoved\", () => {\n setLastEvent(\"frameRemoved\");\n setIsMiniAppSaved(false);\n });\n\n sdk.on(\"notificationsEnabled\", ({ notificationDetails }) => {\n setLastEvent(\"notificationsEnabled\");\n });\n\n sdk.on(\"notificationsDisabled\", () => {\n setLastEvent(\"notificationsDisabled\");\n });\n\n // CRITICAL TO LOAD MINI APP - DON'T REMOVE\n sdk.actions.ready({});\n setIsSDKLoaded(true);\n\n // Clean up on unmount\n return () => {\n sdk.removeAllListeners();\n };\n }, []);\n\n useEffect(() => {\n const updateContext = async () => {\n const frameContext = await sdk.context;\n if (frameContext) {\n setContext(frameContext);\n setIsMiniAppSaved(frameContext.client.added);\n }\n\n const miniAppStatus = await sdk.isInMiniApp();\n setIsMiniApp(miniAppStatus);\n };\n\n if (isSDKLoaded) {\n updateContext();\n }\n }, [isSDKLoaded]);\n\n const pinFrame = useCallback(async () => {\n try {\n const result = await sdk.actions.addFrame();\n console.log(\"addFrame result\", result);\n // @ts-expect-error - result type mixup\n if (result.added) {\n setPinFrameResponse(\n result.notificationDetails\n ? `Added, got notificaton token ${result.notificationDetails.token} and url ${result.notificationDetails.url}`\n : \"Added, got no notification details\",\n );\n }\n } catch (error) {\n setPinFrameResponse(`Error: ${error}`);\n }\n }, []);\n\n return {\n context,\n pinFrame,\n pinFrameResponse,\n isMiniAppSaved,\n lastEvent,\n sdk,\n isSDKLoaded,\n isAuthDialogOpen,\n setIsAuthDialogOpen,\n isMiniApp,\n };\n}\n", - "type": "registry:hook", - "target": "" - }, - { - "path": "registry/mini-app/lib/text-utils.ts", - "content": "export const formatLargeNumber = (num?: number): string => {\n if (!num) return \"0\";\n\n if (num >= 1000000) {\n return (num / 1000000).toFixed(1) + \"M\";\n } else if (num >= 2000) {\n return (num / 1000).toFixed(1) + \"K\";\n } else {\n return num.toString();\n }\n};\n", - "type": "registry:lib", - "target": "" - } - ], - "meta": { - "use_cases": [ - "User Search", - "Profile Lookup" - ], - "keywords": [ - "search", - "profiles", - "neynar", - "farcaster", - "sdk" - ] - } -} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index f1c4ac20..66229f88 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -208,24 +208,27 @@ ] }, { - "name": "profile-search", + "name": "onchain-user-search", "type": "registry:component", - "title": "Profile Search", - "description": "Search Farcaster users with Neynar API and view profiles using mini app SDK. Shows results in clickable cards with user details.", - "dependencies": ["lucide-react"], + "title": "Onchain User Search", + "description": "Unified search for team members and friends across Farcaster usernames, ENS names, and Ethereum addresses. Resolves in all three directions with onchain address as primary interface.", + "dependencies": ["lucide-react", "viem"], "registryDependencies": [ "button", "input", "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", - "https://hellno-mini-app-ui.vercel.app/r/utils.json" + "https://hellno-mini-app-ui.vercel.app/r/utils.json", + "https://hellno-mini-app-ui.vercel.app/r/chains.json", + "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", + "https://hellno-mini-app-ui.vercel.app/r/address-utils.json" ], "meta": { - "use_cases": ["User Search", "Profile Lookup"], - "keywords": ["search", "profiles", "neynar", "farcaster", "sdk"] + "use_cases": ["User Search", "Profile Lookup", "Address Resolution", "ENS Resolution", "Team Member Search"], + "keywords": ["search", "profiles", "neynar", "farcaster", "ens", "address", "ethereum", "onchain"] }, "files": [ { - "path": "registry/mini-app/blocks/profile-search/profile-search.tsx", + "path": "registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx", "type": "registry:component" } ] @@ -342,6 +345,20 @@ "dependencies": [], "registryDependencies": [] }, + { + "name": "address-utils", + "type": "registry:lib", + "title": "addressUtils", + "description": "Utility functions for Ethereum address formatting, validation, and input type detection", + "files": [ + { + "path": "registry/mini-app/lib/address-utils.ts", + "type": "registry:lib" + } + ], + "dependencies": ["viem"], + "registryDependencies": [] + }, { "name": "avatar-utils", "type": "registry:lib", diff --git a/registry.json b/registry.json index f1c4ac20..66229f88 100644 --- a/registry.json +++ b/registry.json @@ -208,24 +208,27 @@ ] }, { - "name": "profile-search", + "name": "onchain-user-search", "type": "registry:component", - "title": "Profile Search", - "description": "Search Farcaster users with Neynar API and view profiles using mini app SDK. Shows results in clickable cards with user details.", - "dependencies": ["lucide-react"], + "title": "Onchain User Search", + "description": "Unified search for team members and friends across Farcaster usernames, ENS names, and Ethereum addresses. Resolves in all three directions with onchain address as primary interface.", + "dependencies": ["lucide-react", "viem"], "registryDependencies": [ "button", "input", "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", - "https://hellno-mini-app-ui.vercel.app/r/utils.json" + "https://hellno-mini-app-ui.vercel.app/r/utils.json", + "https://hellno-mini-app-ui.vercel.app/r/chains.json", + "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", + "https://hellno-mini-app-ui.vercel.app/r/address-utils.json" ], "meta": { - "use_cases": ["User Search", "Profile Lookup"], - "keywords": ["search", "profiles", "neynar", "farcaster", "sdk"] + "use_cases": ["User Search", "Profile Lookup", "Address Resolution", "ENS Resolution", "Team Member Search"], + "keywords": ["search", "profiles", "neynar", "farcaster", "ens", "address", "ethereum", "onchain"] }, "files": [ { - "path": "registry/mini-app/blocks/profile-search/profile-search.tsx", + "path": "registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx", "type": "registry:component" } ] @@ -342,6 +345,20 @@ "dependencies": [], "registryDependencies": [] }, + { + "name": "address-utils", + "type": "registry:lib", + "title": "addressUtils", + "description": "Utility functions for Ethereum address formatting, validation, and input type detection", + "files": [ + { + "path": "registry/mini-app/lib/address-utils.ts", + "type": "registry:lib" + } + ], + "dependencies": ["viem"], + "registryDependencies": [] + }, { "name": "avatar-utils", "type": "registry:lib", diff --git a/registry/mini-app/blocks/profile-search/profile-search.tsx b/registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx similarity index 50% rename from registry/mini-app/blocks/profile-search/profile-search.tsx rename to registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx index be6df367..47c50ee8 100644 --- a/registry/mini-app/blocks/profile-search/profile-search.tsx +++ b/registry/mini-app/blocks/onchain-user-search/onchain-user-search.tsx @@ -4,10 +4,18 @@ import * as React from "react"; import { Button } from "@/registry/mini-app/ui/button"; import { Input } from "@/registry/mini-app/ui/input"; import { useMiniAppSdk } from "@/registry/mini-app/hooks/use-miniapp-sdk"; -import { Search, User, Users, X } from "lucide-react"; +import { Search, User, Users, X, AtSign, Wallet } from "lucide-react"; import { formatLargeNumber } from "@/registry/mini-app/lib/text-utils"; - import { cn } from "@/registry/mini-app/lib/utils"; +import { + detectInputType, + formatAddress, + normalizeAddress, + addressesEqual, +} from "@/registry/mini-app/lib/address-utils"; +import { createPublicClient, http } from "viem"; +import { mainnet } from "viem/chains"; +import { getAlchemyEndpoint } from "@/registry/mini-app/lib/chains"; // Types based on Neynar API response export type FarcasterUser = { @@ -37,8 +45,27 @@ export type NeynarSearchResponse = { }; }; -type ProfileSearchProps = { +export type NeynarBulkAddressResponse = { + "0x...": FarcasterUser[]; +}; + +// Unified user type that combines all identities +export type UnifiedUser = { + // Primary identifier is the onchain address + primaryAddress: string; + // ENS name if available + ensName?: string; + // Farcaster profile if available + farcaster?: FarcasterUser; + // Additional addresses associated with this user + addresses: string[]; + // Source of the result + source: "farcaster" | "ens" | "address"; +}; + +type OnchainUserSearchProps = { apiKey: string; + alchemyApiKey?: string; placeholder?: string; variant?: "destructive" | "secondary" | "ghost" | "default"; className?: string; @@ -53,10 +80,12 @@ type ProfileSearchProps = { apiKey: string, maxResults: number, cursor?: string, - ) => Promise<{ users: FarcasterUser[]; nextCursor?: string }>; + ) => Promise<{ users: UnifiedUser[]; nextCursor?: string }>; userCardComponent?: React.ComponentType; onError?: (error: string) => void; - onClick?: (user: FarcasterUser) => void; + onUserClick?: (user: UnifiedUser) => void; + showAddresses?: boolean; + showENS?: boolean; }; export const calculateRelevanceScore = ( @@ -96,10 +125,11 @@ export const calculateRelevanceScore = ( return score; }; -export function ProfileSearch({ +export function OnchainUserSearch({ apiKey, - onClick, - placeholder = "Search Farcaster users...", + alchemyApiKey, + onUserClick, + placeholder = "Search by username, ENS, or address...", variant = "default", className, inputClassName, @@ -111,25 +141,34 @@ export function ProfileSearch({ searchFunction, userCardComponent: CustomUserCard, onError, -}: ProfileSearchProps) { + showAddresses = true, + showENS = true, +}: OnchainUserSearchProps) { const [searchInput, setSearchInput] = React.useState(""); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(""); - const [searchResults, setSearchResults] = React.useState([]); + const [searchResults, setSearchResults] = React.useState([]); const [nextCursor, setNextCursor] = React.useState(); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const debounceRef = React.useRef(undefined); const { sdk, isSDKLoaded, isMiniApp } = useMiniAppSdk(); - const defaultSearchFunction = async ( + // Create viem client for ENS resolution + const publicClient = React.useMemo(() => { + const alchemyKey = alchemyApiKey || process.env.NEXT_PUBLIC_ALCHEMY_KEY; + const rpcUrl = alchemyKey ? getAlchemyEndpoint(1, alchemyKey) : undefined; + + return createPublicClient({ + chain: mainnet, + transport: http(rpcUrl), + }); + }, [alchemyApiKey]); + + // Search for Farcaster users by username + const searchFarcasterUsers = async ( query: string, - apiKey: string, - maxResults: number, cursor?: string, - ): Promise<{ - users: FarcasterUser[]; - nextCursor?: string; - }> => { + ): Promise<{ users: FarcasterUser[]; nextCursor?: string }> => { let url = `https://api.neynar.com/v2/farcaster/user/search?q=${encodeURIComponent(query)}&limit=${maxResults}`; if (cursor) { url += `&cursor=${encodeURIComponent(cursor)}`; @@ -150,7 +189,7 @@ export function ProfileSearch({ const data: NeynarSearchResponse = await response.json(); - // Sort by relevance if multiple results (only for first page to maintain API order for subsequent pages) + // Sort by relevance if multiple results (only for first page) const users = !cursor ? (data.result.users || []) .map((user) => ({ @@ -167,6 +206,201 @@ export function ProfileSearch({ }; }; + // Search for Farcaster users by address + const searchFarcasterByAddress = async ( + address: string, + ): Promise => { + const url = `https://api.neynar.com/v2/farcaster/user/bulk-by-address?addresses=${encodeURIComponent(address)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + accept: "application/json", + api_key: apiKey, + }, + }); + + if (!response.ok) { + // If 404, it means no users found for this address + if (response.status === 404) { + return []; + } + const errorData = await response.json(); + throw new Error(errorData.message || `API Error: ${response.status}`); + } + + const data: NeynarBulkAddressResponse = await response.json(); + + // The response is an object with addresses as keys + const users: FarcasterUser[] = []; + for (const [addr, userList] of Object.entries(data)) { + if (addressesEqual(addr, address) && Array.isArray(userList)) { + users.push(...userList); + } + } + + return users; + }; + + // Resolve ENS name to address + const resolveENSToAddress = async ( + ensName: string, + ): Promise => { + try { + const address = await publicClient.getEnsAddress({ + name: ensName, + }); + return address || null; + } catch (error) { + return null; + } + }; + + // Reverse resolve address to ENS name + const resolveAddressToENS = async ( + address: string, + ): Promise => { + try { + const ensName = await publicClient.getEnsName({ + address: address as `0x${string}`, + }); + + // Verify the reverse resolution + if (ensName) { + const verifyAddress = await publicClient.getEnsAddress({ + name: ensName, + }); + if (!addressesEqual(verifyAddress, address)) { + return null; + } + } + + return ensName || null; + } catch (error) { + return null; + } + }; + + // Default unified search function + const defaultSearchFunction = async ( + query: string, + apiKey: string, + maxResults: number, + cursor?: string, + ): Promise<{ + users: UnifiedUser[]; + nextCursor?: string; + }> => { + const inputType = detectInputType(query); + const results: UnifiedUser[] = []; + + if (inputType === "address") { + // For addresses, do parallel lookups + const normalizedAddr = normalizeAddress(query); + if (!normalizedAddr) { + throw new Error("Invalid Ethereum address"); + } + + // Parallel lookups for address + const [ensName, farcasterUsers] = await Promise.all([ + showENS ? resolveAddressToENS(normalizedAddr) : Promise.resolve(null), + searchFarcasterByAddress(normalizedAddr), + ]); + + if (farcasterUsers.length > 0) { + // Group by primary address if multiple Farcaster accounts + for (const fcUser of farcasterUsers) { + const addresses = fcUser.verified_addresses?.eth_addresses || []; + results.push({ + primaryAddress: normalizedAddr, + ensName, + farcaster: fcUser, + addresses: addresses + .filter((addr) => !addressesEqual(addr, normalizedAddr)) + .concat(normalizedAddr), + source: "address", + }); + } + } else { + // No Farcaster account, just show address info + results.push({ + primaryAddress: normalizedAddr, + ensName, + addresses: [normalizedAddr], + source: "address", + }); + } + } else if (inputType === "ens") { + // For ENS names, resolve to address first + const address = await resolveENSToAddress(query); + if (!address) { + throw new Error("ENS name could not be resolved"); + } + + // Then search for Farcaster accounts + const farcasterUsers = await searchFarcasterByAddress(address); + + if (farcasterUsers.length > 0) { + for (const fcUser of farcasterUsers) { + const addresses = fcUser.verified_addresses?.eth_addresses || []; + results.push({ + primaryAddress: address, + ensName: query, + farcaster: fcUser, + addresses: addresses + .filter((addr) => !addressesEqual(addr, address)) + .concat(address), + source: "ens", + }); + } + } else { + // No Farcaster account + results.push({ + primaryAddress: address, + ensName: query, + addresses: [address], + source: "ens", + }); + } + } else { + // Username search + const { users: farcasterUsers, nextCursor } = await searchFarcasterUsers( + query, + cursor, + ); + + // Convert to unified format + for (const fcUser of farcasterUsers) { + const primaryAddr = fcUser.verified_addresses?.eth_addresses?.[0]; + if (!primaryAddr) { + // Skip users without verified addresses + continue; + } + + // Look up ENS for primary address if enabled + const ensName = showENS ? await resolveAddressToENS(primaryAddr) : null; + + results.push({ + primaryAddress: primaryAddr, + ensName, + farcaster: fcUser, + addresses: fcUser.verified_addresses?.eth_addresses || [], + source: "farcaster", + }); + } + + return { + users: results, + nextCursor, + }; + } + + return { + users: results, + nextCursor: undefined, + }; + }; + const searchUsers = async (query: string, loadMore = false) => { if (!searchFunction && !apiKey.trim()) { const errorMsg = "API key is required"; @@ -216,7 +450,6 @@ export function ProfileSearch({ onError?.(errorMsg); } } catch (err) { - console.error("Error searching users:", err); const errorMsg = err instanceof Error ? err.message : "Failed to search users"; setError(errorMsg); @@ -231,15 +464,24 @@ export function ProfileSearch({ } }; - const viewProfile = async (username: string, fid: number) => { + const viewProfile = async (user: UnifiedUser) => { try { - if (isMiniApp) { - await sdk.actions.viewProfile({ fid }); + if (user.farcaster && isMiniApp) { + await sdk.actions.viewProfile({ fid: user.farcaster.fid }); + } else if (user.farcaster) { + window.open( + `https://farcaster.xyz/${user.farcaster.username}`, + "_blank", + ); + } else if (user.ensName) { + window.open(`https://app.ens.domains/${user.ensName}`, "_blank"); } else { - window.open(`https://farcaster.xyz/${username}`, "_blank"); + window.open( + `https://etherscan.io/address/${user.primaryAddress}`, + "_blank", + ); } } catch (err) { - console.error("Error viewing profile:", err); const errorMsg = err instanceof Error ? err.message : "Failed to view profile"; setError(errorMsg); @@ -372,16 +614,17 @@ export function ProfileSearch({ const UserCardComponent = CustomUserCard || UserCard; return ( { - console.log("User clicked:", user, isSDKLoaded); - if (onClick) { - onClick(user); + if (onUserClick) { + onUserClick(user); } else { - viewProfile(user.username, user.fid); + viewProfile(user); } }} + showAddresses={showAddresses} + showENS={showENS} /> ); })} @@ -414,66 +657,127 @@ export function ProfileSearch({ // User Card Component type UserCardProps = { - user: FarcasterUser; + user: UnifiedUser; onClick: () => void; + showAddresses?: boolean; + showENS?: boolean; }; -function UserCard({ user, onClick }: UserCardProps) { +function UserCard({ + user, + onClick, + showAddresses = true, + showENS = true, +}: UserCardProps) { + const hasFarcaster = !!user.farcaster; + const displayName = + user.farcaster?.display_name || + user.ensName || + formatAddress(user.primaryAddress); + const username = user.farcaster?.username; + const pfpUrl = user.farcaster?.pfp_url; + return (
- {/* Top row with avatar, name, and FID */} + {/* Top row with avatar, name, and identifiers */}
{/* Avatar */}
- {user.pfp_url ? ( + {pfpUrl ? ( {user.display_name ) : ( -
- +
+ {hasFarcaster ? ( + + ) : ( + + )}
)}
- {/* Name and FID */} + {/* Name and identifiers */}

- {user.display_name || user.username} + {displayName}

- - FID {user.fid} - + {user.farcaster && ( + + FID {user.farcaster.fid} + + )}
- {/* Username */} -

- @{user.username} -

+ {/* Username or ENS */} + {username && ( +

+ @{username} +

+ )} + + {/* ENS name if different from display */} + {showENS && user.ensName && user.ensName !== displayName && ( +

+ {user.ensName} +

+ )} + + {/* Primary address */} + {showAddresses && ( +

+ + {formatAddress(user.primaryAddress)} +

+ )}
{/* Bio */} - {user.profile?.bio?.text && ( + {user.farcaster?.profile?.bio?.text && (

- {user.profile.bio.text} + {user.farcaster.profile.bio.text}

)} - {/* Stats */} + {/* Stats or additional addresses */}
- - {formatLargeNumber(user.follower_count)} followers - - - {formatLargeNumber(user.following_count)} following - + {user.farcaster ? ( + <> + + {formatLargeNumber(user.farcaster.follower_count)} followers + + + {formatLargeNumber(user.farcaster.following_count)} following + + + ) : ( + <> + {user.addresses.length > 1 && showAddresses && ( + + +{user.addresses.length - 1} more address + {user.addresses.length > 2 ? "es" : ""} + + )} + {user.source === "ens" && ( + + ENS + + )} + {user.source === "address" && ( + + Address + + )} + + )}
); diff --git a/registry/mini-app/blocks/profile-search/simulationHelper.tsx b/registry/mini-app/blocks/onchain-user-search/simulationHelper.tsx similarity index 79% rename from registry/mini-app/blocks/profile-search/simulationHelper.tsx rename to registry/mini-app/blocks/onchain-user-search/simulationHelper.tsx index 29a75bcc..8f217520 100644 --- a/registry/mini-app/blocks/profile-search/simulationHelper.tsx +++ b/registry/mini-app/blocks/onchain-user-search/simulationHelper.tsx @@ -1,7 +1,6 @@ import React from "react"; -import { ProfileSearch, type FarcasterUser } from "./profile-search"; +import { OnchainUserSearch, type FarcasterUser, type UnifiedUser, calculateRelevanceScore } from "@/registry/mini-app/blocks/onchain-user-search/onchain-user-search"; import { FlaskConical } from "lucide-react"; -import { calculateRelevanceScore } from "./profile-search"; // Mock data for simulation mode const mockUsers: FarcasterUser[] = [ @@ -83,7 +82,7 @@ const simulateSearch = async ( query: string, _apiKey: string, maxResults: number, -): Promise<{ users: FarcasterUser[]; total: number }> => { +): Promise<{ users: UnifiedUser[]; nextCursor?: string }> => { // Simulate API delay await new Promise((resolve) => setTimeout(resolve, 800 + Math.random() * 400), @@ -105,25 +104,38 @@ const simulateSearch = async ( .sort((a, b) => b.score - a.score) .map((item) => item.user); - // Return top results and total count + // Convert to UnifiedUser format + const unifiedUsers = sortedMatches.slice(0, maxResults).map((user): UnifiedUser => { + const primaryAddr = user.verified_addresses?.eth_addresses?.[0] || "0x0000000000000000000000000000000000000000"; + return { + primaryAddress: primaryAddr, + ensName: user.username.endsWith(".eth") ? user.username : undefined, + farcaster: user, + addresses: user.verified_addresses?.eth_addresses || [primaryAddr], + source: "farcaster", + }; + }); + return { - users: sortedMatches.slice(0, maxResults), - total: sortedMatches.length, + users: unifiedUsers, + nextCursor: sortedMatches.length > maxResults ? "next" : undefined, }; }; // Demo wrapper with simulation mode -export function ProfileSearchSimulationDemo() { +export function OnchainUserSearchSimulationDemo() { const apiKey = process.env.NEXT_PUBLIC_NEYNAR_API_KEY; return ( <>
- {/* Simulation Mode Banner */} {!apiKey && ( diff --git a/registry/mini-app/lib/address-utils.ts b/registry/mini-app/lib/address-utils.ts new file mode 100644 index 00000000..0510ddab --- /dev/null +++ b/registry/mini-app/lib/address-utils.ts @@ -0,0 +1,71 @@ +import { isAddress, getAddress } from "viem"; + +/** + * Formats an Ethereum address to show first and last few characters + * @param address - The address to format + * @param chars - Number of characters to show at start and end (default: 4) + * @returns Formatted address like "0x1234...5678" + */ +export function formatAddress(address: string, chars = 4): string { + if (!address || address.length < chars * 2 + 2) return address; + return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`; +} + +/** + * Detects the type of input (address, ENS name, or username) + * @param input - The search input + * @returns The detected input type + */ +export type InputType = "address" | "ens" | "username"; + +export function detectInputType(input: string): InputType { + if (!input || input.trim().length === 0) return "username"; + + const trimmed = input.trim(); + + // Check if it's a valid Ethereum address + if (isAddress(trimmed)) { + return "address"; + } + + // Check if it's an ENS name (contains . and not already an address) + if (trimmed.includes(".") && trimmed.length > 3) { + // Common ENS TLDs + const ensPattern = /\.(eth|xyz|luxe|kred|art|club|test)$/i; + if (ensPattern.test(trimmed)) { + return "ens"; + } + } + + // Default to username (Farcaster username or FID) + return "username"; +} + +/** + * Validates and normalizes an Ethereum address + * @param address - The address to validate + * @returns The checksummed address or null if invalid + */ +export function normalizeAddress(address: string): string | null { + try { + if (!isAddress(address)) return null; + return getAddress(address); + } catch { + return null; + } +} + +/** + * Checks if two addresses are equal (case-insensitive) + * @param addr1 - First address + * @param addr2 - Second address + * @returns True if addresses are equal + */ +export function addressesEqual(addr1: string | null | undefined, addr2: string | null | undefined): boolean { + if (!addr1 || !addr2) return false; + try { + return getAddress(addr1).toLowerCase() === getAddress(addr2).toLowerCase(); + } catch { + return false; + } +} \ No newline at end of file diff --git a/registry/mini-app/lib/chains.ts b/registry/mini-app/lib/chains.ts index f4a1a3a2..e590e338 100644 --- a/registry/mini-app/lib/chains.ts +++ b/registry/mini-app/lib/chains.ts @@ -104,3 +104,16 @@ export function findChainByName(networkName: string): Chain | undefined { const chainId = nameToId[normalizedName]; return chainId ? getChainById(chainId) : undefined; } + +/** + * Get Alchemy RPC endpoint URL for a specific chain + */ +export function getAlchemyEndpoint(chainId: number, apiKey: string): string | undefined { + const config = SUPPORTED_CHAINS.find((c) => c.id === chainId); + + if (config?.alchemyPrefix && apiKey) { + return `https://${config.alchemyPrefix}.g.alchemy.com/v2/${apiKey}`; + } + + return undefined; +} From 25c5d306819d5604aeb0ec0d698c4bd8f7d4e817 Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 15 Jul 2025 16:14:59 +0200 Subject: [PATCH 02/12] Update registry installation and dependency handling The commit describes the changes to improve the registry installation process and dependency handling. The key changes include: 1. Adding install scripts for both normal and overwrite modes 2. Removing all-components.json in favor of shell scripts 3. Using relative paths for registry dependencies 4. Adding test NFTs and gitignore patterns for testing 5. Updating type comparisons to use consistent double quotes --- .gitignore | 5 + CLAUDE.md | 39 ++++- README.md | 41 ++++- components/nft-card-examples.tsx | 10 ++ components/nft-mint-examples.tsx | 8 + docs/REGISTRY_INSTALLATION.md | 158 ++++++++++++++++++ package.json | 5 +- public/r/add-miniapp-button.json | 2 +- public/r/all-components.json | 28 ---- public/r/install-all-overwrite.sh | 43 +++++ public/r/install-all.sh | 43 +++++ public/r/nft-card.json | 2 +- public/r/nft-mint-flow.json | 4 +- public/r/onchain-user-search.json | 2 +- public/r/registry.json | 10 +- public/r/share-cast-button.json | 2 +- public/r/use-profile.json | 2 +- registry.json | 10 +- .../blocks/manifold-nft-mint/config.ts | 4 +- .../mini-app/blocks/nft-card/nft-card.tsx | 8 +- scripts/generate-all-components.js | 39 ----- 21 files changed, 369 insertions(+), 96 deletions(-) create mode 100644 docs/REGISTRY_INSTALLATION.md delete mode 100644 public/r/all-components.json create mode 100644 public/r/install-all-overwrite.sh create mode 100644 public/r/install-all.sh delete mode 100644 scripts/generate-all-components.js diff --git a/.gitignore b/.gitignore index 6436d77f..47420862 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +**/node_modules /.pnp .pnp.* .yarn/* @@ -40,3 +41,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts .aider* +/test-shadcn-install +/tmp-test-* +/test-minimal-* +/test-registry-* diff --git a/CLAUDE.md b/CLAUDE.md index c73aa5f4..dc10d4a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,4 +304,41 @@ The shadcn CLI sometimes adds extra quotes around string literals during install if (typeof x === "object") {} ``` -3. **Test registry output** - After running `pnpm registry:build`, check the generated JSON files in `public/r/` to ensure no transformation issues \ No newline at end of file +3. **Test registry output** - After running `pnpm registry:build`, check the generated JSON files in `public/r/` to ensure no transformation issues + +### Critical: TypeScript typeof Comparisons + +The shadcn CLI has a bug where it adds extra quotes around string literals in typeof comparisons, breaking TypeScript builds: + +```typescript +// BEFORE installation (in registry): +if (typeof width === "string") { } + +// AFTER installation (broken - extra quotes): +if (typeof width === "'string'") { } +``` + +**Solution: Always use double quotes for typeof comparisons and test after installation:** + +```typescript +// ✅ REQUIRED: Use double quotes consistently +if (typeof value === "string") { } +if (typeof value === "number") { } +if (typeof value === "bigint") { } +if (typeof value === "object") { } +if (typeof value === "boolean") { } +if (typeof value === "undefined") { } +if (typeof value === "function") { } +if (typeof value === "symbol") { } + +// ❌ NEVER: Don't use single quotes +if (typeof value === 'string') { } // Will break during installation +``` + +**Common locations where this occurs:** +- Width/height checks: `typeof width === "string"` +- Data validation: `typeof data !== "object"` +- Number checks: `typeof value === "number"` +- BigInt validation: `typeof id !== "bigint"` + +**Always run `pnpm registry:build` and test installation before committing!** \ No newline at end of file diff --git a/README.md b/README.md index 0032449f..52f6d51c 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,54 @@ Website: [https://hellno-mini-app-ui.vercel.app](https://hellno-mini-app-ui.verc Website: [https://hellno-mini-app-ui.vercel.app](https://hellno-mini-app-ui.vercel.app) -### Install a component +### Quick Start (Recommended) -Example to install simple token transfer button: +Install the most popular components: ```bash +# NFT display and minting +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/nft-card.json +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/nft-mint-flow.json + +# User search (Farcaster, ENS, addresses) +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/onchain-user-search.json + +# Payment button pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/daimo-pay-transfer-button.json ``` +### Install individual components + +```bash +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/[component-name].json +``` + ### Install all components +Install all components with a single command: + ```bash -pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/all-components.json +# Default: Skip existing files +curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all.sh | bash + +# Force overwrite existing files +curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all-overwrite.sh | bash ``` -This will install all available components and their dependencies at once. +### Local Development + +When developing the registry locally: + +```bash +# Start the registry locally first (in mini-app-ui repo) +pnpm dev + +# Install individual components from localhost +pnpm dlx shadcn@latest add http://localhost:3000/r/nft-card.json --yes + +# Note: install-all.sh may reference production URLs in dependencies +# For local development, install components individually +``` ## Component Development Guide diff --git a/components/nft-card-examples.tsx b/components/nft-card-examples.tsx index 43be17ef..bf37eb45 100644 --- a/components/nft-card-examples.tsx +++ b/components/nft-card-examples.tsx @@ -66,6 +66,16 @@ const nftExamples: NFTExample[] = [ network: "base", networkPosition: "outside", }, + { + title: "Test NFT on Base", + description: "Testing NFT processing", + contractAddress: "0x8F843c58201197D20A4ed8AeC12ae8527c2c4d7b", + tokenId: "1", + network: "base", + titlePosition: "outside", + networkPosition: "outside", + layout: "detailed", + }, { title: "Azuki", description: "Title inside and blockchain in top right", diff --git a/components/nft-mint-examples.tsx b/components/nft-mint-examples.tsx index 46615983..38ff808e 100644 --- a/components/nft-mint-examples.tsx +++ b/components/nft-mint-examples.tsx @@ -13,6 +13,14 @@ interface NFTMintExample { } const nftExamples: NFTMintExample[] = [ + { + title: "Test NFT - No Image", + description: "Testing NFT processing for contract without metadata", + contractAddress: "0x8F843c58201197D20A4ed8AeC12ae8527c2c4d7b", + instanceId: "", + tokenId: "", + buttonText: "Mint Test NFT", + }, { title: "Generic NFT Mint", description: "", diff --git a/docs/REGISTRY_INSTALLATION.md b/docs/REGISTRY_INSTALLATION.md new file mode 100644 index 00000000..df0a5115 --- /dev/null +++ b/docs/REGISTRY_INSTALLATION.md @@ -0,0 +1,158 @@ +# Registry Installation Guide + +## Quick Start + +The recommended approach for installing hellno/mini-app-ui components: + +### Option 1: Install Popular Components (Recommended) + +```bash +# NFT display and minting +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/nft-card.json +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/nft-mint-flow.json + +# User search (Farcaster, ENS, addresses) +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/onchain-user-search.json + +# Payment button +pnpm dlx shadcn@latest add https://hellno-mini-app-ui.vercel.app/r/daimo-pay-transfer-button.json +``` + +### Option 2: Install All Components (Recommended) + +Install all components with a single command: + +```bash +# Default: Skip existing files (safe for updates) +curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all.sh | bash + +# Force overwrite all files +curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all-overwrite.sh | bash +``` + +This approach: +- ✅ Skips prompts automatically (no hanging scripts) +- ✅ Preserves your modifications by default +- ✅ Installs components in dependency order +- ✅ Works with both local and production registries +- ✅ Single command, no configuration needed + +## Technical Details + +### How Install Scripts Work + +The install scripts (`install-all.sh` and `install-essential.sh`) provide a reliable way to install components: + +1. **Dependency Order**: Components are installed in the correct order, with shared libraries first, then hooks, UI primitives, and finally complex components. + +2. **Individual Installations**: Each component is installed separately using `pnpm dlx shadcn@latest add`, which: + - Avoids pnpm store version conflicts + - Shows progress for each component + - Makes it easy to identify which component fails if there's an error + +3. **Component Categories**: + - **Shared Libraries**: Core utilities like chains, nft-standards, utils + - **Hooks**: React hooks like use-miniapp-sdk, use-profile + - **UI Primitives**: Basic components like button, input, card + - **Components**: Complex components like nft-card, onchain-user-search + +4. **Essential vs All**: + - `install-essential.sh`: Installs only the most commonly used components (~9 components) + - `install-all.sh`: Installs the complete registry (~25+ components) + +### File Structure After Installation + +The shadcn CLI will place files based on your `components.json` configuration: +``` +your-project/ +├── components/ # Main components and UI primitives +├── lib/ # Utility libraries +├── hooks/ # React hooks +└── registry/ # Complex components (if alias configured) + └── mini-app/ + ├── blocks/ + ├── hooks/ + └── lib/ +``` + +### Installation Requirements + +Before installing, ensure you have: +1. A Next.js project with TypeScript +2. Tailwind CSS configured +3. A proper `components.json` file with aliases: +```json +{ + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} +``` + +## Troubleshooting + +### Common Issues + +1. **"ERR_PNPM_UNEXPECTED_STORE" error** + - This happens when different pnpm versions are mixed + - Solution: Clean reinstall your project dependencies + ```bash + rm -rf node_modules pnpm-lock.yaml + pnpm install + ``` + - Alternative: Use `npx` instead of `pnpm dlx` in the install commands + +2. **"Invalid configuration" error** + - Ensure your `components.json` has all required fields + - Check that the `css` path exists in your project + +3. **Files not in expected locations** + - The shadcn CLI uses your `components.json` aliases + - Registry paths are transformed to match your project structure + - Check `components/`, `lib/`, and `hooks/` directories + +4. **Import errors after installation** + - Ensure your `tsconfig.json` has proper path mappings + - Restart your TypeScript server + - Check that all files were installed + +### Manual File Verification + +To verify all files were installed: +```bash +# Count installed files +find . -name "*.tsx" -o -name "*.ts" | grep -E "(components|lib|hooks)" | wc -l +# Should show ~25+ files for full installation +``` + +## Development Notes + +For contributors: +- Run `pnpm registry:build` to rebuild all registry files including install scripts +- Test with `pnpm registry:test` for installation verification +- The `generate-install-script.js` creates install scripts with components in dependency order +- Scripts are generated for both local (localhost:3000) and production URLs + +## Testing Your Installation + +After installing, verify everything works: + +```typescript +// Test imports +import { NFTCard } from "@/components/nft-card" +import { getChainById } from "@/lib/chains" +import { OnchainUserSearch } from "@/components/onchain-user-search" + +// If these imports fail, some dependencies are missing +``` + +## Getting Help + +If you encounter issues: +1. Check which files are missing +2. Install the specific component that includes those files +3. Report issues at: https://github.com/hellno/mini-app-ui/issues \ No newline at end of file diff --git a/package.json b/package.json index 780d7120..87f74db5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "lint": "next lint", "prepare": "husky", "test:nft": "tsx scripts/test-nft-contracts.ts", - "registry:build": "shadcn registry:build && node scripts/clean-registry.js && node scripts/generate-all-components.js" + "registry:build": "shadcn registry:build && node scripts/clean-registry.js", + "registry:test": "node scripts/test-registry-install.js", + "registry:test:build": "node scripts/test-registry-install.js --build", + "registry:test:keep": "node scripts/test-registry-install.js --keep" }, "dependencies": { "@daimo/contract": "^1.6.0", diff --git a/public/r/add-miniapp-button.json b/public/r/add-miniapp-button.json index 144434eb..5ea9737b 100644 --- a/public/r/add-miniapp-button.json +++ b/public/r/add-miniapp-button.json @@ -15,7 +15,7 @@ ], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "files": [ { diff --git a/public/r/all-components.json b/public/r/all-components.json deleted file mode 100644 index de4e428f..00000000 --- a/public/r/all-components.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "all-components", - "type": "registry:component", - "title": "All Mini App UI Components", - "description": "Install all hellno/mini-app-ui components at once", - "dependencies": [ - "@daimo/contract", - "@daimo/pay", - "@farcaster/frame-wagmi-connector", - "alchemy-sdk", - "lucide-react", - "viem", - "wagmi" - ], - "registryDependencies": [ - "https://hellno-mini-app-ui.vercel.app/r/daimo-pay-transfer-button.json", - "https://hellno-mini-app-ui.vercel.app/r/share-cast-button.json", - "https://hellno-mini-app-ui.vercel.app/r/add-miniapp-button.json", - "https://hellno-mini-app-ui.vercel.app/r/show-coin-balance.json", - "https://hellno-mini-app-ui.vercel.app/r/avatar.json", - "https://hellno-mini-app-ui.vercel.app/r/user-context.json", - "https://hellno-mini-app-ui.vercel.app/r/nft-card.json", - "https://hellno-mini-app-ui.vercel.app/r/onchain-user-search.json", - "https://hellno-mini-app-ui.vercel.app/r/nft-mint-flow.json" - ], - "files": [] -} \ No newline at end of file diff --git a/public/r/install-all-overwrite.sh b/public/r/install-all-overwrite.sh new file mode 100644 index 00000000..6922134c --- /dev/null +++ b/public/r/install-all-overwrite.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Install all components from hellno-mini-app-ui registry (overwrite existing files) +# Usage: curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all-overwrite.sh | bash + +REGISTRY_URL="https://hellno-mini-app-ui.vercel.app" + +# Install shared libraries first +echo "Installing shared libraries (overwrite mode)..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/chains.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/utils.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/address-utils.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/avatar-utils.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/text-utils.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-standards.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/manifold-utils.json --overwrite --yes + +# Install hooks +echo "Installing hooks (overwrite mode)..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/use-miniapp-sdk.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/use-profile.json --overwrite --yes + +# Install UI primitives +echo "Installing UI primitives (overwrite mode)..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/button.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/input.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/card.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/sheet.json --overwrite --yes + +# Install components +echo "Installing components (overwrite mode)..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/avatar.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/user-context.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/add-miniapp-button.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/share-cast-button.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/show-coin-balance.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-card.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-mint-flow.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/manifold-nft-mint.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/onchain-user-search.json --overwrite --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/daimo-pay-transfer-button.json --overwrite --yes + +echo "✅ All components installed successfully (overwrite mode)!" \ No newline at end of file diff --git a/public/r/install-all.sh b/public/r/install-all.sh new file mode 100644 index 00000000..aab94f49 --- /dev/null +++ b/public/r/install-all.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Install all components from hellno-mini-app-ui registry +# Usage: curl -sSL https://hellno-mini-app-ui.vercel.app/r/install-all.sh | bash + +REGISTRY_URL="https://hellno-mini-app-ui.vercel.app" + +# Install shared libraries first +echo "Installing shared libraries..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/chains.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/utils.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/address-utils.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/avatar-utils.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/text-utils.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-standards.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/manifold-utils.json --yes + +# Install hooks +echo "Installing hooks..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/use-miniapp-sdk.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/use-profile.json --yes + +# Install UI primitives +echo "Installing UI primitives..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/button.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/input.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/card.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/sheet.json --yes + +# Install components +echo "Installing components..." +pnpm dlx shadcn@latest add $REGISTRY_URL/r/avatar.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/user-context.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/add-miniapp-button.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/share-cast-button.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/show-coin-balance.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-card.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/nft-mint-flow.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/manifold-nft-mint.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/onchain-user-search.json --yes +pnpm dlx shadcn@latest add $REGISTRY_URL/r/daimo-pay-transfer-button.json --yes + +echo "✅ All components installed successfully!" \ No newline at end of file diff --git a/public/r/nft-card.json b/public/r/nft-card.json index ce564a79..1483228d 100644 --- a/public/r/nft-card.json +++ b/public/r/nft-card.json @@ -18,7 +18,7 @@ "files": [ { "path": "registry/mini-app/blocks/nft-card/nft-card.tsx", - "content": "\"use client\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\nimport Image from \"next/image\";\nimport { useState, useEffect, useRef } from \"react\";\n\ninterface NFTMetadata {\n name?: string;\n description?: string;\n image?: string;\n image_url?: string;\n external_url?: string;\n attributes?: Array<{\n trait_type: string;\n value: string | number;\n display_type?: string;\n }>;\n image_details?: {\n bytes?: number;\n format?: string;\n sha256?: string;\n width?: number;\n height?: number;\n };\n [key: string]: unknown;\n}\nimport { getAddress, type Address } from \"viem\";\nimport { \n findChainByName, \n getPublicClient \n} from \"@/registry/mini-app/lib/chains\";\nimport { \n ERC721_ABI, \n ipfsToHttp \n} from \"@/registry/mini-app/lib/nft-standards\";\nimport { \n getTokenURIWithManifoldSupport \n} from \"@/registry/mini-app/lib/manifold-utils\";\n\n// Base64 placeholder image\nconst PLACEHOLDER_IMAGE =\n \"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2YxZjFmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGZpbGw9IiM5OTkiPk5GVCBJbWFnZTwvdGV4dD48L3N2Zz4=\";\n\n\ntype NFTCardProps = {\n contractAddress: string;\n tokenId: string;\n network?: string;\n alt?: string;\n className?: string;\n width?: number | string;\n height?: number | string;\n rounded?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n shadow?: boolean;\n objectFit?: \"contain\" | \"cover\" | \"fill\";\n fallbackImageUrl?: string;\n showTitle?: boolean;\n showNetwork?: boolean;\n titlePosition?: \"top\" | \"bottom\" | \"outside\";\n networkPosition?:\n | \"top-left\"\n | \"top-right\"\n | \"bottom-left\"\n | \"bottom-right\"\n | \"outside\";\n customTitle?: string;\n customNetworkName?: string;\n loadingComponent?: React.ReactNode;\n errorComponent?: React.ReactNode;\n imageProps?: React.ComponentProps;\n titleClassName?: string;\n networkClassName?: string;\n showOwner?: boolean;\n onLoad?: (metadata: NFTMetadata) => void;\n onError?: (error: Error) => void;\n layout?: \"compact\" | \"card\" | \"detailed\";\n containerClassName?: string;\n};\n\nexport function NFTCard({\n contractAddress,\n tokenId,\n network = \"ethereum\", // Default to Ethereum mainnet\n alt = \"NFT Image\",\n className = \"\",\n width = 300,\n height = 300,\n rounded = \"md\",\n shadow = true,\n objectFit = \"cover\",\n fallbackImageUrl = PLACEHOLDER_IMAGE,\n showTitle = true,\n showNetwork = true,\n titlePosition = \"outside\",\n networkPosition = \"top-right\",\n customTitle,\n customNetworkName,\n loadingComponent,\n errorComponent,\n imageProps,\n titleClassName = \"\",\n networkClassName = \"\",\n showOwner = false,\n onLoad,\n onError,\n layout = \"card\",\n containerClassName = \"\",\n}: NFTCardProps) {\n const [imageUrl, setImageUrl] = useState(fallbackImageUrl);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState(null);\n const [title, setTitle] = useState(customTitle || null);\n const [networkName, setNetworkName] = useState(\n customNetworkName || \"\",\n );\n const [owner, setOwner] = useState(null);\n const [metadata, setMetadata] = useState(null);\n const abortControllerRef = useRef(null);\n\n const roundedClasses = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n xl: \"rounded-xl\",\n full: \"rounded-full\",\n };\n\n const networkPositionClasses = {\n \"top-left\": \"top-0 left-0 rounded-br-md\",\n \"top-right\": \"top-0 right-0 rounded-bl-md\",\n \"bottom-left\": \"bottom-0 left-0 rounded-tr-md\",\n \"bottom-right\": \"bottom-0 right-0 rounded-tl-md\",\n outside: \"\",\n };\n\n useEffect(() => {\n if (customTitle) {\n setTitle(customTitle);\n }\n\n if (customNetworkName) {\n setNetworkName(customNetworkName);\n }\n }, [customTitle, customNetworkName]);\n\n useEffect(() => {\n const fetchNFTData = async () => {\n if (!contractAddress || !tokenId) return;\n \n // Cancel any previous request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n \n // Create new AbortController for this request\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n setIsLoading(true);\n setError(null);\n\n try {\n // Skip chain setup if we have customNetworkName\n if (!customNetworkName) {\n // Find the chain by name using shared utility\n const selectedChain = findChainByName(network || \"ethereum\");\n \n if (!selectedChain) {\n console.warn(\n `Chain \"${network}\" not found, defaulting to Ethereum mainnet`,\n );\n setNetworkName(\"Ethereum\");\n } else {\n setNetworkName(selectedChain.name);\n }\n\n // Create public client using shared utility\n const client = getPublicClient(selectedChain?.id || 1);\n\n console.log(\n `Fetching NFT data from ${selectedChain?.name || 'Ethereum'} for contract ${contractAddress} token ${tokenId}`,\n );\n\n // Skip title setup if we have customTitle\n if (!customTitle) {\n try {\n // Get contract name\n const name = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.name,\n functionName: \"name\",\n })) as string;\n\n // Set title\n setTitle(`${name} #${tokenId}`);\n } catch (nameError) {\n console.warn(\"Could not fetch NFT name:\", nameError);\n setTitle(`NFT #${tokenId}`);\n }\n }\n\n // Get owner if requested\n if (showOwner) {\n try {\n const ownerAddress = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.ownerOf,\n functionName: \"ownerOf\",\n args: [BigInt(tokenId)],\n })) as string;\n\n setOwner(ownerAddress);\n } catch (ownerError) {\n console.warn(\"Could not fetch NFT owner:\", ownerError);\n }\n }\n\n // Get tokenURI with automatic Manifold support\n let metadataUrl = await getTokenURIWithManifoldSupport(\n client,\n getAddress(contractAddress) as Address,\n tokenId\n );\n\n // Handle IPFS URLs using shared utility\n metadataUrl = ipfsToHttp(metadataUrl);\n\n // Fetch metadata with abort signal\n const response = await fetch(metadataUrl, {\n signal: abortController.signal\n });\n \n if (!response.ok) {\n throw new Error(`Failed to fetch metadata: ${response.status}`);\n }\n \n const fetchedMetadata = await response.json();\n console.log(\"NFT metadata:\", fetchedMetadata);\n \n // Store metadata in state\n setMetadata(fetchedMetadata);\n\n // Call onLoad callback if provided\n if (onLoad) {\n onLoad(fetchedMetadata);\n }\n\n // Get image URL from metadata\n let nftImageUrl = fetchedMetadata.image || fetchedMetadata.image_url;\n\n // Handle IPFS URLs for image using shared utility\n if (nftImageUrl) {\n nftImageUrl = ipfsToHttp(nftImageUrl);\n }\n\n if (nftImageUrl) {\n setImageUrl(nftImageUrl);\n } else {\n // If no image URL found, use placeholder\n setImageUrl(fallbackImageUrl);\n }\n }\n } catch (err) {\n // Don't update state if request was aborted\n if (err instanceof Error && err.name === 'AbortError') {\n console.log('NFT data fetch was cancelled');\n return;\n }\n \n console.error(\"Error fetching NFT:\", err);\n const error = err instanceof Error ? err : new Error(String(err));\n setError(`Failed to load NFT data: ${error.message}`);\n setImageUrl(fallbackImageUrl);\n\n // Call onError callback if provided\n if (onError) {\n onError(error);\n }\n } finally {\n // Only update loading state if this request wasn't aborted\n if (!abortController.signal.aborted) {\n setIsLoading(false);\n }\n }\n };\n\n fetchNFTData();\n \n // Cleanup function to abort request if component unmounts or deps change\n return () => {\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n };\n }, [\n contractAddress,\n tokenId,\n network,\n fallbackImageUrl,\n customTitle,\n customNetworkName,\n showOwner,\n onLoad,\n onError,\n ]);\n\n const defaultLoadingComponent = (\n
\n
\n
\n );\n\n const defaultErrorComponent = (\n
\n

{error}

\n
\n );\n\n // Render network badge inside the image\n const renderNetworkBadge = () => {\n if (!showNetwork || !networkName || networkPosition === \"outside\")\n return null;\n\n return (\n \n {networkName}\n
\n );\n };\n\n // Render title inside the image\n const renderInnerTitle = () => {\n if (!showTitle || !title || titlePosition === \"outside\") return null;\n\n return (\n \n {title}\n {showOwner && owner && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n
\n );\n };\n\n // Render outside information (title, network, owner)\n const renderOutsideInfo = () => {\n if (\n (!showTitle || !title) &&\n (!showNetwork || !networkName || networkPosition !== \"outside\") &&\n (!showOwner || !owner || titlePosition !== \"outside\")\n ) {\n return null;\n }\n\n return (\n
\n {showTitle && title && titlePosition === \"outside\" && (\n
\n {title}\n
\n )}\n\n {showNetwork && networkName && networkPosition === \"outside\" && (\n \n Network: {networkName}\n
\n )}\n\n {showOwner && owner && titlePosition === \"outside\" && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n
\n );\n };\n\n // Apply different layouts\n const getContainerClasses = () => {\n switch (layout) {\n case \"compact\":\n return \"inline-block\";\n case \"detailed\":\n return \"flex flex-col overflow-hidden\";\n case \"card\":\n default:\n return \"\";\n }\n };\n\n // Calculate display dimensions that preserve aspect ratio\n const getDisplayDimensions = () => {\n // Handle percentage values\n const isPercentageWidth = typeof width === 'string' && width.includes('%');\n const isPercentageHeight = typeof height === 'string' && height.includes('%');\n \n if (isPercentageWidth || isPercentageHeight) {\n return { \n width: width || '100%', \n height: height || '100%', \n useContain: false,\n isPercentage: true\n };\n }\n \n const maxWidth = typeof width === 'number' ? width : 300;\n const maxHeight = typeof height === 'number' ? height : 300;\n \n // Check if we have image_details with dimensions\n if (metadata?.image_details?.width && metadata?.image_details?.height) {\n const originalAspectRatio = metadata.image_details.width / metadata.image_details.height;\n \n // Scale to fit within bounds while preserving aspect ratio\n const widthBasedHeight = maxWidth / originalAspectRatio;\n const heightBasedWidth = maxHeight * originalAspectRatio;\n \n if (widthBasedHeight <= maxHeight) {\n // Width is the limiting factor\n return { \n width: maxWidth, \n height: Math.round(widthBasedHeight),\n useContain: true, // Use contain to show full image\n isPercentage: false\n };\n } else {\n // Height is the limiting factor\n return { \n width: Math.round(heightBasedWidth), \n height: maxHeight,\n useContain: true,\n isPercentage: false\n };\n }\n }\n \n // No image_details, use provided dimensions\n return { width: maxWidth, height: maxHeight, useContain: false, isPercentage: false };\n };\n\n const displayDimensions = getDisplayDimensions();\n\n return (\n
\n \n {isLoading && (loadingComponent || defaultLoadingComponent)}\n\n {error && (errorComponent || defaultErrorComponent)}\n\n setImageUrl(PLACEHOLDER_IMAGE)}\n {...imageProps}\n />\n\n {renderInnerTitle()}\n {renderNetworkBadge()}\n
\n\n {renderOutsideInfo()}\n
\n );\n}\n", + "content": "\"use client\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\nimport Image from \"next/image\";\nimport { useState, useEffect, useRef } from \"react\";\n\ninterface NFTMetadata {\n name?: string;\n description?: string;\n image?: string;\n image_url?: string;\n external_url?: string;\n attributes?: Array<{\n trait_type: string;\n value: string | number;\n display_type?: string;\n }>;\n image_details?: {\n bytes?: number;\n format?: string;\n sha256?: string;\n width?: number;\n height?: number;\n };\n [key: string]: unknown;\n}\nimport { getAddress, type Address } from \"viem\";\nimport { \n findChainByName, \n getPublicClient \n} from \"@/registry/mini-app/lib/chains\";\nimport { \n ERC721_ABI, \n ipfsToHttp \n} from \"@/registry/mini-app/lib/nft-standards\";\nimport { \n getTokenURIWithManifoldSupport \n} from \"@/registry/mini-app/lib/manifold-utils\";\n\n// Base64 placeholder image\nconst PLACEHOLDER_IMAGE =\n \"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2YxZjFmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGZpbGw9IiM5OTkiPk5GVCBJbWFnZTwvdGV4dD48L3N2Zz4=\";\n\n\ntype NFTCardProps = {\n contractAddress: string;\n tokenId: string;\n network?: string;\n alt?: string;\n className?: string;\n width?: number | string;\n height?: number | string;\n rounded?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n shadow?: boolean;\n objectFit?: \"contain\" | \"cover\" | \"fill\";\n fallbackImageUrl?: string;\n showTitle?: boolean;\n showNetwork?: boolean;\n titlePosition?: \"top\" | \"bottom\" | \"outside\";\n networkPosition?:\n | \"top-left\"\n | \"top-right\"\n | \"bottom-left\"\n | \"bottom-right\"\n | \"outside\";\n customTitle?: string;\n customNetworkName?: string;\n loadingComponent?: React.ReactNode;\n errorComponent?: React.ReactNode;\n imageProps?: React.ComponentProps;\n titleClassName?: string;\n networkClassName?: string;\n showOwner?: boolean;\n onLoad?: (metadata: NFTMetadata) => void;\n onError?: (error: Error) => void;\n layout?: \"compact\" | \"card\" | \"detailed\";\n containerClassName?: string;\n};\n\nexport function NFTCard({\n contractAddress,\n tokenId,\n network = \"ethereum\", // Default to Ethereum mainnet\n alt = \"NFT Image\",\n className = \"\",\n width = 300,\n height = 300,\n rounded = \"md\",\n shadow = true,\n objectFit = \"cover\",\n fallbackImageUrl = PLACEHOLDER_IMAGE,\n showTitle = true,\n showNetwork = true,\n titlePosition = \"outside\",\n networkPosition = \"top-right\",\n customTitle,\n customNetworkName,\n loadingComponent,\n errorComponent,\n imageProps,\n titleClassName = \"\",\n networkClassName = \"\",\n showOwner = false,\n onLoad,\n onError,\n layout = \"card\",\n containerClassName = \"\",\n}: NFTCardProps) {\n const [imageUrl, setImageUrl] = useState(fallbackImageUrl);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState(null);\n const [title, setTitle] = useState(customTitle || null);\n const [networkName, setNetworkName] = useState(\n customNetworkName || \"\",\n );\n const [owner, setOwner] = useState(null);\n const [metadata, setMetadata] = useState(null);\n const abortControllerRef = useRef(null);\n\n const roundedClasses = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n xl: \"rounded-xl\",\n full: \"rounded-full\",\n };\n\n const networkPositionClasses = {\n \"top-left\": \"top-0 left-0 rounded-br-md\",\n \"top-right\": \"top-0 right-0 rounded-bl-md\",\n \"bottom-left\": \"bottom-0 left-0 rounded-tr-md\",\n \"bottom-right\": \"bottom-0 right-0 rounded-tl-md\",\n outside: \"\",\n };\n\n useEffect(() => {\n if (customTitle) {\n setTitle(customTitle);\n }\n\n if (customNetworkName) {\n setNetworkName(customNetworkName);\n }\n }, [customTitle, customNetworkName]);\n\n useEffect(() => {\n const fetchNFTData = async () => {\n if (!contractAddress || !tokenId) return;\n \n // Cancel any previous request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n \n // Create new AbortController for this request\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n setIsLoading(true);\n setError(null);\n\n try {\n // Skip chain setup if we have customNetworkName\n if (!customNetworkName) {\n // Find the chain by name using shared utility\n const selectedChain = findChainByName(network || \"ethereum\");\n \n if (!selectedChain) {\n console.warn(\n `Chain \"${network}\" not found, defaulting to Ethereum mainnet`,\n );\n setNetworkName(\"Ethereum\");\n } else {\n setNetworkName(selectedChain.name);\n }\n\n // Create public client using shared utility\n const client = getPublicClient(selectedChain?.id || 1);\n\n console.log(\n `Fetching NFT data from ${selectedChain?.name || 'Ethereum'} for contract ${contractAddress} token ${tokenId}`,\n );\n\n // Skip title setup if we have customTitle\n if (!customTitle) {\n try {\n // Get contract name\n const name = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.name,\n functionName: \"name\",\n })) as string;\n\n // Set title\n setTitle(`${name} #${tokenId}`);\n } catch (nameError) {\n console.warn(\"Could not fetch NFT name:\", nameError);\n setTitle(`NFT #${tokenId}`);\n }\n }\n\n // Get owner if requested\n if (showOwner) {\n try {\n const ownerAddress = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.ownerOf,\n functionName: \"ownerOf\",\n args: [BigInt(tokenId)],\n })) as string;\n\n setOwner(ownerAddress);\n } catch (ownerError) {\n console.warn(\"Could not fetch NFT owner:\", ownerError);\n }\n }\n\n // Get tokenURI with automatic Manifold support\n let metadataUrl = await getTokenURIWithManifoldSupport(\n client,\n getAddress(contractAddress) as Address,\n tokenId\n );\n\n // Handle IPFS URLs using shared utility\n metadataUrl = ipfsToHttp(metadataUrl);\n\n // Fetch metadata with abort signal\n const response = await fetch(metadataUrl, {\n signal: abortController.signal\n });\n \n if (!response.ok) {\n throw new Error(`Failed to fetch metadata: ${response.status}`);\n }\n \n const fetchedMetadata = await response.json();\n console.log(\"NFT metadata:\", fetchedMetadata);\n \n // Store metadata in state\n setMetadata(fetchedMetadata);\n\n // Call onLoad callback if provided\n if (onLoad) {\n onLoad(fetchedMetadata);\n }\n\n // Get image URL from metadata\n let nftImageUrl = fetchedMetadata.image || fetchedMetadata.image_url;\n\n // Handle IPFS URLs for image using shared utility\n if (nftImageUrl) {\n nftImageUrl = ipfsToHttp(nftImageUrl);\n }\n\n if (nftImageUrl) {\n setImageUrl(nftImageUrl);\n } else {\n // If no image URL found, use placeholder\n setImageUrl(fallbackImageUrl);\n }\n }\n } catch (err) {\n // Don't update state if request was aborted\n if (err instanceof Error && err.name === 'AbortError') {\n console.log('NFT data fetch was cancelled');\n return;\n }\n \n console.error(\"Error fetching NFT:\", err);\n const error = err instanceof Error ? err : new Error(String(err));\n setError(`Failed to load NFT data: ${error.message}`);\n setImageUrl(fallbackImageUrl);\n\n // Call onError callback if provided\n if (onError) {\n onError(error);\n }\n } finally {\n // Only update loading state if this request wasn't aborted\n if (!abortController.signal.aborted) {\n setIsLoading(false);\n }\n }\n };\n\n fetchNFTData();\n \n // Cleanup function to abort request if component unmounts or deps change\n return () => {\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n };\n }, [\n contractAddress,\n tokenId,\n network,\n fallbackImageUrl,\n customTitle,\n customNetworkName,\n showOwner,\n onLoad,\n onError,\n ]);\n\n const defaultLoadingComponent = (\n
\n
\n
\n );\n\n const defaultErrorComponent = (\n
\n

{error}

\n
\n );\n\n // Render network badge inside the image\n const renderNetworkBadge = () => {\n if (!showNetwork || !networkName || networkPosition === \"outside\")\n return null;\n\n return (\n \n {networkName}\n \n );\n };\n\n // Render title inside the image\n const renderInnerTitle = () => {\n if (!showTitle || !title || titlePosition === \"outside\") return null;\n\n return (\n \n {title}\n {showOwner && owner && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Render outside information (title, network, owner)\n const renderOutsideInfo = () => {\n if (\n (!showTitle || !title) &&\n (!showNetwork || !networkName || networkPosition !== \"outside\") &&\n (!showOwner || !owner || titlePosition !== \"outside\")\n ) {\n return null;\n }\n\n return (\n
\n {showTitle && title && titlePosition === \"outside\" && (\n
\n {title}\n
\n )}\n\n {showNetwork && networkName && networkPosition === \"outside\" && (\n \n Network: {networkName}\n
\n )}\n\n {showOwner && owner && titlePosition === \"outside\" && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Apply different layouts\n const getContainerClasses = () => {\n switch (layout) {\n case \"compact\":\n return \"inline-block\";\n case \"detailed\":\n return \"flex flex-col overflow-hidden\";\n case \"card\":\n default:\n return \"\";\n }\n };\n\n // Calculate display dimensions that preserve aspect ratio\n const getDisplayDimensions = () => {\n // Handle percentage values\n const isPercentageWidth = typeof width === \"string\" && width.includes(\"%\");\n const isPercentageHeight = typeof height === \"string\" && height.includes(\"%\");\n \n if (isPercentageWidth || isPercentageHeight) {\n return { \n width: width || '100%', \n height: height || '100%', \n useContain: false,\n isPercentage: true\n };\n }\n \n const maxWidth = typeof width === \"number\" ? width : 300;\n const maxHeight = typeof height === \"number\" ? height : 300;\n \n // Check if we have image_details with dimensions\n if (metadata?.image_details?.width && metadata?.image_details?.height) {\n const originalAspectRatio = metadata.image_details.width / metadata.image_details.height;\n \n // Scale to fit within bounds while preserving aspect ratio\n const widthBasedHeight = maxWidth / originalAspectRatio;\n const heightBasedWidth = maxHeight * originalAspectRatio;\n \n if (widthBasedHeight <= maxHeight) {\n // Width is the limiting factor\n return { \n width: maxWidth, \n height: Math.round(widthBasedHeight),\n useContain: true, // Use contain to show full image\n isPercentage: false\n };\n } else {\n // Height is the limiting factor\n return { \n width: Math.round(heightBasedWidth), \n height: maxHeight,\n useContain: true,\n isPercentage: false\n };\n }\n }\n \n // No image_details, use provided dimensions\n return { width: maxWidth, height: maxHeight, useContain: false, isPercentage: false };\n };\n\n const displayDimensions = getDisplayDimensions();\n\n return (\n
\n \n {isLoading && (loadingComponent || defaultLoadingComponent)}\n\n {error && (errorComponent || defaultErrorComponent)}\n\n setImageUrl(PLACEHOLDER_IMAGE)}\n {...imageProps}\n />\n\n {renderInnerTitle()}\n {renderNetworkBadge()}\n
\n\n {renderOutsideInfo()}\n \n );\n}\n", "type": "registry:component" }, { diff --git a/public/r/nft-mint-flow.json b/public/r/nft-mint-flow.json index 4c45f5b8..46315a55 100644 --- a/public/r/nft-mint-flow.json +++ b/public/r/nft-mint-flow.json @@ -20,7 +20,7 @@ "registryDependencies": [ "button", "sheet", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/nft-standards.json", "https://hellno-mini-app-ui.vercel.app/r/nft-card.json" @@ -68,7 +68,7 @@ }, { "path": "registry/mini-app/blocks/nft-card/nft-card.tsx", - "content": "\"use client\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\nimport Image from \"next/image\";\nimport { useState, useEffect, useRef } from \"react\";\n\ninterface NFTMetadata {\n name?: string;\n description?: string;\n image?: string;\n image_url?: string;\n external_url?: string;\n attributes?: Array<{\n trait_type: string;\n value: string | number;\n display_type?: string;\n }>;\n image_details?: {\n bytes?: number;\n format?: string;\n sha256?: string;\n width?: number;\n height?: number;\n };\n [key: string]: unknown;\n}\nimport { getAddress, type Address } from \"viem\";\nimport { \n findChainByName, \n getPublicClient \n} from \"@/registry/mini-app/lib/chains\";\nimport { \n ERC721_ABI, \n ipfsToHttp \n} from \"@/registry/mini-app/lib/nft-standards\";\nimport { \n getTokenURIWithManifoldSupport \n} from \"@/registry/mini-app/lib/manifold-utils\";\n\n// Base64 placeholder image\nconst PLACEHOLDER_IMAGE =\n \"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2YxZjFmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGZpbGw9IiM5OTkiPk5GVCBJbWFnZTwvdGV4dD48L3N2Zz4=\";\n\n\ntype NFTCardProps = {\n contractAddress: string;\n tokenId: string;\n network?: string;\n alt?: string;\n className?: string;\n width?: number | string;\n height?: number | string;\n rounded?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n shadow?: boolean;\n objectFit?: \"contain\" | \"cover\" | \"fill\";\n fallbackImageUrl?: string;\n showTitle?: boolean;\n showNetwork?: boolean;\n titlePosition?: \"top\" | \"bottom\" | \"outside\";\n networkPosition?:\n | \"top-left\"\n | \"top-right\"\n | \"bottom-left\"\n | \"bottom-right\"\n | \"outside\";\n customTitle?: string;\n customNetworkName?: string;\n loadingComponent?: React.ReactNode;\n errorComponent?: React.ReactNode;\n imageProps?: React.ComponentProps;\n titleClassName?: string;\n networkClassName?: string;\n showOwner?: boolean;\n onLoad?: (metadata: NFTMetadata) => void;\n onError?: (error: Error) => void;\n layout?: \"compact\" | \"card\" | \"detailed\";\n containerClassName?: string;\n};\n\nexport function NFTCard({\n contractAddress,\n tokenId,\n network = \"ethereum\", // Default to Ethereum mainnet\n alt = \"NFT Image\",\n className = \"\",\n width = 300,\n height = 300,\n rounded = \"md\",\n shadow = true,\n objectFit = \"cover\",\n fallbackImageUrl = PLACEHOLDER_IMAGE,\n showTitle = true,\n showNetwork = true,\n titlePosition = \"outside\",\n networkPosition = \"top-right\",\n customTitle,\n customNetworkName,\n loadingComponent,\n errorComponent,\n imageProps,\n titleClassName = \"\",\n networkClassName = \"\",\n showOwner = false,\n onLoad,\n onError,\n layout = \"card\",\n containerClassName = \"\",\n}: NFTCardProps) {\n const [imageUrl, setImageUrl] = useState(fallbackImageUrl);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState(null);\n const [title, setTitle] = useState(customTitle || null);\n const [networkName, setNetworkName] = useState(\n customNetworkName || \"\",\n );\n const [owner, setOwner] = useState(null);\n const [metadata, setMetadata] = useState(null);\n const abortControllerRef = useRef(null);\n\n const roundedClasses = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n xl: \"rounded-xl\",\n full: \"rounded-full\",\n };\n\n const networkPositionClasses = {\n \"top-left\": \"top-0 left-0 rounded-br-md\",\n \"top-right\": \"top-0 right-0 rounded-bl-md\",\n \"bottom-left\": \"bottom-0 left-0 rounded-tr-md\",\n \"bottom-right\": \"bottom-0 right-0 rounded-tl-md\",\n outside: \"\",\n };\n\n useEffect(() => {\n if (customTitle) {\n setTitle(customTitle);\n }\n\n if (customNetworkName) {\n setNetworkName(customNetworkName);\n }\n }, [customTitle, customNetworkName]);\n\n useEffect(() => {\n const fetchNFTData = async () => {\n if (!contractAddress || !tokenId) return;\n \n // Cancel any previous request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n \n // Create new AbortController for this request\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n setIsLoading(true);\n setError(null);\n\n try {\n // Skip chain setup if we have customNetworkName\n if (!customNetworkName) {\n // Find the chain by name using shared utility\n const selectedChain = findChainByName(network || \"ethereum\");\n \n if (!selectedChain) {\n console.warn(\n `Chain \"${network}\" not found, defaulting to Ethereum mainnet`,\n );\n setNetworkName(\"Ethereum\");\n } else {\n setNetworkName(selectedChain.name);\n }\n\n // Create public client using shared utility\n const client = getPublicClient(selectedChain?.id || 1);\n\n console.log(\n `Fetching NFT data from ${selectedChain?.name || 'Ethereum'} for contract ${contractAddress} token ${tokenId}`,\n );\n\n // Skip title setup if we have customTitle\n if (!customTitle) {\n try {\n // Get contract name\n const name = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.name,\n functionName: \"name\",\n })) as string;\n\n // Set title\n setTitle(`${name} #${tokenId}`);\n } catch (nameError) {\n console.warn(\"Could not fetch NFT name:\", nameError);\n setTitle(`NFT #${tokenId}`);\n }\n }\n\n // Get owner if requested\n if (showOwner) {\n try {\n const ownerAddress = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.ownerOf,\n functionName: \"ownerOf\",\n args: [BigInt(tokenId)],\n })) as string;\n\n setOwner(ownerAddress);\n } catch (ownerError) {\n console.warn(\"Could not fetch NFT owner:\", ownerError);\n }\n }\n\n // Get tokenURI with automatic Manifold support\n let metadataUrl = await getTokenURIWithManifoldSupport(\n client,\n getAddress(contractAddress) as Address,\n tokenId\n );\n\n // Handle IPFS URLs using shared utility\n metadataUrl = ipfsToHttp(metadataUrl);\n\n // Fetch metadata with abort signal\n const response = await fetch(metadataUrl, {\n signal: abortController.signal\n });\n \n if (!response.ok) {\n throw new Error(`Failed to fetch metadata: ${response.status}`);\n }\n \n const fetchedMetadata = await response.json();\n console.log(\"NFT metadata:\", fetchedMetadata);\n \n // Store metadata in state\n setMetadata(fetchedMetadata);\n\n // Call onLoad callback if provided\n if (onLoad) {\n onLoad(fetchedMetadata);\n }\n\n // Get image URL from metadata\n let nftImageUrl = fetchedMetadata.image || fetchedMetadata.image_url;\n\n // Handle IPFS URLs for image using shared utility\n if (nftImageUrl) {\n nftImageUrl = ipfsToHttp(nftImageUrl);\n }\n\n if (nftImageUrl) {\n setImageUrl(nftImageUrl);\n } else {\n // If no image URL found, use placeholder\n setImageUrl(fallbackImageUrl);\n }\n }\n } catch (err) {\n // Don't update state if request was aborted\n if (err instanceof Error && err.name === 'AbortError') {\n console.log('NFT data fetch was cancelled');\n return;\n }\n \n console.error(\"Error fetching NFT:\", err);\n const error = err instanceof Error ? err : new Error(String(err));\n setError(`Failed to load NFT data: ${error.message}`);\n setImageUrl(fallbackImageUrl);\n\n // Call onError callback if provided\n if (onError) {\n onError(error);\n }\n } finally {\n // Only update loading state if this request wasn't aborted\n if (!abortController.signal.aborted) {\n setIsLoading(false);\n }\n }\n };\n\n fetchNFTData();\n \n // Cleanup function to abort request if component unmounts or deps change\n return () => {\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n };\n }, [\n contractAddress,\n tokenId,\n network,\n fallbackImageUrl,\n customTitle,\n customNetworkName,\n showOwner,\n onLoad,\n onError,\n ]);\n\n const defaultLoadingComponent = (\n
\n
\n
\n );\n\n const defaultErrorComponent = (\n
\n

{error}

\n
\n );\n\n // Render network badge inside the image\n const renderNetworkBadge = () => {\n if (!showNetwork || !networkName || networkPosition === \"outside\")\n return null;\n\n return (\n \n {networkName}\n \n );\n };\n\n // Render title inside the image\n const renderInnerTitle = () => {\n if (!showTitle || !title || titlePosition === \"outside\") return null;\n\n return (\n \n {title}\n {showOwner && owner && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Render outside information (title, network, owner)\n const renderOutsideInfo = () => {\n if (\n (!showTitle || !title) &&\n (!showNetwork || !networkName || networkPosition !== \"outside\") &&\n (!showOwner || !owner || titlePosition !== \"outside\")\n ) {\n return null;\n }\n\n return (\n
\n {showTitle && title && titlePosition === \"outside\" && (\n
\n {title}\n
\n )}\n\n {showNetwork && networkName && networkPosition === \"outside\" && (\n \n Network: {networkName}\n
\n )}\n\n {showOwner && owner && titlePosition === \"outside\" && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Apply different layouts\n const getContainerClasses = () => {\n switch (layout) {\n case \"compact\":\n return \"inline-block\";\n case \"detailed\":\n return \"flex flex-col overflow-hidden\";\n case \"card\":\n default:\n return \"\";\n }\n };\n\n // Calculate display dimensions that preserve aspect ratio\n const getDisplayDimensions = () => {\n // Handle percentage values\n const isPercentageWidth = typeof width === 'string' && width.includes('%');\n const isPercentageHeight = typeof height === 'string' && height.includes('%');\n \n if (isPercentageWidth || isPercentageHeight) {\n return { \n width: width || '100%', \n height: height || '100%', \n useContain: false,\n isPercentage: true\n };\n }\n \n const maxWidth = typeof width === 'number' ? width : 300;\n const maxHeight = typeof height === 'number' ? height : 300;\n \n // Check if we have image_details with dimensions\n if (metadata?.image_details?.width && metadata?.image_details?.height) {\n const originalAspectRatio = metadata.image_details.width / metadata.image_details.height;\n \n // Scale to fit within bounds while preserving aspect ratio\n const widthBasedHeight = maxWidth / originalAspectRatio;\n const heightBasedWidth = maxHeight * originalAspectRatio;\n \n if (widthBasedHeight <= maxHeight) {\n // Width is the limiting factor\n return { \n width: maxWidth, \n height: Math.round(widthBasedHeight),\n useContain: true, // Use contain to show full image\n isPercentage: false\n };\n } else {\n // Height is the limiting factor\n return { \n width: Math.round(heightBasedWidth), \n height: maxHeight,\n useContain: true,\n isPercentage: false\n };\n }\n }\n \n // No image_details, use provided dimensions\n return { width: maxWidth, height: maxHeight, useContain: false, isPercentage: false };\n };\n\n const displayDimensions = getDisplayDimensions();\n\n return (\n
\n \n {isLoading && (loadingComponent || defaultLoadingComponent)}\n\n {error && (errorComponent || defaultErrorComponent)}\n\n setImageUrl(PLACEHOLDER_IMAGE)}\n {...imageProps}\n />\n\n {renderInnerTitle()}\n {renderNetworkBadge()}\n
\n\n {renderOutsideInfo()}\n \n );\n}\n", + "content": "\"use client\";\n\nimport { cn } from \"@/registry/mini-app/lib/utils\";\nimport Image from \"next/image\";\nimport { useState, useEffect, useRef } from \"react\";\n\ninterface NFTMetadata {\n name?: string;\n description?: string;\n image?: string;\n image_url?: string;\n external_url?: string;\n attributes?: Array<{\n trait_type: string;\n value: string | number;\n display_type?: string;\n }>;\n image_details?: {\n bytes?: number;\n format?: string;\n sha256?: string;\n width?: number;\n height?: number;\n };\n [key: string]: unknown;\n}\nimport { getAddress, type Address } from \"viem\";\nimport { \n findChainByName, \n getPublicClient \n} from \"@/registry/mini-app/lib/chains\";\nimport { \n ERC721_ABI, \n ipfsToHttp \n} from \"@/registry/mini-app/lib/nft-standards\";\nimport { \n getTokenURIWithManifoldSupport \n} from \"@/registry/mini-app/lib/manifold-utils\";\n\n// Base64 placeholder image\nconst PLACEHOLDER_IMAGE =\n \"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2YxZjFmMSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGZpbGw9IiM5OTkiPk5GVCBJbWFnZTwvdGV4dD48L3N2Zz4=\";\n\n\ntype NFTCardProps = {\n contractAddress: string;\n tokenId: string;\n network?: string;\n alt?: string;\n className?: string;\n width?: number | string;\n height?: number | string;\n rounded?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n shadow?: boolean;\n objectFit?: \"contain\" | \"cover\" | \"fill\";\n fallbackImageUrl?: string;\n showTitle?: boolean;\n showNetwork?: boolean;\n titlePosition?: \"top\" | \"bottom\" | \"outside\";\n networkPosition?:\n | \"top-left\"\n | \"top-right\"\n | \"bottom-left\"\n | \"bottom-right\"\n | \"outside\";\n customTitle?: string;\n customNetworkName?: string;\n loadingComponent?: React.ReactNode;\n errorComponent?: React.ReactNode;\n imageProps?: React.ComponentProps;\n titleClassName?: string;\n networkClassName?: string;\n showOwner?: boolean;\n onLoad?: (metadata: NFTMetadata) => void;\n onError?: (error: Error) => void;\n layout?: \"compact\" | \"card\" | \"detailed\";\n containerClassName?: string;\n};\n\nexport function NFTCard({\n contractAddress,\n tokenId,\n network = \"ethereum\", // Default to Ethereum mainnet\n alt = \"NFT Image\",\n className = \"\",\n width = 300,\n height = 300,\n rounded = \"md\",\n shadow = true,\n objectFit = \"cover\",\n fallbackImageUrl = PLACEHOLDER_IMAGE,\n showTitle = true,\n showNetwork = true,\n titlePosition = \"outside\",\n networkPosition = \"top-right\",\n customTitle,\n customNetworkName,\n loadingComponent,\n errorComponent,\n imageProps,\n titleClassName = \"\",\n networkClassName = \"\",\n showOwner = false,\n onLoad,\n onError,\n layout = \"card\",\n containerClassName = \"\",\n}: NFTCardProps) {\n const [imageUrl, setImageUrl] = useState(fallbackImageUrl);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState(null);\n const [title, setTitle] = useState(customTitle || null);\n const [networkName, setNetworkName] = useState(\n customNetworkName || \"\",\n );\n const [owner, setOwner] = useState(null);\n const [metadata, setMetadata] = useState(null);\n const abortControllerRef = useRef(null);\n\n const roundedClasses = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n xl: \"rounded-xl\",\n full: \"rounded-full\",\n };\n\n const networkPositionClasses = {\n \"top-left\": \"top-0 left-0 rounded-br-md\",\n \"top-right\": \"top-0 right-0 rounded-bl-md\",\n \"bottom-left\": \"bottom-0 left-0 rounded-tr-md\",\n \"bottom-right\": \"bottom-0 right-0 rounded-tl-md\",\n outside: \"\",\n };\n\n useEffect(() => {\n if (customTitle) {\n setTitle(customTitle);\n }\n\n if (customNetworkName) {\n setNetworkName(customNetworkName);\n }\n }, [customTitle, customNetworkName]);\n\n useEffect(() => {\n const fetchNFTData = async () => {\n if (!contractAddress || !tokenId) return;\n \n // Cancel any previous request\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n \n // Create new AbortController for this request\n const abortController = new AbortController();\n abortControllerRef.current = abortController;\n\n setIsLoading(true);\n setError(null);\n\n try {\n // Skip chain setup if we have customNetworkName\n if (!customNetworkName) {\n // Find the chain by name using shared utility\n const selectedChain = findChainByName(network || \"ethereum\");\n \n if (!selectedChain) {\n console.warn(\n `Chain \"${network}\" not found, defaulting to Ethereum mainnet`,\n );\n setNetworkName(\"Ethereum\");\n } else {\n setNetworkName(selectedChain.name);\n }\n\n // Create public client using shared utility\n const client = getPublicClient(selectedChain?.id || 1);\n\n console.log(\n `Fetching NFT data from ${selectedChain?.name || 'Ethereum'} for contract ${contractAddress} token ${tokenId}`,\n );\n\n // Skip title setup if we have customTitle\n if (!customTitle) {\n try {\n // Get contract name\n const name = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.name,\n functionName: \"name\",\n })) as string;\n\n // Set title\n setTitle(`${name} #${tokenId}`);\n } catch (nameError) {\n console.warn(\"Could not fetch NFT name:\", nameError);\n setTitle(`NFT #${tokenId}`);\n }\n }\n\n // Get owner if requested\n if (showOwner) {\n try {\n const ownerAddress = (await client.readContract({\n address: getAddress(contractAddress),\n abi: ERC721_ABI.ownerOf,\n functionName: \"ownerOf\",\n args: [BigInt(tokenId)],\n })) as string;\n\n setOwner(ownerAddress);\n } catch (ownerError) {\n console.warn(\"Could not fetch NFT owner:\", ownerError);\n }\n }\n\n // Get tokenURI with automatic Manifold support\n let metadataUrl = await getTokenURIWithManifoldSupport(\n client,\n getAddress(contractAddress) as Address,\n tokenId\n );\n\n // Handle IPFS URLs using shared utility\n metadataUrl = ipfsToHttp(metadataUrl);\n\n // Fetch metadata with abort signal\n const response = await fetch(metadataUrl, {\n signal: abortController.signal\n });\n \n if (!response.ok) {\n throw new Error(`Failed to fetch metadata: ${response.status}`);\n }\n \n const fetchedMetadata = await response.json();\n console.log(\"NFT metadata:\", fetchedMetadata);\n \n // Store metadata in state\n setMetadata(fetchedMetadata);\n\n // Call onLoad callback if provided\n if (onLoad) {\n onLoad(fetchedMetadata);\n }\n\n // Get image URL from metadata\n let nftImageUrl = fetchedMetadata.image || fetchedMetadata.image_url;\n\n // Handle IPFS URLs for image using shared utility\n if (nftImageUrl) {\n nftImageUrl = ipfsToHttp(nftImageUrl);\n }\n\n if (nftImageUrl) {\n setImageUrl(nftImageUrl);\n } else {\n // If no image URL found, use placeholder\n setImageUrl(fallbackImageUrl);\n }\n }\n } catch (err) {\n // Don't update state if request was aborted\n if (err instanceof Error && err.name === 'AbortError') {\n console.log('NFT data fetch was cancelled');\n return;\n }\n \n console.error(\"Error fetching NFT:\", err);\n const error = err instanceof Error ? err : new Error(String(err));\n setError(`Failed to load NFT data: ${error.message}`);\n setImageUrl(fallbackImageUrl);\n\n // Call onError callback if provided\n if (onError) {\n onError(error);\n }\n } finally {\n // Only update loading state if this request wasn't aborted\n if (!abortController.signal.aborted) {\n setIsLoading(false);\n }\n }\n };\n\n fetchNFTData();\n \n // Cleanup function to abort request if component unmounts or deps change\n return () => {\n if (abortControllerRef.current) {\n abortControllerRef.current.abort();\n }\n };\n }, [\n contractAddress,\n tokenId,\n network,\n fallbackImageUrl,\n customTitle,\n customNetworkName,\n showOwner,\n onLoad,\n onError,\n ]);\n\n const defaultLoadingComponent = (\n
\n
\n
\n );\n\n const defaultErrorComponent = (\n
\n

{error}

\n
\n );\n\n // Render network badge inside the image\n const renderNetworkBadge = () => {\n if (!showNetwork || !networkName || networkPosition === \"outside\")\n return null;\n\n return (\n \n {networkName}\n \n );\n };\n\n // Render title inside the image\n const renderInnerTitle = () => {\n if (!showTitle || !title || titlePosition === \"outside\") return null;\n\n return (\n \n {title}\n {showOwner && owner && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Render outside information (title, network, owner)\n const renderOutsideInfo = () => {\n if (\n (!showTitle || !title) &&\n (!showNetwork || !networkName || networkPosition !== \"outside\") &&\n (!showOwner || !owner || titlePosition !== \"outside\")\n ) {\n return null;\n }\n\n return (\n
\n {showTitle && title && titlePosition === \"outside\" && (\n
\n {title}\n
\n )}\n\n {showNetwork && networkName && networkPosition === \"outside\" && (\n \n Network: {networkName}\n
\n )}\n\n {showOwner && owner && titlePosition === \"outside\" && (\n
\n Owner: {owner.substring(0, 6)}...{owner.substring(owner.length - 4)}\n
\n )}\n \n );\n };\n\n // Apply different layouts\n const getContainerClasses = () => {\n switch (layout) {\n case \"compact\":\n return \"inline-block\";\n case \"detailed\":\n return \"flex flex-col overflow-hidden\";\n case \"card\":\n default:\n return \"\";\n }\n };\n\n // Calculate display dimensions that preserve aspect ratio\n const getDisplayDimensions = () => {\n // Handle percentage values\n const isPercentageWidth = typeof width === \"string\" && width.includes(\"%\");\n const isPercentageHeight = typeof height === \"string\" && height.includes(\"%\");\n \n if (isPercentageWidth || isPercentageHeight) {\n return { \n width: width || '100%', \n height: height || '100%', \n useContain: false,\n isPercentage: true\n };\n }\n \n const maxWidth = typeof width === \"number\" ? width : 300;\n const maxHeight = typeof height === \"number\" ? height : 300;\n \n // Check if we have image_details with dimensions\n if (metadata?.image_details?.width && metadata?.image_details?.height) {\n const originalAspectRatio = metadata.image_details.width / metadata.image_details.height;\n \n // Scale to fit within bounds while preserving aspect ratio\n const widthBasedHeight = maxWidth / originalAspectRatio;\n const heightBasedWidth = maxHeight * originalAspectRatio;\n \n if (widthBasedHeight <= maxHeight) {\n // Width is the limiting factor\n return { \n width: maxWidth, \n height: Math.round(widthBasedHeight),\n useContain: true, // Use contain to show full image\n isPercentage: false\n };\n } else {\n // Height is the limiting factor\n return { \n width: Math.round(heightBasedWidth), \n height: maxHeight,\n useContain: true,\n isPercentage: false\n };\n }\n }\n \n // No image_details, use provided dimensions\n return { width: maxWidth, height: maxHeight, useContain: false, isPercentage: false };\n };\n\n const displayDimensions = getDisplayDimensions();\n\n return (\n
\n \n {isLoading && (loadingComponent || defaultLoadingComponent)}\n\n {error && (errorComponent || defaultErrorComponent)}\n\n setImageUrl(PLACEHOLDER_IMAGE)}\n {...imageProps}\n />\n\n {renderInnerTitle()}\n {renderNetworkBadge()}\n
\n\n {renderOutsideInfo()}\n \n );\n}\n", "type": "registry:component", "target": "" }, diff --git a/public/r/onchain-user-search.json b/public/r/onchain-user-search.json index 527bf65c..4ce053d1 100644 --- a/public/r/onchain-user-search.json +++ b/public/r/onchain-user-search.json @@ -17,7 +17,7 @@ "registryDependencies": [ "button", "input", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/utils.json", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", diff --git a/public/r/registry.json b/public/r/registry.json index 66229f88..9da6e692 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -52,7 +52,7 @@ "dependencies": [], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "meta": { @@ -84,7 +84,7 @@ "dependencies": [], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "files": [ { @@ -138,7 +138,7 @@ ], "dependencies": ["@farcaster/frame-core", "@farcaster/frame-sdk"], "registryDependencies": [ - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ] }, { @@ -216,7 +216,7 @@ "registryDependencies": [ "button", "input", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/utils.json", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", @@ -247,7 +247,7 @@ "registryDependencies": [ "button", "sheet", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/nft-standards.json", "https://hellno-mini-app-ui.vercel.app/r/nft-card.json" diff --git a/public/r/share-cast-button.json b/public/r/share-cast-button.json index b082f80d..5454f163 100644 --- a/public/r/share-cast-button.json +++ b/public/r/share-cast-button.json @@ -15,7 +15,7 @@ ], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "files": [ { diff --git a/public/r/use-profile.json b/public/r/use-profile.json index cb6fae69..c3797abb 100644 --- a/public/r/use-profile.json +++ b/public/r/use-profile.json @@ -9,7 +9,7 @@ "@farcaster/frame-sdk" ], "registryDependencies": [ - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "files": [ { diff --git a/registry.json b/registry.json index 66229f88..9da6e692 100644 --- a/registry.json +++ b/registry.json @@ -52,7 +52,7 @@ "dependencies": [], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "meta": { @@ -84,7 +84,7 @@ "dependencies": [], "registryDependencies": [ "button", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ], "files": [ { @@ -138,7 +138,7 @@ ], "dependencies": ["@farcaster/frame-core", "@farcaster/frame-sdk"], "registryDependencies": [ - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json" + "use-miniapp-sdk" ] }, { @@ -216,7 +216,7 @@ "registryDependencies": [ "button", "input", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/utils.json", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/text-utils.json", @@ -247,7 +247,7 @@ "registryDependencies": [ "button", "sheet", - "https://hellno-mini-app-ui.vercel.app/r/use-miniapp-sdk.json", + "use-miniapp-sdk", "https://hellno-mini-app-ui.vercel.app/r/chains.json", "https://hellno-mini-app-ui.vercel.app/r/nft-standards.json", "https://hellno-mini-app-ui.vercel.app/r/nft-card.json" diff --git a/registry/mini-app/blocks/manifold-nft-mint/config.ts b/registry/mini-app/blocks/manifold-nft-mint/config.ts index e5ac2162..3b6dc3c7 100644 --- a/registry/mini-app/blocks/manifold-nft-mint/config.ts +++ b/registry/mini-app/blocks/manifold-nft-mint/config.ts @@ -89,7 +89,7 @@ export async function getNftDetails(contractAddress: Address, instanceId: string const [rawInstanceId, rawClaim] = data; // Validate types - if (typeof rawInstanceId !== 'bigint' || !rawClaim || typeof rawClaim !== 'object') { + if (typeof rawInstanceId !== "bigint" || !rawClaim || typeof rawClaim !== "object") { throw new Error("Invalid data types in getClaimForToken response"); } @@ -125,7 +125,7 @@ export async function getNftDetails(contractAddress: Address, instanceId: string const [rawInstanceId, rawClaim] = data; // Validate types - if (typeof rawInstanceId !== 'bigint' || !rawClaim || typeof rawClaim !== 'object') { + if (typeof rawInstanceId !== "bigint" || !rawClaim || typeof rawClaim !== "object") { throw new Error("Invalid data types in getClaimForToken response"); } diff --git a/registry/mini-app/blocks/nft-card/nft-card.tsx b/registry/mini-app/blocks/nft-card/nft-card.tsx index 424c96af..6e2559e2 100644 --- a/registry/mini-app/blocks/nft-card/nft-card.tsx +++ b/registry/mini-app/blocks/nft-card/nft-card.tsx @@ -411,8 +411,8 @@ export function NFTCard({ // Calculate display dimensions that preserve aspect ratio const getDisplayDimensions = () => { // Handle percentage values - const isPercentageWidth = typeof width === 'string' && width.includes('%'); - const isPercentageHeight = typeof height === 'string' && height.includes('%'); + const isPercentageWidth = typeof width === "string" && width.includes("%"); + const isPercentageHeight = typeof height === "string" && height.includes("%"); if (isPercentageWidth || isPercentageHeight) { return { @@ -423,8 +423,8 @@ export function NFTCard({ }; } - const maxWidth = typeof width === 'number' ? width : 300; - const maxHeight = typeof height === 'number' ? height : 300; + const maxWidth = typeof width === "number" ? width : 300; + const maxHeight = typeof height === "number" ? height : 300; // Check if we have image_details with dimensions if (metadata?.image_details?.width && metadata?.image_details?.height) { diff --git a/scripts/generate-all-components.js b/scripts/generate-all-components.js deleted file mode 100644 index 946588fb..00000000 --- a/scripts/generate-all-components.js +++ /dev/null @@ -1,39 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -// Read the registry.json -const registryPath = path.join(__dirname, '..', 'public', 'r', 'registry.json'); -const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - -// Filter installable components -const installableComponents = registry.items.filter( - item => item.type === 'registry:component' || item.type === 'registry:block' -); - -// Extract all unique dependencies -const allDependencies = new Set(); -installableComponents.forEach(component => { - if (component.dependencies) { - component.dependencies.forEach(dep => allDependencies.add(dep)); - } -}); - -// Create the all-components meta file -const allComponents = { - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "all-components", - "type": "registry:component", - "title": "All Mini App UI Components", - "description": "Install all hellno/mini-app-ui components at once", - "dependencies": Array.from(allDependencies).sort(), - "registryDependencies": installableComponents.map( - component => `https://hellno-mini-app-ui.vercel.app/r/${component.name}.json` - ), - "files": [] -}; - -// Write the all-components.json file -const outputPath = path.join(__dirname, '..', 'public', 'r', 'all-components.json'); -fs.writeFileSync(outputPath, JSON.stringify(allComponents, null, 2)); - -console.log('✅ Generated all-components.json with', installableComponents.length, 'components'); \ No newline at end of file From c4b7356a8f755984b678175e9a7f1d66ca67049d Mon Sep 17 00:00:00 2001 From: hellno Date: Wed, 16 Jul 2025 13:31:34 +0200 Subject: [PATCH 03/12] Add share bottom sheet component to mini app ui --- CLAUDE.md | 5 +- app/component/share-bottom-sheet/page.tsx | 103 ++++++++++++++++ lib/components-config.tsx | 29 +++++ public/r/registry.json | 28 +++++ public/r/share-bottom-sheet.json | 71 +++++++++++ registry.json | 28 +++++ .../share-bottom-sheet/share-bottom-sheet.tsx | 110 ++++++++++++++++++ 7 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 app/component/share-bottom-sheet/page.tsx create mode 100644 public/r/share-bottom-sheet.json create mode 100644 registry/mini-app/blocks/share-bottom-sheet/share-bottom-sheet.tsx diff --git a/CLAUDE.md b/CLAUDE.md index dc10d4a8..7d4e3386 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,8 +199,9 @@ Components can depend on other registry items via `registryDependencies` field i When adding new components: 1. Create component files in `registry/mini-app/blocks//` 2. Add registry entry to `registry.json` with proper metadata -3. The pre-commit hook will automatically run `registry:build` to generate public files -4. Deploy triggers automatic Vercel deployment +3. Add the component to `lib/components-config.tsx` so it appears on the homepage for users to test (components only, not hooks or utils) +4. The pre-commit hook will automatically run `registry:build` to generate public files +5. Deploy triggers automatic Vercel deployment ## Important Notes diff --git a/app/component/share-bottom-sheet/page.tsx b/app/component/share-bottom-sheet/page.tsx new file mode 100644 index 00000000..3fd095a4 --- /dev/null +++ b/app/component/share-bottom-sheet/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { ShareBottomSheet } from "@/registry/mini-app/blocks/share-bottom-sheet/share-bottom-sheet"; +import { Button } from "@/registry/mini-app/ui/button"; + +export default function ShareBottomSheetDemo() { + const [open, setOpen] = useState(false); + const [customText, setCustomText] = useState( + "I just donated to help support Roman Storm's legal defense fund. Join me in defending the right to privacy and the right to publish code!" + ); + + const examples = [ + { + title: "Legal Defense Fund", + text: "I just donated to help support Roman Storm's legal defense fund. Join me in defending the right to privacy and the right to publish code!", + url: "https://www.justiceforstorm.com", + }, + { + title: "Project Launch", + text: "Just launched my new Farcaster mini app! 🚀 It's a game-changer for the community. Check it out and let me know what you think! This is a longer text that will be truncated after 4 lines to maintain a clean UI design while still conveying the essential message.", + url: "https://mini-app-ui.vercel.app", + }, + { + title: "Simple Share", + text: "Building in public is the way! 🛠️", + }, + ]; + + return ( +
+
+
+

Share Bottom Sheet

+

+ A bottom sheet component for sharing content to Farcaster with a + customizable message. Text is automatically truncated to 4 lines + for optimal readability. +

+
+ +
+

Examples

+ +
+ {examples.map((example, index) => ( +
+

{example.title}

+

+ {example.text} +

+ +
+ ))} +
+
+ +
+

Custom Text

+