diff --git a/minikit/coin-your-idea/.gitignore b/minikit/coin-your-idea/.gitignore new file mode 100644 index 0000000..5cf4091 --- /dev/null +++ b/minikit/coin-your-idea/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +.env.local \ No newline at end of file diff --git a/minikit/coin-your-idea/README.md b/minikit/coin-your-idea/README.md new file mode 100644 index 0000000..ba21c06 --- /dev/null +++ b/minikit/coin-your-idea/README.md @@ -0,0 +1,70 @@ +# CoinSpark Demo + +This is a demonstration frame built with [MiniKit](https://docs.base.org/builderkits/minikit/overview), [OnchainKit](https://docs.base.org/builderkits/onchainkit/getting-started), and Zora's Coins SDK to show how to mint your ideas on-chain as unique crypto assets. + +## Getting Started + +1. Install dependencies: + + ```bash + npm install + # or yarn install + # or pnpm install + # or bun install + ``` + +2. Set up environment variables: + + Create a `.env.local` file in the project root (or use the provided `env.example`) and add the following: + + ```env + # Required for Frame metadata + NEXT_PUBLIC_URL= + NEXT_PUBLIC_VERSION= + NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME= + NEXT_PUBLIC_ICON_URL= + NEXT_PUBLIC_IMAGE_URL= + NEXT_PUBLIC_SPLASH_IMAGE_URL= + NEXT_PUBLIC_SPLASH_BACKGROUND_COLOR= + + # Required for Frame account association + FARCASTER_HEADER= + FARCASTER_PAYLOAD= + FARCASTER_SIGNATURE= + + # Required for webhooks and background notifications + REDIS_URL= + REDIS_TOKEN= + ``` + +3. Start the development server: + + ```bash + npm run dev + ``` + +## Features + +- AI-powered idea-to-coin generation via OpenAI +- On-chain minting with Zora's Coins SDK +- Frame integration for seamless account association and notifications +- View your minted coins in the “My Coins” tab +- Real-time transaction feedback with Etherscan links +- Dark/light theme support with customizable styling via Tailwind CSS + +## Project Structure + +- `app/page.tsx` — Main demo page with Create and My Coins tabs +- `app/components/` — Reusable UI components (inputs, cards, modals) +- `app/api/` — Backend routes for coin generation, metadata, and notifications +- `app/providers.tsx` — MiniKit and OnchainKit setup +- `app/theme.css` — Custom theming for Tailwind and OnchainKit +- `.well-known/farcaster.json` — Frame metadata endpoint + +## Learn More + +- MiniKit Documentation: https://docs.base.org/builderkits/minikit/overview +- OnchainKit Documentation: https://docs.base.org/builderkits/onchainkit/getting-started +- Zora Coins SDK: https://github.com/zoralabs/coins-sdk +- Next.js Documentation: https://nextjs.org/docs +- Tailwind CSS Documentation: https://tailwindcss.com/docs diff --git a/minikit/coin-your-idea/app/.DS_Store b/minikit/coin-your-idea/app/.DS_Store new file mode 100644 index 0000000..eb93f81 Binary files /dev/null and b/minikit/coin-your-idea/app/.DS_Store differ diff --git a/minikit/coin-your-idea/app/.well-known/farcaster.json/route.ts b/minikit/coin-your-idea/app/.well-known/farcaster.json/route.ts new file mode 100644 index 0000000..10d2a1f --- /dev/null +++ b/minikit/coin-your-idea/app/.well-known/farcaster.json/route.ts @@ -0,0 +1,22 @@ +export async function GET() { + const URL = process.env.NEXT_PUBLIC_URL; + + return Response.json({ + accountAssociation: { + header: process.env.FARCASTER_HEADER, + payload: process.env.FARCASTER_PAYLOAD, + signature: process.env.FARCASTER_SIGNATURE, + }, + frame: { + version: process.env.NEXT_PUBLIC_VERSION, + name: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME, + homeUrl: URL, + iconUrl: process.env.NEXT_PUBLIC_ICON_URL, + imageUrl: process.env.NEXT_PUBLIC_IMAGE_URL, + buttonTitle: `Launch ${process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME}`, + splashImageUrl: process.env.NEXT_PUBLIC_SPLASH_IMAGE_URL, + splashBackgroundColor: `#${process.env.NEXT_PUBLIC_SPLASH_BACKGROUND_COLOR}`, + webhookUrl: `${URL}/api/webhook`, + }, + }); +} diff --git a/minikit/coin-your-idea/app/api/.DS_Store b/minikit/coin-your-idea/app/api/.DS_Store new file mode 100644 index 0000000..ca42644 Binary files /dev/null and b/minikit/coin-your-idea/app/api/.DS_Store differ diff --git a/minikit/coin-your-idea/app/api/coin-metadata/[id]/route.ts b/minikit/coin-your-idea/app/api/coin-metadata/[id]/route.ts new file mode 100644 index 0000000..6a77771 --- /dev/null +++ b/minikit/coin-your-idea/app/api/coin-metadata/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PROJECT_URL } from '@/lib/constants'; + +// Simple in-memory store for demo purposes +const metadataStore: Record = {}; + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = params.id; + const data = await request.json(); + + // Store metadata with environment-specific image URL + metadataStore[id] = { + name: data.name, + symbol: data.symbol, + description: data.description, + image: process.env.ENV === 'local' + ? "https://i.imgur.com/tdvjX6c.png" + : `${PROJECT_URL}/api/generateImage?idea=${data.description}` + }; + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: 'Failed to store metadata' }, { status: 500 }); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = params.id; + + // Return stored metadata or fallback with environment-specific image URL + if (metadataStore[id]) { + return NextResponse.json(metadataStore[id]); + } + + return NextResponse.json({ + name: "Banger Coin", + symbol: "BANGER", + description: "A coin created from a banger", + image: process.env.ENV === 'local' + ? "https://i.imgur.com/tdvjX6c.png" + : `${PROJECT_URL}/api/generateImage?idea=A coin created from a banger` + }); + } catch (error) { + return NextResponse.json({ error: 'Failed to retrieve metadata' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/api/generate-coin/route.ts b/minikit/coin-your-idea/app/api/generate-coin/route.ts new file mode 100644 index 0000000..553c7c1 --- /dev/null +++ b/minikit/coin-your-idea/app/api/generate-coin/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import OpenAI from 'openai'; +import crypto from 'crypto'; +import clientPromise from '@/lib/mongodb'; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, // Server-side environment variable +}); + +export async function POST(request: NextRequest) { + try { + const { idea, owner } = await request.json(); + if (!idea || !owner) { + return NextResponse.json({ error: 'Idea and owner address required' }, { status: 400 }); + } + + const result = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: `Generate parameters for a cryptocurrency coin based on the following idea.\nReturn JSON in this format:\n{\n "name": "short name (max 3 words)",\n "symbol": "ticker symbol (max 5 letters)"\n}` + }, + { role: "user", content: idea } + ] + }); + + const content = result.choices[0].message.content; + let parsedContent; + + try { + parsedContent = JSON.parse(content || '{}'); + } catch (e) { + parsedContent = { + name: idea.split(' ').slice(0, 3).join(' ').substring(0, 20) || "Idea Coin", + symbol: idea.split(' ') + .filter((w: string) => w) + .slice(0, 3) + .map((word: string) => word[0]) + .join('') + .toUpperCase() + .substring(0, 5) || "IDEA" + }; + } + + // Generate unique ID + const uniqueId = crypto.randomBytes(8).toString('hex'); + + // Create metadata URL using request origin + const origin = new URL(request.url).origin; + const metadataUrl = `${origin}/api/coin-metadata/${uniqueId}`; + + // Store metadata + await fetch(metadataUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: parsedContent.name, + symbol: parsedContent.symbol, + description: idea, + }), + }); + + // Insert coin record into MongoDB + const client = await clientPromise; + const db = client.db(); + await db.collection('coins').insertOne({ + id: uniqueId, + name: parsedContent.name, + symbol: parsedContent.symbol, + description: idea, + metadataUrl, + ownerAddress: owner, + createdAt: new Date() + }); + + return NextResponse.json({ + name: parsedContent.name, + symbol: parsedContent.symbol, + metadataUrl + }); + + } catch (error) { + console.error('Error generating coin parameters:', error); + return NextResponse.json( + { error: 'Failed to generate coin parameters' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/api/generateImage/route.tsx b/minikit/coin-your-idea/app/api/generateImage/route.tsx new file mode 100644 index 0000000..f69c2a3 --- /dev/null +++ b/minikit/coin-your-idea/app/api/generateImage/route.tsx @@ -0,0 +1,50 @@ +import { ImageResponse } from 'next/og'; +import { NextRequest } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const idea = searchParams.get('idea') || 'Default idea'; + + return new ImageResponse( + ( +
+
This is a Coined Idea
+
+ {idea} +
+
+ ), + { + width: 1200, + height: 630, + } + ); + } catch (error) { + console.error('Error generating image:', error); + return new Response('Error generating image', { status: 500 }); + } +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/api/metadata/[id]/route.ts b/minikit/coin-your-idea/app/api/metadata/[id]/route.ts new file mode 100644 index 0000000..a9b4fe7 --- /dev/null +++ b/minikit/coin-your-idea/app/api/metadata/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// In-memory store for demonstration (in production you'd use a database) +const metadataStore: Record = {}; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = params.id; + + // Check if we already have this metadata stored + if (metadataStore[id]) { + return NextResponse.json(metadataStore[id]); + } + + // If not, this is a new request - decode the ID + const decodedIdea = decodeURIComponent(Buffer.from(id, 'base64').toString()); + + // Get the host for creating absolute URLs + const host = request.headers.get('host') || 'localhost:3000'; + const protocol = host.includes('localhost') ? 'http' : 'https'; + const baseUrl = `${protocol}://${host}`; + + // Create image URL + const imageUrl = `${baseUrl}/api/generateImage?idea=${encodeURIComponent(decodedIdea)}`; + + // Create coin metadata following EIP-7572 standard + const metadata = { + name: decodedIdea.split(' ').slice(0, 3).join(' ').substring(0, 30) || "Idea Coin", + description: decodedIdea.substring(0, 500) || "A fun idea coin", + symbol: decodedIdea.split(' ') + .slice(0, 3) + .map(word => word[0]) + .join('') + .toUpperCase() + .substring(0, 5) || "IDEA", + image: imageUrl + }; + + // Store for future requests + metadataStore[id] = metadata; + + return NextResponse.json(metadata); + } catch (error) { + console.error('Error generating metadata:', error); + return NextResponse.json( + { error: 'Failed to generate metadata' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/api/my-coins/route.ts b/minikit/coin-your-idea/app/api/my-coins/route.ts new file mode 100644 index 0000000..eb3e61c --- /dev/null +++ b/minikit/coin-your-idea/app/api/my-coins/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import clientPromise from '@/lib/mongodb'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const owner = searchParams.get('owner'); + if (!owner) { + return NextResponse.json({ error: 'Owner address is required' }, { status: 400 }); + } + + const client = await clientPromise; + const db = client.db(); + const coins = await db + .collection('coins') + .find({ ownerAddress: owner }) + .sort({ createdAt: -1 }) + .toArray(); + + return NextResponse.json(coins); + } catch (error) { + console.error('Error fetching coins:', error); + return NextResponse.json({ error: 'Failed to fetch coins' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/api/notify/route.ts b/minikit/coin-your-idea/app/api/notify/route.ts new file mode 100644 index 0000000..eefa1a9 --- /dev/null +++ b/minikit/coin-your-idea/app/api/notify/route.ts @@ -0,0 +1,32 @@ +import { sendFrameNotification } from "@/lib/notification-client"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { fid, notification } = body; + + const result = await sendFrameNotification({ + fid, + title: notification.title, + body: notification.body, + notificationDetails: notification.notificationDetails, + }); + + if (result.state === "error") { + return NextResponse.json( + { error: result.error }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Unknown error", + }, + { status: 400 }, + ); + } +} diff --git a/minikit/coin-your-idea/app/api/webhook/route.ts b/minikit/coin-your-idea/app/api/webhook/route.ts new file mode 100644 index 0000000..fd166a9 --- /dev/null +++ b/minikit/coin-your-idea/app/api/webhook/route.ts @@ -0,0 +1,124 @@ +import { + setUserNotificationDetails, + deleteUserNotificationDetails, +} from "@/lib/notification"; +import { sendFrameNotification } from "@/lib/notification-client"; +import { http } from "viem"; +import { createPublicClient } from "viem"; +import { optimism } from "viem/chains"; + +const appName = process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME; + +const KEY_REGISTRY_ADDRESS = "0x00000000Fc1237824fb747aBDE0FF18990E59b7e"; + +const KEY_REGISTRY_ABI = [ + { + inputs: [ + { name: "fid", type: "uint256" }, + { name: "key", type: "bytes" }, + ], + name: "keyDataOf", + outputs: [ + { + components: [ + { name: "state", type: "uint8" }, + { name: "keyType", type: "uint32" }, + ], + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +async function verifyFidOwnership(fid: number, appKey: `0x${string}`) { + const client = createPublicClient({ + chain: optimism, + transport: http(), + }); + + try { + const result = await client.readContract({ + address: KEY_REGISTRY_ADDRESS, + abi: KEY_REGISTRY_ABI, + functionName: "keyDataOf", + args: [BigInt(fid), appKey], + }); + + return result.state === 1 && result.keyType === 1; + } catch (error) { + console.error("Key Registry verification failed:", error); + return false; + } +} + +function decode(encoded: string) { + return JSON.parse(Buffer.from(encoded, "base64url").toString("utf-8")); +} + +export async function POST(request: Request) { + const requestJson = await request.json(); + + const { header: encodedHeader, payload: encodedPayload } = requestJson; + + const headerData = decode(encodedHeader); + const event = decode(encodedPayload); + + const { fid, key } = headerData; + + const valid = await verifyFidOwnership(fid, key); + + if (!valid) { + return Response.json( + { success: false, error: "Invalid FID ownership" }, + { status: 401 }, + ); + } + + switch (event.event) { + case "frame_added": + console.log( + "frame_added", + "event.notificationDetails", + event.notificationDetails, + ); + if (event.notificationDetails) { + await setUserNotificationDetails(fid, event.notificationDetails); + await sendFrameNotification({ + fid, + title: `Welcome to ${appName}`, + body: `Thank you for adding ${appName}`, + }); + } else { + await deleteUserNotificationDetails(fid); + } + + break; + case "frame_removed": { + console.log("frame_removed"); + await deleteUserNotificationDetails(fid); + break; + } + case "notifications_enabled": { + console.log("notifications_enabled", event.notificationDetails); + await setUserNotificationDetails(fid, event.notificationDetails); + await sendFrameNotification({ + fid, + title: `Welcome to ${appName}`, + body: `Thank you for enabling notifications for ${appName}`, + }); + + break; + } + case "notifications_disabled": { + console.log("notifications_disabled"); + await deleteUserNotificationDetails(fid); + + break; + } + } + + return Response.json({ success: true }); +} diff --git a/minikit/coin-your-idea/app/coins/[id]/page.tsx b/minikit/coin-your-idea/app/coins/[id]/page.tsx new file mode 100644 index 0000000..0e9db55 --- /dev/null +++ b/minikit/coin-your-idea/app/coins/[id]/page.tsx @@ -0,0 +1,58 @@ +"use client"; +import { useEffect, useState } from 'react'; +import Image from 'next/image'; + +interface CoinMetadata { + name: string; + symbol: string; + description: string; + image: string; +} + +export const dynamic = 'force-dynamic'; + +export default function CoinPage({ params }: { params: { id: string } }) { + const { id } = params; + const [metadata, setMetadata] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`/api/coin-metadata/${id}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load metadata'); + return res.json(); + }) + .then(setMetadata) + .catch((err) => setError(err.message)); + }, [id]); + + if (error) { + return
{error}
; + } + + if (!metadata) { + return
Loading...
; + } + + const { name, symbol, description, image } = metadata; + + return ( +
+
+

+ {name} ({symbol}) +

+

{description}

+ {image && ( + {`${name} + )} +
+
+ ); +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/components/CoinButton.tsx b/minikit/coin-your-idea/app/components/CoinButton.tsx new file mode 100644 index 0000000..6224ca1 --- /dev/null +++ b/minikit/coin-your-idea/app/components/CoinButton.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from "react"; +import type { Address, Abi } from "viem"; +import { + useWriteContract, + useSimulateContract, + useAccount, +} from "wagmi"; +import { base } from 'wagmi/chains'; +import { createCoinCall } from "@zoralabs/coins-sdk"; +import { Button } from "./ui/button"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Loader2, CheckCircle, AlertCircle, Coins } from "lucide-react"; +import { toast } from "sonner"; + +// Extend contract parameters to include chainId +type ContractParams = { + address: Address; + abi: Abi; + functionName: string; + args: readonly unknown[] | unknown[]; + value?: bigint; +}; + +export type CreateCoinArgs = { + name: string; + symbol: string; + uri: string; + initialPurchaseWei?: bigint; + onSuccess?: (hash: string) => void; + onError?: (error: Error) => void; + className?: string; +}; + +export function CoinButton({ + name, + symbol, + uri = "", + initialPurchaseWei = BigInt(0), + onSuccess, + onError, + className +}: CreateCoinArgs) { + const account = useAccount(); + const [status, setStatus] = useState('idle'); + const [contractParams, setContractParams] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + // Create the contract call params + useEffect(() => { + const fetchContractParams = async () => { + if (!uri) { + setErrorMessage("URI is required"); + setContractParams(null); + return; + } + + if (!account.address) { + setErrorMessage("Please connect your wallet"); + setContractParams(null); + return; + } + + try { + const params = await createCoinCall({ + name, + symbol, + uri, + payoutRecipient: account.address, + initialPurchaseWei: initialPurchaseWei || BigInt(0), + platformReferrer: "0x0000000000000000000000000000000000000000" as `0x${string}`, + }); + + // Extract only the parameters we need + const newContractParams: ContractParams = { + address: params.address, + abi: params.abi, + functionName: params.functionName, + args: params.args, + value: params.value, + }; + + console.log("Setting new contract params:", newContractParams); + setContractParams(newContractParams); + setErrorMessage(null); + } catch (error) { + console.error("Error creating coin call params:", error); + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Failed to create coin parameters'; + setErrorMessage(message); + setContractParams(null); + onError?.(error instanceof Error ? error : new Error(message)); + } + }; + + fetchContractParams(); + }, [name, symbol, uri, account.address, initialPurchaseWei, onError]); + + // Simulate the contract call + const { data: simulation, error: simulationError } = useSimulateContract({ + address: contractParams?.address as `0x${string}`, + abi: contractParams?.abi, + functionName: contractParams?.functionName, + args: contractParams?.args, + chainId: base.id as typeof base.id, + query: { + enabled: !!contractParams, + }, + }); + + // Debug logging + useEffect(() => { + console.log('Contract Params:', contractParams); + console.log('Simulation Data:', simulation); + console.log('Simulation Error:', simulationError); + }, [contractParams, simulation, simulationError]); + + // Prepare write function + const { writeContractAsync } = useWriteContract(); + + const handleClick = async () => { + if (!contractParams) { + setErrorMessage("Contract parameters not ready"); + return; + } + + try { + setIsLoading(true); + setStatus('pending'); + // Provide chainId explicitly to avoid connector.getChainId() error + const hash = await writeContractAsync({ + ...contractParams, + chainId: base.id, + }); + setStatus('success'); + onSuccess?.(hash); + } catch (error: any) { + // Handle user rejection separately + const isUserRejected = error?.message?.includes('User rejected'); + if (isUserRejected) { + // User cancelled the transaction + setStatus('idle'); + toast('Transaction cancelled by user'); + return; + } + setStatus('error'); + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'Failed to create coin'; + setErrorMessage(message); + onError?.(error instanceof Error ? error : new Error(message)); + toast.error(message); + } finally { + setIsLoading(false); + } + }; + + // Format the ETH value for display + const formatEthValue = (wei: bigint | undefined) => { + if (!wei) return "0.01 ETH"; + return `${Number(wei) / 1e18} ETH`; + }; + + return ( +
+
+
+ + Initial cost: {formatEthValue(initialPurchaseWei)} +
+ + {simulationError && ( +
+ + Simulation failed +
+ )} +
+ + + + {simulationError && ( + + + Simulation Error + + {simulationError.message} + + + )} + + {status === 'error' && errorMessage && ( + + + Transaction Failed + + {errorMessage} + + + )} +
+ ); +} diff --git a/minikit/coin-your-idea/app/components/CoinCreationFlow.tsx b/minikit/coin-your-idea/app/components/CoinCreationFlow.tsx new file mode 100644 index 0000000..0f48220 --- /dev/null +++ b/minikit/coin-your-idea/app/components/CoinCreationFlow.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { AlertCircle } from "lucide-react"; +import { IdeaInput } from "./IdeaInput"; +import { CoinDetails } from "./CoinDetails"; +import { CoinButton } from "./CoinButton"; +import { CreateCoinArgs } from "@/lib/types"; + +interface CoinCreationFlowProps { + onSuccess: (hash: string) => void; +} + +export function CoinCreationFlow({ onSuccess }: CoinCreationFlowProps) { + const [coinParams, setCoinParams] = useState(null); + const [error, setError] = useState(null); + + const handleIdeaGenerated = (params: CreateCoinArgs) => { + setCoinParams(params); + setError(null); + }; + + const handleError = (error: Error) => { + setError(error.message); + }; + + const handleTxHash = (hash: string) => { + onSuccess(hash); + }; + + return ( +
+ + + {error && ( + + + Error + {error} + + )} + + {coinParams && ( +
+ + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/components/CoinDetails.tsx b/minikit/coin-your-idea/app/components/CoinDetails.tsx new file mode 100644 index 0000000..1e36cfa --- /dev/null +++ b/minikit/coin-your-idea/app/components/CoinDetails.tsx @@ -0,0 +1,56 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card"; +import { CreateCoinArgs } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface CoinDetailsProps { + coinParams: CreateCoinArgs; +} + +export function CoinDetails({ coinParams }: CoinDetailsProps) { + return ( + +
+ + + Coin Details + + + Review your coin specifications before creation + + + +
+ {[ + { label: "Name", value: coinParams.name }, + { label: "Symbol", value: coinParams.symbol }, + { label: "URI", value: coinParams.uri }, + { label: "Payout Recipient", value: coinParams.payoutRecipient }, + ].map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/minikit/coin-your-idea/app/components/DemoComponents.tsx b/minikit/coin-your-idea/app/components/DemoComponents.tsx new file mode 100644 index 0000000..15f1ee6 --- /dev/null +++ b/minikit/coin-your-idea/app/components/DemoComponents.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { useAccount } from "wagmi"; +import { + Transaction, + TransactionButton, + TransactionToast, + TransactionToastAction, + TransactionToastIcon, + TransactionToastLabel, + TransactionError, + TransactionResponse, + TransactionStatusAction, + TransactionStatusLabel, + TransactionStatus, +} from "@coinbase/onchainkit/transaction"; +import { useNotification } from "@coinbase/onchainkit/minikit"; + +type ButtonProps = { + children: ReactNode; + variant?: "primary" | "secondary" | "outline" | "ghost"; + size?: "sm" | "md" | "lg"; + className?: string; + onClick?: () => void; + disabled?: boolean; + type?: "button" | "submit" | "reset"; + icon?: ReactNode; +} + +export function Button({ + children, + variant = "primary", + size = "md", + className = "", + onClick, + disabled = false, + type = "button", + icon, +}: ButtonProps) { + const baseClasses = + "inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#0052FF] disabled:opacity-50 disabled:pointer-events-none"; + + const variantClasses = { + primary: + "bg-[var(--app-accent)] hover:bg-[var(--app-accent-hover)] text-[var(--app-background)]", + secondary: + "bg-[var(--app-gray)] hover:bg-[var(--app-gray-dark)] text-[var(--app-foreground)]", + outline: + "border border-[var(--app-accent)] hover:bg-[var(--app-accent-light)] text-[var(--app-accent)]", + ghost: + "hover:bg-[var(--app-accent-light)] text-[var(--app-foreground-muted)]", + }; + + const sizeClasses = { + sm: "text-xs px-2.5 py-1.5 rounded-md", + md: "text-sm px-4 py-2 rounded-lg", + lg: "text-base px-6 py-3 rounded-lg", + }; + + return ( + + ); +} + +type CardProps = { + title?: string; + children: ReactNode; + className?: string; + onClick?: () => void; +} + +function Card({ + title, + children, + className = "", + onClick, +}: CardProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick(); + } + }; + + return ( +
+ {title && ( +
+

+ {title} +

+
+ )} +
{children}
+
+ ); +} + +type FeaturesProps = { + setActiveTab: (tab: string) => void; +}; + +export function Features({ setActiveTab }: FeaturesProps) { + return ( +
+ +
    +
  • + + + Minimalistic and beautiful UI design + +
  • +
  • + + + Responsive layout for all devices + +
  • +
  • + + + Dark mode support + +
  • +
  • + + + OnchainKit integration + +
  • +
+ +
+
+ ); +} + +type HomeProps = { + setActiveTab: (tab: string) => void; +}; + +export function Home({ setActiveTab }: HomeProps) { + return ( +
+ +

+ This is a minimalistic Mini App built with OnchainKit components. +

+ +
+ + + + +
+ ); +} + +type IconProps = { + name: "heart" | "star" | "check" | "plus" | "arrow-right"; + size?: "sm" | "md" | "lg"; + className?: string; +} + +export function Icon({ name, size = "md", className = "" }: IconProps) { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-5 h-5", + lg: "w-6 h-6", + }; + + const icons = { + heart: ( + + ), + star: ( + + ), + check: ( + + ), + plus: ( + + ), + "arrow-right": ( + + ), + }; + + return ( + + {icons[name]} + + ); +} + +type Todo = { + id: number; + text: string; + completed: boolean; +} + +function TodoList() { + const [todos, setTodos] = useState([ + { id: 1, text: "Learn about MiniKit", completed: false }, + { id: 2, text: "Build a Mini App", completed: true }, + { id: 3, text: "Deploy to Base and go viral", completed: false }, + ]); + const [newTodo, setNewTodo] = useState(""); + + const addTodo = () => { + if (newTodo.trim() === "") return; + + const newId = + todos.length > 0 ? Math.max(...todos.map((t) => t.id)) + 1 : 1; + setTodos([...todos, { id: newId, text: newTodo, completed: false }]); + setNewTodo(""); + }; + + const toggleTodo = (id: number) => { + setTodos( + todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const deleteTodo = (id: number) => { + setTodos(todos.filter((todo) => todo.id !== id)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + addTodo(); + } + }; + + return ( + +
+
+ setNewTodo(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add a new task..." + className="flex-1 px-3 py-2 bg-[var(--app-card-bg)] border border-[var(--app-card-border)] rounded-lg text-[var(--app-foreground)] placeholder-[var(--app-foreground-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--app-accent)]" + /> + +
+ +
    + {todos.map((todo) => ( +
  • +
    + + +
    + +
  • + ))} +
+
+
+ ); +} + + +function TransactionCard() { + const { address } = useAccount(); + + // Example transaction call - sending 0 ETH to self + const calls = useMemo(() => address + ? [ + { + to: address, + data: "0x" as `0x${string}`, + value: BigInt(0), + }, + ] + : [], [address]); + + const sendNotification = useNotification(); + + const handleSuccess = useCallback(async (response: TransactionResponse) => { + const transactionHash = response.transactionReceipts[0].transactionHash; + + console.log(`Transaction successful: ${transactionHash}`); + + await sendNotification({ + title: "Congratulations!", + body: `You sent your a transaction, ${transactionHash}!`, + }); + }, [sendNotification]); + + return ( + +
+

+ Experience the power of seamless sponsored transactions with{" "} + + OnchainKit + + . +

+ +
+ {address ? ( + + console.error("Transaction failed:", error) + } + > + + + + + + + + + + + + ) : ( +

+ Connect your wallet to send a transaction +

+ )} +
+
+
+ ); +} diff --git a/minikit/coin-your-idea/app/components/Header.tsx b/minikit/coin-your-idea/app/components/Header.tsx new file mode 100644 index 0000000..587b268 --- /dev/null +++ b/minikit/coin-your-idea/app/components/Header.tsx @@ -0,0 +1,76 @@ +import { useAccount, useConnect, useDisconnect } from "wagmi"; +import { Button } from "./ui/button"; +import { Wallet, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { Logo } from "./Logo"; + +const formatAddress = (address: string | undefined) => { + if (!address) return ""; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +}; + +export const Header = () => { + const account = useAccount(); + const { connectors, connect, error } = useConnect(); + const { disconnect } = useDisconnect(); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard!"); + }; + + return ( +
+
+ +

+ CoinSpark +

+
+ +
+ {account.status === "connected" ? ( +
+
+
+ + {formatAddress(account.address)} + + +
+ +
+ ) : ( +
+ {connectors.filter(connector => connector.name === 'Coinbase Wallet').map((connector) => ( + + ))} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/minikit/coin-your-idea/app/components/IdeaInput.tsx b/minikit/coin-your-idea/app/components/IdeaInput.tsx new file mode 100644 index 0000000..2b53c10 --- /dev/null +++ b/minikit/coin-your-idea/app/components/IdeaInput.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "./ui/card"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { CreateCoinArgs } from "@/lib/types"; +import { useAccount } from 'wagmi'; + +const MAX_IDEA_LENGTH = 400; + +interface IdeaInputProps { + onIdeaGenerated: (params: CreateCoinArgs) => void; +} + +export function IdeaInput({ onIdeaGenerated }: IdeaInputProps) { + const [idea, setIdea] = useState(''); + const [loading, setLoading] = useState(false); + const { address: accountAddress } = useAccount(); + + const handleIdeaChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const singleLineValue = value.replace(/\n/g, ''); + if (singleLineValue.length <= MAX_IDEA_LENGTH) { + setIdea(singleLineValue); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + const generateCoinParams = async (ideaText: string) => { + if (!ideaText) return; + setLoading(true); + + try { + if (!accountAddress) throw new Error('Connect wallet to generate coins'); + const response = await fetch('/api/generate-coin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ idea: ideaText, owner: accountAddress }), + }); + + if (!response.ok) { + throw new Error('Failed to generate coin parameters'); + } + + const data = await response.json(); + + let metadataUrl = data.metadataUrl; + if (metadataUrl.startsWith('/') && typeof window !== 'undefined') { + metadataUrl = window.location.origin + metadataUrl; + } + + onIdeaGenerated({ + name: data.name, + symbol: data.symbol, + uri: metadataUrl, + payoutRecipient: "0x0000000000000000000000000000000000000000" as `0x${string}`, + initialPurchaseWei: BigInt(0) + }); + + toast.success("Generated coin parameters successfully!"); + + } catch (e) { + const errorMessage = `Error: ${(e as Error).message}`; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + Turn Your Idea into a Coin + + Enter your idea and coin it! (Max {MAX_IDEA_LENGTH} characters) + + + +
+