Skip to content

feat(demo): add “CoinSpark” demo under minikit/Coin-your-idea #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions minikit/coin-your-idea/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.env
.env.local
70 changes: 70 additions & 0 deletions minikit/coin-your-idea/README.md
Original file line number Diff line number Diff line change
@@ -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
Binary file added minikit/coin-your-idea/app/.DS_Store
Binary file not shown.
22 changes: 22 additions & 0 deletions minikit/coin-your-idea/app/.well-known/farcaster.json/route.ts
Original file line number Diff line number Diff line change
@@ -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`,
},
});
}
Binary file added minikit/coin-your-idea/app/api/.DS_Store
Binary file not shown.
54 changes: 54 additions & 0 deletions minikit/coin-your-idea/app/api/coin-metadata/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};

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 });
}
}
92 changes: 92 additions & 0 deletions minikit/coin-your-idea/app/api/generate-coin/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
50 changes: 50 additions & 0 deletions minikit/coin-your-idea/app/api/generateImage/route.tsx
Original file line number Diff line number Diff line change
@@ -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(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
fontSize: 40,
color: 'white',
background: 'linear-gradient(to bottom right, #663399, #FF6B6B)',
width: '100%',
height: '100%',
padding: 40,
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '15px',
}}
>
<div style={{ fontSize: 60, fontWeight: 'bold', marginBottom: 20 }}>This is a Coined Idea</div>
<div style={{
padding: '20px',
background: 'rgba(0,0,0,0.3)',
borderRadius: '10px',
maxWidth: '80%',
wordWrap: 'break-word'
}}>
{idea}
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
} catch (error) {
console.error('Error generating image:', error);
return new Response('Error generating image', { status: 500 });
}
}
53 changes: 53 additions & 0 deletions minikit/coin-your-idea/app/api/metadata/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};

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 }
);
}
}
25 changes: 25 additions & 0 deletions minikit/coin-your-idea/app/api/my-coins/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
32 changes: 32 additions & 0 deletions minikit/coin-your-idea/app/api/notify/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
124 changes: 124 additions & 0 deletions minikit/coin-your-idea/app/api/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
58 changes: 58 additions & 0 deletions minikit/coin-your-idea/app/coins/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<CoinMetadata | null>(null);
const [error, setError] = useState<string | null>(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 <div className="text-center text-red-500">{error}</div>;
}

if (!metadata) {
return <div className="text-center">Loading...</div>;
}

const { name, symbol, description, image } = metadata;

return (
<main className="min-h-screen flex flex-col items-center justify-center bg-background p-6">
<div className="max-w-md bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-center">
<h1 className="text-3xl font-bold mb-4">
{name} ({symbol})
</h1>
<p className="mb-4">{description}</p>
{image && (
<Image
src={image}
alt={`${name} image`}
width={1200}
height={630}
className="w-full h-auto rounded"
/>
)}
</div>
</main>
);
}
219 changes: 219 additions & 0 deletions minikit/coin-your-idea/app/components/CoinButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('idle');
const [contractParams, setContractParams] = useState<ContractParams | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Coins className="h-4 w-4 text-brand" />
<span>Initial cost: {formatEthValue(initialPurchaseWei)}</span>
</div>

{simulationError && (
<div className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Simulation failed
</div>
)}
</div>

<Button
onClick={handleClick}
disabled={isLoading || !!simulationError || !contractParams}
className={`w-full bg-accentPrimary hover:bg-accentPrimary/90 text-white`}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deploying...
</>
) : "Deploy it!"}
</Button>

{simulationError && (
<Alert variant="destructive" className="mt-4 border border-red-200 bg-red-50/50 dark:bg-red-900/10 slide-in-from-top animate-in">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Simulation Error</AlertTitle>
<AlertDescription className="text-sm">
{simulationError.message}
</AlertDescription>
</Alert>
)}

{status === 'error' && errorMessage && (
<Alert variant="destructive" className="mt-4 border border-red-200 bg-red-50/50 dark:bg-red-900/10 slide-in-from-top animate-in">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Transaction Failed</AlertTitle>
<AlertDescription className="text-sm">
{errorMessage}
</AlertDescription>
</Alert>
)}
</div>
);
}
57 changes: 57 additions & 0 deletions minikit/coin-your-idea/app/components/CoinCreationFlow.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateCoinArgs | null>(null);
const [error, setError] = useState<string | null>(null);

const handleIdeaGenerated = (params: CreateCoinArgs) => {
setCoinParams(params);
setError(null);
};

const handleError = (error: Error) => {
setError(error.message);
};

const handleTxHash = (hash: string) => {
onSuccess(hash);
};

return (
<div className="space-y-6">
<IdeaInput onIdeaGenerated={handleIdeaGenerated} />

{error && (
<Alert variant="destructive" className="mb-6 slide-in-from-top animate-in">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

{coinParams && (
<div className="space-y-8">
<CoinDetails coinParams={coinParams} />
<CoinButton
name={coinParams.name}
symbol={coinParams.symbol}
uri={coinParams.uri}
initialPurchaseWei={coinParams.initialPurchaseWei}
onError={handleError}
onSuccess={handleTxHash}
/>
</div>
)}
</div>
);
}
56 changes: 56 additions & 0 deletions minikit/coin-your-idea/app/components/CoinDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="w-full max-w-2xl mx-auto bg-gradient-to-br from-accentPrimary/5 via-background/80 to-background/90 p-1 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300">
<div className="bg-background/95 backdrop-blur-sm rounded-xl p-4 sm:p-6">
<CardHeader className="bg-accentPrimary/10 border-b border-accentPrimary/10 p-3 sm:p-4 rounded-t-lg">
<CardTitle className="text-xl sm:text-2xl font-bold text-accentPrimary tracking-tight">
Coin Details
</CardTitle>
<CardDescription className="text-accentPrimary/70 text-sm sm:text-base mt-1">
Review your coin specifications before creation
</CardDescription>
</CardHeader>
<CardContent className="p-3 sm:p-4">
<div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2">
{[
{ label: "Name", value: coinParams.name },
{ label: "Symbol", value: coinParams.symbol },
{ label: "URI", value: coinParams.uri },
{ label: "Payout Recipient", value: coinParams.payoutRecipient },
].map((item) => (
<div
key={item.label}
className={cn(
"group rounded-xl p-3 sm:p-4 bg-accentPrimary/5 hover:bg-accentPrimary/10 transition-all duration-200 cursor-pointer",
item.label === "URI" && "sm:col-span-2",
"active:bg-accentPrimary/15" // For touch feedback
)}
>
<h3 className="text-xs sm:text-sm font-semibold text-accentPrimary uppercase tracking-wider mb-2">
{item.label}
</h3>
<p
className={cn(
"text-accentPrimary/90 text-sm sm:text-base",
item.label === "Payout Recipient" && "font-mono text-xs sm:text-sm",
item.label === "URI" && "break-all text-xs sm:text-sm"
)}
>
{item.value}
</p>
</div>
))}
</div>
</CardContent>
</div>
</Card>
);
}
462 changes: 462 additions & 0 deletions minikit/coin-your-idea/app/components/DemoComponents.tsx

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions minikit/coin-your-idea/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Logo className="h-8 w-8 text-accentPrimary" />
<h1 className="text-2xl font-heading text-textPrimary">
CoinSpark
</h1>
</div>

<div className="flex items-center gap-3">
{account.status === "connected" ? (
<div className="flex items-center gap-2">
<div className="px-3 py-1.5 bg-white rounded-full flex items-center gap-2 border border-accentPrimary shadow-sm">
<div className="w-2 h-2 rounded-full bg-accentPrimary"></div>
<span className="text-xs font-medium text-accentPrimary">
{formatAddress(account.address)}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-accentPrimary"
onClick={() => account.address && copyToClipboard(account.address)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => disconnect()}
className="text-xs text-accentPrimary border-accentPrimary hover:bg-accentPrimary hover:text-white"
>
Disconnect
</Button>
</div>
) : (
<div className="flex gap-2">
{connectors.filter(connector => connector.name === 'Coinbase Wallet').map((connector) => (
<Button
key={connector.uid}
onClick={() => connect({ connector })}
variant="gradient"
size="sm"
className="flex items-center gap-1.5 bg-accentPrimary hover:bg-accentPrimary/90 text-white"
>
<Wallet className="h-4 w-4" />
Sign In
</Button>
))}
</div>
)}
</div>
</div>
);
};
116 changes: 116 additions & 0 deletions minikit/coin-your-idea/app/components/IdeaInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { address: accountAddress } = useAccount();

const handleIdeaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const singleLineValue = value.replace(/\n/g, '');
if (singleLineValue.length <= MAX_IDEA_LENGTH) {
setIdea(singleLineValue);
}
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<Card className="border-accentPrimary shadow-sm hover-scale">
<CardHeader className="pb-3">
<CardTitle className="text-xl text-accentPrimary">Turn Your Idea into a Coin</CardTitle>
<CardDescription className="text-accentPrimary/80">
Enter your idea and coin it! (Max {MAX_IDEA_LENGTH} characters)
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<textarea
value={idea}
onChange={handleIdeaChange}
onKeyDown={handleKeyDown}
placeholder="Input your idea here example: 'I want to create a coin for my favorite food'"
className="w-full px-3 py-2 border border-accentPrimary rounded-md focus:outline-none focus:ring-2 focus:ring-accentPrimary min-h-[100px] resize-none"
rows={1}
/>
<div className="text-right text-sm text-accentPrimary/80">
{idea.length}/{MAX_IDEA_LENGTH} characters
</div>
</div>
</CardContent>
<CardFooter>
<Button
onClick={() => generateCoinParams(idea)}
disabled={!idea || loading}
className="w-full bg-accentPrimary hover:bg-accentPrimary/90 text-white"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating your coin...
</>
) : 'Coin it!'}
</Button>
</CardFooter>
</Card>
);
}
24 changes: 24 additions & 0 deletions minikit/coin-your-idea/app/components/Logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

export function Logo({ className }: { className?: string }) {
return (
<svg
className={className}
width="32"
height="32"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Coin outline */}
<circle cx="32" cy="32" r="30" stroke="currentColor" strokeWidth="4" />
{/* Brain silhouette inside coin */}
<path
d="M20 32c0-6 4-10 12-10s12 4 12 10-4 10-12 10-12-4-12-10z"
fill="currentColor"
/>
{/* Brain midline */}
<line x1="32" y1="22" x2="32" y2="42" stroke="#ffffff" strokeWidth="3" />
</svg>
);
}
64 changes: 64 additions & 0 deletions minikit/coin-your-idea/app/components/SuccessCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "./ui/card";
import { Button } from "./ui/button";
import { CheckCircle, Copy, ExternalLink } from "lucide-react";
import { toast } from "sonner";

interface SuccessCardProps {
txHash: string;
onReset: () => void;
}

export function SuccessCard({ txHash, onReset }: SuccessCardProps) {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};

const getEtherscanLink = (hash: string) => {
return `https://basescan.org/tx/${hash}`;
};

return (
<Card className="border-green-200 bg-green-50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700">
<CheckCircle className="h-5 w-5" />
Congratulations! Your coin is now live
</CardTitle>
<CardDescription className="text-green-600">
Your idea has been immortalized on the blockchain
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-white p-3 flex flex-col gap-2">
<p className="text-xs text-slate-500">Transaction Hash</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-slate-100 p-2 rounded flex-1 overflow-auto">{txHash}</code>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(txHash)} className="text-accentPrimary">
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => window.open(getEtherscanLink(txHash), '_blank')}
className="text-accentPrimary"
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full text-accentPrimary border-accentPrimary hover:bg-accentPrimary hover:text-white"
onClick={onReset}
>
Create Another Coin
</Button>
</CardFooter>
</Card>
);
}
49 changes: 49 additions & 0 deletions minikit/coin-your-idea/app/components/TransactionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import React from "react";
import ReactDOM from "react-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "./ui/card";
import { Button } from "./ui/button";
import { CheckCircle, Copy, ExternalLink, X } from "lucide-react";
import { toast } from "sonner";

interface TransactionModalProps {
txHash: string;
onClose: () => void;
}

export function TransactionModal({ txHash, onClose }: TransactionModalProps) {
if (!txHash) return null;
const link = `https://basescan.org/tx/${txHash}`;

return ReactDOM.createPortal(
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-md bg-white p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-green-700 flex items-center gap-2">
<CheckCircle className="h-5 w-5" />
Transaction Successful
</h3>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
<CardContent>
<p className="text-xs text-gray-600 mb-2">Transaction Hash</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 p-2 rounded flex-1 overflow-x-auto whitespace-nowrap">
{txHash}
</code>
<Button variant="ghost" size="icon" onClick={() => { navigator.clipboard.writeText(txHash); toast.success("Copied!"); }}>
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => window.open(link, "_blank")}>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>,
document.body
);
}
77 changes: 77 additions & 0 deletions minikit/coin-your-idea/app/components/WalletConnect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useAccount, useDisconnect } from "wagmi";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Wallet, Copy, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { sdk } from '@farcaster/frame-sdk';

export const WalletConnect = () => {
const account = useAccount();
const { disconnect } = useDisconnect();

const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};

const formatAddress = (address: string | undefined) => {
if (!address) return "";
return `${address.substring(0, 4)}...${address.substring(address.length - 2)}`;
};

const handleSignIn = async () => {
try {
// ensure Farcaster frame SDK is ready
await sdk.actions.ready();
const nonce = Math.random().toString(36).substring(2);
const result = await sdk.actions.signIn({ nonce });
// notify successful sign-in
toast.success('Signed in to Farcaster!');

} catch (e) {
console.error('Farcaster sign in failed', e);
toast.error('Farcaster sign in failed');
}
};

return (
<>
<div className="flex items-center gap-3">
{account.status === "connected" ? (
<div className="flex items-center gap-2">
<div className="px-3 py-1.5 bg-white rounded-full flex items-center gap-2 border border-accentPrimary shadow-sm">
<div className="w-2 h-2 rounded-full bg-accentPrimary"></div>
<span className="text-xs font-medium text-accentPrimary">{formatAddress(account.address)}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-accentPrimary"
onClick={() => account.address && copyToClipboard(account.address)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => disconnect()}
className="text-xs text-accentPrimary border-accentPrimary hover:bg-accentPrimary hover:text-white"
>
Disconnect
</Button>
</div>
) : (
<Button
onClick={handleSignIn}
variant="gradient"
size="sm"
className="flex items-center gap-1.5 bg-accentPrimary hover:bg-accentPrimary/90 text-white"
>
<Wallet className="h-4 w-4" />
Sign In
</Button>
)}
</div>
</>
);
};
33 changes: 33 additions & 0 deletions minikit/coin-your-idea/app/components/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Button } from "./ui/button";
import { Wallet, Coins } from "lucide-react";
import { useConnect } from "wagmi";

export function WelcomeScreen() {
const { connectors, connect } = useConnect();

return (
<div className="py-16 flex flex-col items-center justify-center">
<Coins className="h-16 w-16 text-accentPrimary mb-4" />
<h2 className="text-2xl font-bold text-center mb-2 text-accentPrimary">CoinSpark!</h2>
<p className="text-center text-accentPrimary/80 max-w-md mb-6">
Never let a banger go to waste. Coin it!
<br />
Connect your wallet to get started.
</p>
<div className="flex gap-3">
{connectors.filter(connector => connector.name === 'Coinbase Wallet').map((connector) => (
<Button
key={connector.uid}
onClick={() => connect({ connector })}
variant="gradient"
size="xl"
className="flex items-center gap-2 bg-accentPrimary hover:bg-accentPrimary/90 text-white"
>
<Wallet className="h-5 w-5" />
Sign In
</Button>
))}
</div>
</div>
);
}
66 changes: 66 additions & 0 deletions minikit/coin-your-idea/app/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)

function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}

function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}

function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}

export { Alert, AlertTitle, AlertDescription }
64 changes: 64 additions & 0 deletions minikit/coin-your-idea/app/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
"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",
{
variants: {
variant: {
default:
"bg-surface bg-opacity-70 backdrop-blur-md border border-highlight text-textPrimary rounded-md hover:border-secondary hover:shadow-glass transition-all duration-200",
destructive:
"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",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
gradient:
"bg-gradient-to-r from-brand/90 to-brand text-brand-foreground shadow-md hover:shadow-lg hover:from-brand hover:to-brand/90 active:scale-[0.98] transition-all duration-200",
brand:
"bg-brand text-brand-foreground shadow-sm hover:bg-brand/90 active:scale-[0.98] transition-all duration-200",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
xl: "h-12 rounded-md text-base px-8 has-[>svg]:px-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"

return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}

export { Button, buttonVariants }
92 changes: 92 additions & 0 deletions minikit/coin-your-idea/app/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-surface bg-opacity-70 backdrop-blur-md shadow-glass border border-primary border-opacity-20 rounded-xl transition hover:border-secondary hover:shadow-xl text-textPrimary flex flex-col gap-6 p-6",
className
)}
{...props}
/>
)
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}

function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}

function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}

function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}

function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}

function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}

export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
21 changes: 21 additions & 0 deletions minikit/coin-your-idea/app/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}

export { Input }
25 changes: 25 additions & 0 deletions minikit/coin-your-idea/app/components/ui/sonner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client"

import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"

const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()

return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}

export { Toaster }
120 changes: 120 additions & 0 deletions minikit/coin-your-idea/app/components/ui/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as React from "react"

import { cn } from "@/lib/utils"

const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"

const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"

const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"

const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"

const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"

export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
18 changes: 18 additions & 0 deletions minikit/coin-your-idea/app/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}

export { Textarea }
24 changes: 24 additions & 0 deletions minikit/coin-your-idea/app/components/ui/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "./button";

export function ThemeToggle() {
const { theme, setTheme } = useTheme();

return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="rounded-full"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}
25 changes: 25 additions & 0 deletions minikit/coin-your-idea/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
--background: #ffffff;
--foreground: #111111;
}

@media (prefers-color-scheme: dark) {
:root {
--background: #111111;
--foreground: #ffffff;
}
}

body {
color: var(--foreground);
background: var(--background);
font-family: "Geist", sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-size: 80%;
}
50 changes: 50 additions & 0 deletions minikit/coin-your-idea/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import './theme.css';
import '@coinbase/onchainkit/styles.css';
import type { Metadata, Viewport } from 'next';
import './globals.css';
import { Providers } from './providers';


export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};

export async function generateMetadata(): Promise<Metadata> {
const URL = process.env.NEXT_PUBLIC_URL;
return {
title: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
description:
"Generated by `create-onchain --mini`, a Next.js template for MiniKit",
other: {
"fc:frame": JSON.stringify({
version: process.env.NEXT_PUBLIC_VERSION,
imageUrl: process.env.NEXT_PUBLIC_IMAGE_URL,
button: {
title: `Launch ${process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME}`,
action: {
type: "launch_frame",
name: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
url: URL,
splashImageUrl: process.env.NEXT_PUBLIC_SPLASH_IMAGE_URL,
splashBackgroundColor: `#${process.env.NEXT_PUBLIC_SPLASH_BACKGROUND_COLOR}`,
},
},
}),
},
};
}

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="bg-background">
<Providers>{children}</Providers>
</body>
</html>
);
}
234 changes: 234 additions & 0 deletions minikit/coin-your-idea/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"use client";

import { useState, useEffect } from "react";
import { CheckCircle, AlertCircle, ExternalLink, Copy, Plus, Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "./components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "./components/ui/alert";
import { Button } from "./components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./components/ui/table";
import { toast } from "sonner";
import { CoinDetails } from "./components/CoinDetails";
import { IdeaInput } from "./components/IdeaInput";
import { CreateCoinArgs } from "@/lib/types";
import { CoinButton } from "./components/CoinButton";
import { Logo } from "./components/Logo";
import Image from "next/image";
import { useMiniKit, useAddFrame } from "@coinbase/onchainkit/minikit";
import { TransactionModal } from "./components/TransactionModal";
import { useAccount } from 'wagmi';
import { WalletConnect } from "./components/WalletConnect";

interface ApiCoin {
_id: string;
id: string;
name: string;
symbol: string;
description: string;
metadataUrl: string;
ownerAddress: string;
createdAt: string;
}

const emptyCoinArgs: CreateCoinArgs = {
name: "name",
symbol: "symbol",
uri: "uri",
payoutRecipient: "0x0000000000000000000000000000000000000000" as `0x${string}`,
initialPurchaseWei: BigInt(1),
};

export default function Page() {
const { setFrameReady, isFrameReady, context } = useMiniKit();
const addFrame = useAddFrame();
const [frameAdded, setFrameAdded] = useState(false);
const [tab, setTab] = useState<'create' | 'mycoins'>('create');
const [coinParams, setCoinParams] = useState<CreateCoinArgs | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [myCoins, setMyCoins] = useState<ApiCoin[]>([]);
// Ethereum wallet status for gating
const { status: accountStatus, address: accountAddress } = useAccount();

useEffect(() => {
if (!isFrameReady) setFrameReady();
if (context?.client.added) setFrameAdded(true);
}, [isFrameReady, setFrameReady, context]);

const handleAddFrame = async () => {
const added = await addFrame();
setFrameAdded(Boolean(added));
};

const handleIdeaGenerated = (params: CreateCoinArgs) => {
setCoinParams(params);
setApiError(null);
try { localStorage.setItem("coinParams", JSON.stringify(params)); } catch {}
};

const handleError = (error: Error) => {
setApiError(error.message);
setCoinParams(null);
};

const handleTxHash = async (hash: string) => {
setTxHash(hash);
if (accountStatus === 'connected' && accountAddress) {
try {
const res = await fetch(`/api/my-coins?owner=${accountAddress}`);
const data = await res.json();
if (Array.isArray(data)) {
setMyCoins(data);
} else {
setMyCoins([]);
setApiError('Failed to load coins: Invalid API response');
}
} catch {
setMyCoins([]);
setApiError('Failed to load coins: Network error');
}
}
};

useEffect(() => {
if (accountStatus === 'connected' && tab === 'mycoins' && accountAddress) {
fetch(`/api/my-coins?owner=${accountAddress}`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) setMyCoins(data);
else {
setMyCoins([]);
setApiError('Failed to load coins: Invalid API response');
}
})
.catch(() => {
setMyCoins([]);
setApiError('Failed to load coins: Network error');
});
}
}, [accountStatus, tab, accountAddress]);

// Debug: log myCoins state
useEffect(() => {
console.log('MyCoins state:', myCoins);
}, [myCoins]);

const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};

const getEtherscanLink = (hash: string) => `https://basescan.org/tx/${hash}`;

return (
<main className="min-h-screen relative">
<Image src="/hero-bg.svg" alt="Background" fill className="object-cover -z-10" priority quality={75} sizes="100vw" />
<div className="mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-8 max-w-full sm:max-w-lg md:max-w-3xl lg:max-w-5xl">
<div className="flex flex-row items-center justify-between mb-6 gap-2 sm:gap-4">
<div className="flex items-center gap-2">
<Logo className="h-6 w-6 sm:h-7 sm:w-7 text-accentPrimary" />
<h1 className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-heading text-accentPrimary">CoinSpark</h1>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<WalletConnect />
{!frameAdded && (
<Button variant="ghost" size="icon" onClick={handleAddFrame} className="text-accentPrimary">
<Plus className="h-4 w-4" />
</Button>
)}
{frameAdded && (
<span className="text-green-600">
<Check className="h-4 w-4" />
</span>
)}
</div>
</div>

{apiError && (
<Alert variant="destructive" className="mb-6 slide-in-from-top animate-in">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{apiError}</AlertDescription>
</Alert>
)}

<div className="space-y-6">
<div className="flex space-x-4 mb-6">
<button onClick={() => setTab('create')} className={`px-3 py-1 rounded font-medium transition-colors ${tab==='create'?'bg-accentPrimary text-white':'text-textPrimary hover:bg-accentPrimary/10'}`}>Create</button>
<button onClick={() => setTab('mycoins')} className={`px-3 py-1 rounded font-medium transition-colors ${tab==='mycoins'?'bg-accentPrimary text-white':'text-textPrimary hover:bg-accentPrimary/10'}`}>My Coins</button>
</div>

{tab === 'create' ? (
accountStatus === 'connected' ? (
<>
<IdeaInput onIdeaGenerated={handleIdeaGenerated} />
{coinParams && (
<div className="space-y-4">
<CoinDetails coinParams={coinParams} />
<CoinButton {...coinParams} onSuccess={handleTxHash} onError={handleError} className="w-full sm:w-auto" />
</div>
)}
{txHash && (
<TransactionModal txHash={txHash} onClose={() => setTxHash(null)} />
)}
</>
) : (
<div className="text-center text-textPrimary">
<div className="py-12 sm:py-16 flex flex-col items-center justify-center">
<Logo className="h-12 w-12 sm:h-16 sm:w-16 text-accentPrimary mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-center mb-2 text-accentPrimary font-heading">
CoinSpark
</h2>
<p className="text-center text-accentPrimary/80 text-sm sm:text-base max-w-xs sm:max-w-md mb-6">
Never let an idea go to waste. Coin it! <br />
Connect your wallet to get started.
</p>
</div>
</div>
)
) : (
<div>
{myCoins.length > 0 ? (
<div className="grid gap-4">
{myCoins.map(coin => (
<Card key={coin.id} className="border">
<CardHeader><CardTitle>{coin.name} ({coin.symbol})</CardTitle></CardHeader>
<CardContent>{coin.description}</CardContent>
<CardFooter className="flex justify-between">
<div className="flex space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/coins/${coin.id}`)}
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const embedCode = `<iframe src="${window.location.origin}/coins/${coin.id}" width="400" height="500" style="border:none;"></iframe>`;
copyToClipboard(embedCode);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(coin.metadataUrl)}>
<Copy className="h-4 w-4" />
</Button>
</CardFooter>
</Card>
))}
</div>
) : (
<div className="text-center text-textSecondary py-12">
No coins found. Mint one to see it here!
</div>
)}
</div>
)}
</div>
</div>
</main>
);
}
28 changes: 28 additions & 0 deletions minikit/coin-your-idea/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { type ReactNode } from "react";
import { base } from "wagmi/chains";
import { MiniKitProvider } from "@coinbase/onchainkit/minikit";
import { WagmiConfig } from "wagmi";
import { getConfig } from "../lib/wagmi";

export function Providers(props: { children: ReactNode }) {
return (
<WagmiConfig config={getConfig()}>
<MiniKitProvider
apiKey={process.env.NEXT_PUBLIC_ONCHAINKIT_API_KEY}
chain={base}
config={{
appearance: {
mode: "auto",
theme: "mini-app-theme",
name: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
logo: process.env.NEXT_PUBLIC_ICON_URL,
},
}}
>
{props.children}
</MiniKitProvider>
</WagmiConfig>
);
}
137 changes: 137 additions & 0 deletions minikit/coin-your-idea/app/theme.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap");

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
--app-background: #ffffff;
--app-foreground: #111111;
--app-foreground-muted: #58585c;
--app-accent: #0052ff;
--app-accent-hover: #0047e1;
--app-accent-active: #003db8;
--app-accent-light: #e6edff;
--app-gray: #f5f5f5;
--app-gray-dark: #e0e0e0;
--app-card-bg: rgba(255, 255, 255, 0.4);
--app-card-border: rgba(0, 0, 0, 0.1);
}

@media (prefers-color-scheme: dark) {
:root {
--app-background: #111111;
--app-foreground: #ffffff;
--app-foreground-muted: #c8c8d1;
--app-accent: #0052ff;
--app-accent-hover: #0047e1;
--app-accent-active: #003db8;
--app-accent-light: #1e293b;
--app-gray: #1e1e1e;
--app-gray-dark: #2e2e2e;
--app-card-bg: rgba(17, 17, 17, 0.4);
--app-card-border: rgba(115, 115, 115, 0.5);
}
}

.mini-app-theme {
--ock-font-family: "Geist", sans-serif;
--ock-border-radius: 0.75rem;
--ock-border-radius-inner: 0.5rem;

/* Text colors */
--ock-text-inverse: var(--app-background);
--ock-text-foreground: var(--app-foreground);
--ock-text-foreground-muted: var(--app-foreground-muted);
--ock-text-error: #ef4444;
--ock-text-primary: var(--app-accent);
--ock-text-success: #22c55e;
--ock-text-warning: #f59e0b;
--ock-text-disabled: #a1a1aa;

/* Background colors */
--ock-bg-default: var(--app-background);
--ock-bg-default-hover: var(--app-gray);
--ock-bg-default-active: var(--app-gray-dark);
--ock-bg-alternate: var(--app-gray);
--ock-bg-alternate-hover: var(--app-gray-dark);
--ock-bg-alternate-active: var(--app-gray-dark);
--ock-bg-inverse: var(--app-foreground);
--ock-bg-inverse-hover: #2a2a2a;
--ock-bg-inverse-active: #3a3a3a;
--ock-bg-primary: var(--app-accent);
--ock-bg-primary-hover: var(--app-accent-hover);
--ock-bg-primary-active: var(--app-accent-active);
--ock-bg-primary-washed: var(--app-accent-light);
--ock-bg-primary-disabled: #80a8ff;
--ock-bg-secondary: var(--app-gray);
--ock-bg-secondary-hover: var(--app-gray-dark);
--ock-bg-secondary-active: #d1d1d1;
--ock-bg-error: #fee2e2;
--ock-bg-warning: #fef3c7;
--ock-bg-success: #dcfce7;
--ock-bg-default-reverse: var(--app-foreground);

/* Icon colors */
--ock-icon-color-primary: var(--app-accent);
--ock-icon-color-foreground: var(--app-foreground);
--ock-icon-color-foreground-muted: #71717a;
--ock-icon-color-inverse: var(--app-background);
--ock-icon-color-error: #ef4444;
--ock-icon-color-success: #22c55e;
--ock-icon-color-warning: #f59e0b;

/* Line colors */
--ock-line-primary: var(--app-accent);
--ock-line-default: var(--app-gray-dark);
--ock-line-heavy: #a1a1aa;
--ock-line-inverse: #d4d4d8;
}

* {
touch-action: manipulation;
}

body {
color: var(--app-foreground);
background: var(--app-background);
font-family: var(--font-geist-sans), sans-serif;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
}

@layer utilities {
.text-balance {
text-wrap: balance;
}
}

.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}

.animate-fade-out {
animation: fadeOut 3s forwards;
}

@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes fadeOut {
0% {
opacity: 1;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
20 changes: 20 additions & 0 deletions minikit/coin-your-idea/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const rawUrl = process.env.NEXT_PUBLIC_URL || 'localhost:3000';
export const PROJECT_URL = rawUrl.startsWith('http://') || rawUrl.startsWith('https://') ? rawUrl : `http://${rawUrl}`;

export const COIN_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000000" as `0x${string}`;

export const coinFactoryAbi = [
{
inputs: [
{ internalType: "string", name: "name", type: "string" },
{ internalType: "string", name: "symbol", type: "string" },
{ internalType: "string", name: "uri", type: "string" },
{ internalType: "address", name: "payoutRecipient", type: "address" },
{ internalType: "uint256", name: "initialPurchaseWei", type: "uint256" }
],
name: "createCoin",
outputs: [],
stateMutability: "payable",
type: "function"
}
] as const;
25 changes: 25 additions & 0 deletions minikit/coin-your-idea/lib/mongodb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { MongoClient } from 'mongodb';

if (!process.env.MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable');
}

const uri = process.env.MONGODB_URI;
let client: MongoClient;
let clientPromise: Promise<MongoClient>;

if (process.env.NODE_ENV === 'development') {
// @ts-ignore
if (!(global as any)._mongoClientPromise) {
client = new MongoClient(uri);
// @ts-ignore
(global as any)._mongoClientPromise = client.connect();
}
// @ts-ignore
clientPromise = (global as any)._mongoClientPromise;
} else {
client = new MongoClient(uri);
clientPromise = client.connect();
}

export default clientPromise;
67 changes: 67 additions & 0 deletions minikit/coin-your-idea/lib/notification-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
FrameNotificationDetails,
type SendNotificationRequest,
sendNotificationResponseSchema,
} from "@farcaster/frame-sdk";
import { getUserNotificationDetails } from "@/lib/notification";

const appUrl = process.env.NEXT_PUBLIC_URL || "";

type SendFrameNotificationResult =
| {
state: "error";
error: unknown;
}
| { state: "no_token" }
| { state: "rate_limit" }
| { state: "success" };

export async function sendFrameNotification({
fid,
title,
body,
notificationDetails,
}: {
fid: number;
title: string;
body: string;
notificationDetails?: FrameNotificationDetails | null;
}): Promise<SendFrameNotificationResult> {
if (!notificationDetails) {
notificationDetails = await getUserNotificationDetails(fid);
}
if (!notificationDetails) {
return { state: "no_token" };
}

const response = await fetch(notificationDetails.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
notificationId: crypto.randomUUID(),
title,
body,
targetUrl: appUrl,
tokens: [notificationDetails.token],
} satisfies SendNotificationRequest),
});

const responseJson = await response.json();

if (response.status === 200) {
const responseBody = sendNotificationResponseSchema.safeParse(responseJson);
if (responseBody.success === false) {
return { state: "error", error: responseBody.error.errors };
}

if (responseBody.data.result.rateLimitedTokens.length) {
return { state: "rate_limit" };
}

return { state: "success" };
}

return { state: "error", error: responseJson };
}
42 changes: 42 additions & 0 deletions minikit/coin-your-idea/lib/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { FrameNotificationDetails } from "@farcaster/frame-sdk";
import { redis } from "./redis";

const notificationServiceKey =
process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME ?? "minikit";

function getUserNotificationDetailsKey(fid: number): string {
return `${notificationServiceKey}:user:${fid}`;
}

export async function getUserNotificationDetails(
fid: number,
): Promise<FrameNotificationDetails | null> {
if (!redis) {
return null;
}

return await redis.get<FrameNotificationDetails>(
getUserNotificationDetailsKey(fid),
);
}

export async function setUserNotificationDetails(
fid: number,
notificationDetails: FrameNotificationDetails,
): Promise<void> {
if (!redis) {
return;
}

await redis.set(getUserNotificationDetailsKey(fid), notificationDetails);
}

export async function deleteUserNotificationDetails(
fid: number,
): Promise<void> {
if (!redis) {
return;
}

await redis.del(getUserNotificationDetailsKey(fid));
}
15 changes: 15 additions & 0 deletions minikit/coin-your-idea/lib/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Redis } from "@upstash/redis";

if (!process.env.REDIS_URL || !process.env.REDIS_TOKEN) {
console.warn(
"REDIS_URL or REDIS_TOKEN environment variable is not defined, please add to enable background notifications and webhooks.",
);
}

export const redis =
process.env.REDIS_URL && process.env.REDIS_TOKEN
? new Redis({
url: process.env.REDIS_URL,
token: process.env.REDIS_TOKEN,
})
: null;
27 changes: 27 additions & 0 deletions minikit/coin-your-idea/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export interface CreateCoinArgs {
name: string;
symbol: string;
uri: string;
payoutRecipient: `0x${string}`;
initialPurchaseWei: bigint;
}

export interface CoinDetailsProps {
name: string;
symbol: string;
uri: string;
}

export interface CoinButtonProps {
name: string;
symbol: string;
uri: string;
payoutRecipient: `0x${string}`;
initialPurchaseWei: bigint;
onError: (error: string) => void;
onTxHash: (hash: string) => void;
}

export interface IdeaInputProps {
onIdeaGenerated: (params: CreateCoinArgs) => void;
}
6 changes: 6 additions & 0 deletions minikit/coin-your-idea/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
41 changes: 41 additions & 0 deletions minikit/coin-your-idea/lib/wagmi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseEther } from "viem";
import { http, cookieStorage, createConfig, createStorage } from "wagmi";
import { base } from "wagmi/chains";
import { coinbaseWallet } from "wagmi/connectors";
import { toHex } from "viem";

export const cbWalletConnector = coinbaseWallet({
appName: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME || "MiniKit",
preference: {
keysUrl: "https://keys-dev.coinbase.com/connect",
options: "smartWalletOnly",
enableAutoSubAccounts: true,
defaultSpendLimits: {
[base.id]: [
{
token: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
allowance: toHex(parseEther("0.01")),
period: 86400,
},
],
},
},
});

export function getConfig() {
return createConfig({
chains: [base],
connectors: [cbWalletConnector],
storage: createStorage({ storage: cookieStorage }),
ssr: true,
transports: {
[base.id]: http(),
},
});
}

declare module "wagmi" {
interface Register {
config: ReturnType<typeof getConfig>;
}
}
5 changes: 5 additions & 0 deletions minikit/coin-your-idea/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
24 changes: 24 additions & 0 deletions minikit/coin-your-idea/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Transpile external SDKs
transpilePackages: ['@zoralabs/coins-sdk'],
experimental: {
esmExternals: 'loose'
},
// Silence warnings
// https://github.com/WalletConnect/walletconnect-monorepo/issues/1908
webpack: (config, options) => {
// Add fallbacks for Node modules
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
crypto: false
};
config.externals.push("pino-pretty", "lokijs", "encoding");
return config;
},
};

export default nextConfig;
50 changes: 50 additions & 0 deletions minikit/coin-your-idea/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "my-minikit-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@coinbase/onchainkit": "latest",
"@farcaster/frame-sdk": "^0.0.35",
"@radix-ui/react-slot": "^1.2.0",
"@tanstack/react-query": "^5",
"@upstash/redis": "^1.34.4",
"@zoralabs/coins-sdk": "^0.1.0",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.506.0",
"mongodb": "^6.16.0",
"next": "^14.2.15",
"next-themes": "^0.4.6",
"openai": "^4.96.2",
"postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.1",
"react": "^18",
"react-dom": "^18",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"viem": "^2.27.2",
"wagmi": "^2.14.11"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.15",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8",
"prettier": "^3.5.3",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
8 changes: 8 additions & 0 deletions minikit/coin-your-idea/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'postcss-import': {},
'postcss-nesting': {},
tailwindcss: {},
autoprefixer: {},
},
};
34 changes: 34 additions & 0 deletions minikit/coin-your-idea/public/.well-known/farcaster.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"accountAssociation": {
"header": "eyJmaWQiOjc5ODg3MiwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweGIyMjZBODZFM2EyZUQwZUQwNWI0NWI4ODIxNGVCNzczN2IyN2Q4ZjAifQ",
"payload": "eyJkb21haW4iOiJjb2luc3BhcmsudmVyY2VsLmFwcCJ9",
"signature": "MHhkZTYyMWQ4ZTBjY2FjMGU3YzAyZWJiYWI4MDcyY2QxNTdmNTE2ODRjNjFlYzRmYzllMGRmNWI1N2M2ODAzNDdkMzA0NjdjM2ZjMGI3NTg5MzhlYzQ5N2UzZTVkMjQyNTgxZjExY2U0NDQwZjZmYTgxZGZkMDExOTg5MjdmMDViMDFj"
},
"frame": {
"version": "1",
"name": "CoinSpark",
"iconUrl": "https://coinspark.vercel.app/app.png",
"splashImageUrl": "https://coinspark.vercel.app/logo.png",
"splashBackgroundColor": "#000000",
"homeUrl": "https://coinspark.vercel.app",
"subtitle": "Coin your ideas",
"description": "CoinSpark is a platform for creating and coining ideas.",
"screenshotUrls": [
"https://coinspark.vercel.app/screenshot1.png",
"https://coinspark.vercel.app/screenshot2.png",
"https://coinspark.vercel.app/screenshot3.png"
],
"primaryCategory": "social",
"tags": [
"coinspark",
"leaderboard",
"warpcast",
"earn"
],
"heroImageUrl": "https://coinspark.vercel.app/og.png",
"tagline": "Top Warpcast creators",
"ogTitle": "CoinSpark",
"ogDescription": "Climb the leaderboard and earn rewards by being active on Warpcast.",
"ogImageUrl": "https://coinspark.vercel.app/og.png"
}
}
13 changes: 13 additions & 0 deletions minikit/coin-your-idea/public/hero-bg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added minikit/coin-your-idea/public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions minikit/coin-your-idea/public/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions minikit/coin-your-idea/tailwind.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Config } from "tailwindcss";

const config: Config = {
darkMode: ['class'],
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./app/components/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
fontFamily: {
heading: ['Cabinet Grotesk', 'Satoshi', 'sans-serif'],
body: ['Inter', 'sans-serif'],
mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'],
},
colors: {
background: '#FFFFFF',
accentPrimary: '#8B5CF6',
accentViolet: '#6D28D9',
accentLight: '#C4B5FD',
success: '#34D399',
textPrimary: '#1F2937',
textSecondary: '#6B7280',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
backgroundImage: {
'hero-light': "url('/hero-bg.svg')",
'hero-dark': "url('/hero-bg.svg')",
},
backdropBlur: {
md: '10px',
},
boxShadow: {
glass: '0 4px 30px rgba(0, 0, 0, 0.1)',
},
},
},
plugins: [],
};
export default config;
26 changes: 26 additions & 0 deletions minikit/coin-your-idea/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
5,503 changes: 5,503 additions & 0 deletions minikit/coin-your-idea/yarn.lock

Large diffs are not rendered by default.