412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import React from "react"
|
|
|
|
import { useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSession, signIn } from 'next-auth/react'
|
|
import { Header } from '@/components/header'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Label } from '@/components/ui/label'
|
|
import { LgGeminiAi, LgLock, LgInfo } from '@/components/icons'
|
|
|
|
export default function CreateCoinPage() {
|
|
const router = useRouter()
|
|
const { data: session } = useSession()
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
ticker: '',
|
|
description: '',
|
|
image: '',
|
|
website: '',
|
|
twitter: '',
|
|
telegram: '',
|
|
initialBuy: 0,
|
|
useUserImage: false,
|
|
verified: false,
|
|
boosted: false,
|
|
})
|
|
|
|
const [initialBuyInput, setInitialBuyInput] = useState('')
|
|
const [initialBuyMode, setInitialBuyMode] = useState<'sol' | 'tokens'>('sol')
|
|
|
|
React.useEffect(() => {
|
|
const amount = parseFloat(initialBuyInput) || 0;
|
|
if (amount <= 0) {
|
|
setFormData(prev => ({ ...prev, initialBuy: 0 }))
|
|
return;
|
|
}
|
|
|
|
if (initialBuyMode === 'sol') {
|
|
setFormData(prev => ({ ...prev, initialBuy: amount }))
|
|
} else {
|
|
//! Bonding curve math (Constant Product)
|
|
//! k = 32,190,000,000
|
|
//! virtualSolReserves = 30
|
|
//! virtualTokenReserves = 1,073,000,000
|
|
|
|
const vTokens = 1073000000;
|
|
const vSol = 30;
|
|
const k = vTokens * vSol;
|
|
|
|
const tokensToBuy = amount;
|
|
|
|
//! cap buy at 80% of supply to avoid explosion
|
|
if (tokensToBuy >= vTokens * 0.8) {
|
|
return;
|
|
}
|
|
|
|
const newVTokens = vTokens - tokensToBuy;
|
|
const newVSol = k / newVTokens;
|
|
const costSol = newVSol - vSol;
|
|
|
|
setFormData(prev => ({ ...prev, initialBuy: costSol }))
|
|
}
|
|
}, [initialBuyInput, initialBuyMode])
|
|
|
|
React.useEffect(() => {
|
|
if (session?.user?.image) {
|
|
setFormData(prev => ({ ...prev, useUserImage: true }))
|
|
}
|
|
}, [session])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!session) {
|
|
signIn('discord')
|
|
return
|
|
}
|
|
|
|
setError('')
|
|
setLoading(true)
|
|
|
|
try {
|
|
const response = await fetch('/api/coins', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to create coin')
|
|
}
|
|
|
|
router.push(`/coin/${data._id}`)
|
|
} catch (err: any) {
|
|
setError(err.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const previewImage = formData.useUserImage && session?.user?.image
|
|
? session.user.image
|
|
: `https://api.dicebear.com/7.x/identicon/svg?seed=${formData.ticker || 'preview'}`
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
|
{/* Header */}
|
|
<div className="mb-10 text-center">
|
|
<div className="mb-4 inline-flex items-center justify-center rounded-2xl bg-accent/20 p-2">
|
|
<img src="/logo.svg" className="h-20 w-20" alt="Logo" />
|
|
</div>
|
|
<h1 className="mb-3 text-3xl font-bold">Launch Your Memecoin</h1>
|
|
<p className="text-muted-foreground">
|
|
Create a new coin and make some MULA...
|
|
</p>
|
|
</div>
|
|
|
|
{!session ? (
|
|
<div className="rounded-2xl border border-border bg-card p-8 text-center">
|
|
<LgLock className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
|
<h2 className="mb-2 text-xl font-semibold">Sign In Required</h2>
|
|
<p className="mb-6 text-muted-foreground">
|
|
Connect your Discord account to create coins
|
|
</p>
|
|
<Button
|
|
onClick={() => signIn('discord')}
|
|
className="gap-2 bg-[#5865F2] hover:bg-[#4752C4]"
|
|
>
|
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
|
</svg>
|
|
Sign in with Discord
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Preview Card */}
|
|
<div className="rounded-2xl border border-border bg-card p-6">
|
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">Preview</h3>
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src={previewImage || "/placeholder.svg"}
|
|
alt="Preview"
|
|
className="h-16 w-16 rounded-xl bg-muted object-cover"
|
|
/>
|
|
<div>
|
|
<p className="text-xl font-semibold">
|
|
{formData.name || 'Coin Name'}
|
|
</p>
|
|
<p className="text-muted-foreground">
|
|
${formData.ticker || 'TICKER'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Image Source Toggle */}
|
|
{session?.user?.image && (
|
|
<div className="mt-4 flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="useUserImage"
|
|
checked={formData.useUserImage}
|
|
onChange={(e) => setFormData({ ...formData, useUserImage: e.target.checked })}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
<label htmlFor="useUserImage" className="text-sm text-foreground">
|
|
Use my Discord profile picture as icon
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Form Fields */}
|
|
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Coin Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="e.g. Doge Moon"
|
|
className="h-12 bg-muted/30"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ticker">Ticker Symbol</Label>
|
|
<div className="relative">
|
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
$
|
|
</span>
|
|
<Input
|
|
id="ticker"
|
|
value={formData.ticker}
|
|
onChange={(e) => setFormData({ ...formData, ticker: e.target.value.toUpperCase() })}
|
|
placeholder="MOON"
|
|
className="h-12 bg-muted/30 pl-8 uppercase"
|
|
maxLength={10}
|
|
required
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
3-10 characters, letters only
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description (Optional)</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Tell us about your coin..."
|
|
className="bg-muted/30"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="website">Website (Optional)</Label>
|
|
<Input
|
|
id="website"
|
|
value={formData.website}
|
|
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
|
placeholder="https://..."
|
|
className="h-12 bg-muted/30"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="twitter">Twitter (Optional)</Label>
|
|
<Input
|
|
id="twitter"
|
|
value={formData.twitter}
|
|
onChange={(e) => setFormData({ ...formData, twitter: e.target.value })}
|
|
placeholder="https://x.com/..."
|
|
className="h-12 bg-muted/30"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="telegram">Telegram (Optional)</Label>
|
|
<Input
|
|
id="telegram"
|
|
value={formData.telegram}
|
|
onChange={(e) => setFormData({ ...formData, telegram: e.target.value })}
|
|
placeholder="https://t.me/..."
|
|
className="h-12 bg-muted/30"
|
|
/>
|
|
</div>
|
|
|
|
{/* Options Section */}
|
|
<div className="space-y-4 pt-4 border-t border-border">
|
|
<h3 className="text-lg font-semibold">Options</h3>
|
|
|
|
{/* Verified Coin Toggle - Only for verified users */}
|
|
{(session?.user as any)?.verified && (
|
|
<div className="flex items-center justify-between rounded-xl border border-border bg-muted/20 p-4">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-base">Verified Coin</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Mark this coin as verified immediately
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={formData.verified}
|
|
onCheckedChange={(checked) => setFormData({ ...formData, verified: checked })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Boost Toggle */}
|
|
<div className="flex items-center justify-between rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-base flex items-center gap-2">
|
|
Start Boosted
|
|
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-xs text-yellow-500">
|
|
+1 SOL
|
|
</span>
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Pin your coin to the top for 24 hours immediately upon launch.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={formData.boosted}
|
|
onCheckedChange={(checked) => setFormData({ ...formData, boosted: checked })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="initialBuy">Initial Buy</Label>
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setInitialBuyMode('sol')}
|
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
|
initialBuyMode === 'sol'
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
SOL
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setInitialBuyMode('tokens')}
|
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
|
initialBuyMode === 'tokens'
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
Tokens
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 flex items-center justify-center text-muted-foreground">
|
|
{initialBuyMode === 'sol' ? (
|
|
<img src="/solana.svg" className="h-4 w-4 opacity-80" alt="SOL" />
|
|
) : (
|
|
<span className="text-xs font-bold text-muted-foreground">T</span>
|
|
)}
|
|
</span>
|
|
<Input
|
|
id="initialBuy"
|
|
type="number"
|
|
step={initialBuyMode === 'sol' ? "0.01" : "1000"}
|
|
min="0"
|
|
value={initialBuyInput}
|
|
onChange={(e) => setInitialBuyInput(e.target.value)}
|
|
placeholder={initialBuyMode === 'sol' ? "0.00" : "Number of tokens"}
|
|
className="h-12 bg-muted/30 pl-10"
|
|
/>
|
|
|
|
{initialBuyMode === 'tokens' && formData.initialBuy > 0 && (
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
|
≈ {formData.initialBuy.toFixed(4)} SOL
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{initialBuyMode === 'sol'
|
|
? "Enter the amount of SOL you want to spend."
|
|
: "Enter the number of tokens to buy. The price increases as you buy more!"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Box */}
|
|
<div className="flex gap-3 rounded-xl bg-primary/10 p-4">
|
|
<LgInfo className="h-5 w-5 shrink-0" />
|
|
<div className="text-sm">
|
|
<p className="font-medium text-primary">Creation Fee: 0.02 SOL</p>
|
|
<p className="text-muted-foreground">
|
|
Your coin launches with 1 billion tokens.
|
|
Cost: 0.02 SOL + Initial Buy Amount.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex gap-3 rounded-xl bg-destructive/10 p-4">
|
|
<LgInfo className="h-5 w-5 shrink-0 text-destructive" />
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<Button
|
|
type="submit"
|
|
disabled={loading || !formData.name || !formData.ticker}
|
|
className="h-12 w-full text-base font-medium"
|
|
>
|
|
{loading ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
Launching...
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{/* Icon removed or replaced */}
|
|
Launch Coin (Cost: {(0.02 + (formData.boosted ? 1 : 0) + (formData.initialBuy || 0)).toFixed(2)} SOL)
|
|
</div>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|