From 45f2b62d8de5460220d87195439402246b265fb6 Mon Sep 17 00:00:00 2001 From: focat <78060856+focat69@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:49:09 -0700 Subject: [PATCH] chore: push site to git --- .gitignore | 27 + READ.txt | 14 + app/admin/page.tsx | 196 + app/api/admin/balance/route.ts | 51 + app/api/auth/[...nextauth]/route.ts | 6 + app/api/coins/[id]/boost/route.ts | 56 + app/api/coins/[id]/holders/route.ts | 69 + app/api/coins/[id]/route.ts | 104 + app/api/coins/[id]/verify/route.ts | 47 + app/api/coins/route.ts | 186 + app/api/comments/route.ts | 121 + app/api/gamble/route.ts | 80 + app/api/miner/buy/route.ts | 81 + app/api/miner/collect/route.ts | 158 + app/api/notice/route.ts | 60 + app/api/portfolio/route.ts | 64 + app/api/rain/route.ts | 118 + app/api/rewards/route.ts | 104 + app/api/sol-price/route.ts | 30 + app/api/swap/route.ts | 286 + app/api/tip/route.ts | 124 + app/api/trade/route.ts | 320 + app/api/trades/route.ts | 29 + app/api/user/onboarding/route.ts | 79 + app/api/user/route.ts | 48 + app/api/users/[username]/route.ts | 63 + app/coin/[id]/coin-client.tsx | 665 + app/coin/[id]/page.tsx | 50 + app/create/page.tsx | 412 + app/dashboard/page.tsx | 398 + app/discord/page.tsx | 7 + app/globals.css | 150 + app/layout.tsx | 60 + app/login/page.tsx | 80 + app/onboarding/page.tsx | 95 + app/page.tsx | 255 + app/privacy/page.tsx | 62 + app/rewards/page.tsx | 205 + app/swap/page.tsx | 375 + app/terms/page.tsx | 59 + app/u/[username]/page.tsx | 357 + components.json | 21 + components/coin-card.tsx | 186 + components/comments.tsx | 152 + components/global-chat.tsx | 614 + components/header.tsx | 200 + components/holders.tsx | 113 + components/icons/OK.tsx | 44 + components/icons/about.tsx | 55 + components/icons/add-user-male.tsx | 79 + components/icons/appointment-reminders.tsx | 44 + components/icons/back.tsx | 33 + components/icons/binoculars.tsx | 88 + components/icons/bookmark-ribbon.tsx | 44 + components/icons/bookmark.tsx | 44 + components/icons/box.tsx | 44 + components/icons/briefcase.tsx | 66 + components/icons/calendar.tsx | 55 + components/icons/cancel-2.tsx | 33 + components/icons/cancel.tsx | 44 + components/icons/check-all.tsx | 55 + components/icons/checked-2.tsx | 44 + components/icons/checked.tsx | 44 + components/icons/checkmark.tsx | 33 + components/icons/clock.tsx | 44 + components/icons/close-window.tsx | 52 + components/icons/combo-chart.tsx | 123 + components/icons/conference-call.tsx | 98 + components/icons/contacts.tsx | 44 + components/icons/cursor.tsx | 44 + components/icons/delete-sign.tsx | 44 + components/icons/delete.tsx | 44 + components/icons/document.tsx | 66 + components/icons/domain.tsx | 66 + components/icons/download.tsx | 44 + components/icons/downloads-2.tsx | 44 + components/icons/edit.tsx | 44 + components/icons/email.tsx | 44 + components/icons/empty-trush.tsx | 33 + components/icons/exit.tsx | 44 + components/icons/expand-arrow.tsx | 33 + components/icons/external-link.tsx | 44 + components/icons/facebook-new.tsx | 44 + components/icons/facebook.tsx | 44 + components/icons/file.tsx | 44 + components/icons/filled-trash.tsx | 55 + components/icons/folder-invoices-19.tsx | 44 + components/icons/folder-invoices.tsx | 44 + components/icons/for-you.tsx | 46 + components/icons/forward.tsx | 33 + components/icons/gemini-ai.tsx | 63 + components/icons/gmail.tsx | 52 + components/icons/google-logo.tsx | 55 + components/icons/groups.tsx | 169 + components/icons/hand-cursor.tsx | 44 + components/icons/home.tsx | 44 + components/icons/idea.tsx | 46 + components/icons/image-file.tsx | 66 + components/icons/index.ts | 101 + components/icons/info.tsx | 55 + components/icons/instagram-new.tsx | 55 + components/icons/key.tsx | 33 + components/icons/like.tsx | 33 + components/icons/linkedin.tsx | 44 + components/icons/lock.tsx | 55 + components/icons/mailbox-closed-flag-down.tsx | 55 + components/icons/maintenance.tsx | 63 + components/icons/marker.tsx | 44 + components/icons/menu.tsx | 33 + components/icons/music.tsx | 33 + components/icons/news.tsx | 77 + components/icons/no-synchronize.tsx | 63 + components/icons/opened-folder.tsx | 44 + components/icons/phone.tsx | 33 + components/icons/picture.tsx | 55 + components/icons/pinterest.tsx | 44 + components/icons/plus-math.tsx | 33 + components/icons/plus.tsx | 44 + components/icons/puzzle.tsx | 44 + components/icons/refresh.tsx | 33 + components/icons/restart.tsx | 33 + components/icons/save.tsx | 63 + components/icons/scroll.tsx | 55 + components/icons/search.tsx | 33 + components/icons/secured-letter.tsx | 44 + components/icons/service.tsx | 74 + components/icons/settings.tsx | 44 + components/icons/share-2.tsx | 66 + components/icons/share-3.tsx | 44 + components/icons/share.tsx | 66 + components/icons/shutdown.tsx | 55 + components/icons/speech-bubble.tsx | 33 + components/icons/star.tsx | 35 + components/icons/sun.tsx | 46 + components/icons/support.tsx | 44 + components/icons/synchronize.tsx | 52 + components/icons/toolbox.tsx | 44 + components/icons/trash.tsx | 44 + components/icons/trending-up.tsx | 123 + components/icons/twitter.tsx | 44 + components/icons/uncheck-all.tsx | 44 + components/icons/unlock.tsx | 55 + components/icons/upload-2.tsx | 44 + components/icons/user-female.tsx | 44 + components/icons/user-male.tsx | 44 + components/icons/user.tsx | 52 + components/icons/visible.tsx | 44 + components/icons/whatsapp.tsx | 44 + components/icons/youtube-play.tsx | 44 + components/onboarding-guard.tsx | 36 + components/session-provider.tsx | 9 + components/theme-provider.tsx | 11 + components/trade-history.tsx | 245 + components/trade-panel.tsx | 399 + components/trading-chart.tsx | 247 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 213 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 + components/ui/chart.tsx | 353 + components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 + components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 + components/ui/empty.tsx | 104 + components/ui/field.tsx | 244 + components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 169 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 + components/ui/navigation-menu.tsx | 166 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 ++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 + components/ui/user-badges.tsx | 135 + components/ui/verified-badge.tsx | 22 + hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 + lib/auth.ts | 100 + lib/mongodb.ts | 24 + lib/rate-limit.ts | 19 + lib/socket-protocol.ts.unobf.old | 177 + lib/utils.ts | 6 + lib/validations.ts | 56 + lib/vendor-metrics.ts | 84 + next.config.mjs | 14 + package-lock.json | 10726 ++++++++++++++++ package.json | 95 + pnpm-lock.yaml | 5 + postcss.config.mjs | 8 + public/banner.png | Bin 0 -> 328656 bytes public/logo.ico | Bin 0 -> 8148 bytes public/logo.png | Bin 0 -> 25422 bytes public/logo.svg | 4 + public/raydium-ray-logo.png | Bin 0 -> 226317 bytes public/rules.png | Bin 0 -> 222022 bytes public/solana.svg | 28 + public/update.png | Bin 0 -> 202651 bytes scripts/ban-alts.ts | 110 + scripts/database-reset.ts | 77 + scripts/delete-miners-for-coin.ts | 79 + scripts/give-all-sol.ts | 64 + scripts/give-beta-to-all.ts | 36 + scripts/set-role.ts | 80 + scripts/verify.ts | 59 + server/socket-server.ts | 1105 ++ server/tsconfig.json | 13 + styles/globals.css | 125 + tsconfig.json | 41 + types/index.ts | 124 + 248 files changed, 33283 insertions(+) create mode 100644 .gitignore create mode 100644 READ.txt create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/balance/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/coins/[id]/boost/route.ts create mode 100644 app/api/coins/[id]/holders/route.ts create mode 100644 app/api/coins/[id]/route.ts create mode 100644 app/api/coins/[id]/verify/route.ts create mode 100644 app/api/coins/route.ts create mode 100644 app/api/comments/route.ts create mode 100644 app/api/gamble/route.ts create mode 100644 app/api/miner/buy/route.ts create mode 100644 app/api/miner/collect/route.ts create mode 100644 app/api/notice/route.ts create mode 100644 app/api/portfolio/route.ts create mode 100644 app/api/rain/route.ts create mode 100644 app/api/rewards/route.ts create mode 100644 app/api/sol-price/route.ts create mode 100644 app/api/swap/route.ts create mode 100644 app/api/tip/route.ts create mode 100644 app/api/trade/route.ts create mode 100644 app/api/trades/route.ts create mode 100644 app/api/user/onboarding/route.ts create mode 100644 app/api/user/route.ts create mode 100644 app/api/users/[username]/route.ts create mode 100644 app/coin/[id]/coin-client.tsx create mode 100644 app/coin/[id]/page.tsx create mode 100644 app/create/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/discord/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/onboarding/page.tsx create mode 100644 app/page.tsx create mode 100644 app/privacy/page.tsx create mode 100644 app/rewards/page.tsx create mode 100644 app/swap/page.tsx create mode 100644 app/terms/page.tsx create mode 100644 app/u/[username]/page.tsx create mode 100644 components.json create mode 100644 components/coin-card.tsx create mode 100644 components/comments.tsx create mode 100644 components/global-chat.tsx create mode 100644 components/header.tsx create mode 100644 components/holders.tsx create mode 100644 components/icons/OK.tsx create mode 100644 components/icons/about.tsx create mode 100644 components/icons/add-user-male.tsx create mode 100644 components/icons/appointment-reminders.tsx create mode 100644 components/icons/back.tsx create mode 100644 components/icons/binoculars.tsx create mode 100644 components/icons/bookmark-ribbon.tsx create mode 100644 components/icons/bookmark.tsx create mode 100644 components/icons/box.tsx create mode 100644 components/icons/briefcase.tsx create mode 100644 components/icons/calendar.tsx create mode 100644 components/icons/cancel-2.tsx create mode 100644 components/icons/cancel.tsx create mode 100644 components/icons/check-all.tsx create mode 100644 components/icons/checked-2.tsx create mode 100644 components/icons/checked.tsx create mode 100644 components/icons/checkmark.tsx create mode 100644 components/icons/clock.tsx create mode 100644 components/icons/close-window.tsx create mode 100644 components/icons/combo-chart.tsx create mode 100644 components/icons/conference-call.tsx create mode 100644 components/icons/contacts.tsx create mode 100644 components/icons/cursor.tsx create mode 100644 components/icons/delete-sign.tsx create mode 100644 components/icons/delete.tsx create mode 100644 components/icons/document.tsx create mode 100644 components/icons/domain.tsx create mode 100644 components/icons/download.tsx create mode 100644 components/icons/downloads-2.tsx create mode 100644 components/icons/edit.tsx create mode 100644 components/icons/email.tsx create mode 100644 components/icons/empty-trush.tsx create mode 100644 components/icons/exit.tsx create mode 100644 components/icons/expand-arrow.tsx create mode 100644 components/icons/external-link.tsx create mode 100644 components/icons/facebook-new.tsx create mode 100644 components/icons/facebook.tsx create mode 100644 components/icons/file.tsx create mode 100644 components/icons/filled-trash.tsx create mode 100644 components/icons/folder-invoices-19.tsx create mode 100644 components/icons/folder-invoices.tsx create mode 100644 components/icons/for-you.tsx create mode 100644 components/icons/forward.tsx create mode 100644 components/icons/gemini-ai.tsx create mode 100644 components/icons/gmail.tsx create mode 100644 components/icons/google-logo.tsx create mode 100644 components/icons/groups.tsx create mode 100644 components/icons/hand-cursor.tsx create mode 100644 components/icons/home.tsx create mode 100644 components/icons/idea.tsx create mode 100644 components/icons/image-file.tsx create mode 100644 components/icons/index.ts create mode 100644 components/icons/info.tsx create mode 100644 components/icons/instagram-new.tsx create mode 100644 components/icons/key.tsx create mode 100644 components/icons/like.tsx create mode 100644 components/icons/linkedin.tsx create mode 100644 components/icons/lock.tsx create mode 100644 components/icons/mailbox-closed-flag-down.tsx create mode 100644 components/icons/maintenance.tsx create mode 100644 components/icons/marker.tsx create mode 100644 components/icons/menu.tsx create mode 100644 components/icons/music.tsx create mode 100644 components/icons/news.tsx create mode 100644 components/icons/no-synchronize.tsx create mode 100644 components/icons/opened-folder.tsx create mode 100644 components/icons/phone.tsx create mode 100644 components/icons/picture.tsx create mode 100644 components/icons/pinterest.tsx create mode 100644 components/icons/plus-math.tsx create mode 100644 components/icons/plus.tsx create mode 100644 components/icons/puzzle.tsx create mode 100644 components/icons/refresh.tsx create mode 100644 components/icons/restart.tsx create mode 100644 components/icons/save.tsx create mode 100644 components/icons/scroll.tsx create mode 100644 components/icons/search.tsx create mode 100644 components/icons/secured-letter.tsx create mode 100644 components/icons/service.tsx create mode 100644 components/icons/settings.tsx create mode 100644 components/icons/share-2.tsx create mode 100644 components/icons/share-3.tsx create mode 100644 components/icons/share.tsx create mode 100644 components/icons/shutdown.tsx create mode 100644 components/icons/speech-bubble.tsx create mode 100644 components/icons/star.tsx create mode 100644 components/icons/sun.tsx create mode 100644 components/icons/support.tsx create mode 100644 components/icons/synchronize.tsx create mode 100644 components/icons/toolbox.tsx create mode 100644 components/icons/trash.tsx create mode 100644 components/icons/trending-up.tsx create mode 100644 components/icons/twitter.tsx create mode 100644 components/icons/uncheck-all.tsx create mode 100644 components/icons/unlock.tsx create mode 100644 components/icons/upload-2.tsx create mode 100644 components/icons/user-female.tsx create mode 100644 components/icons/user-male.tsx create mode 100644 components/icons/user.tsx create mode 100644 components/icons/visible.tsx create mode 100644 components/icons/whatsapp.tsx create mode 100644 components/icons/youtube-play.tsx create mode 100644 components/onboarding-guard.tsx create mode 100644 components/session-provider.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/trade-history.tsx create mode 100644 components/trade-panel.tsx create mode 100644 components/trading-chart.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 components/ui/user-badges.tsx create mode 100644 components/ui/verified-badge.tsx create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/auth.ts create mode 100644 lib/mongodb.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/socket-protocol.ts.unobf.old create mode 100644 lib/utils.ts create mode 100644 lib/validations.ts create mode 100644 lib/vendor-metrics.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/banner.png create mode 100644 public/logo.ico create mode 100644 public/logo.png create mode 100644 public/logo.svg create mode 100644 public/raydium-ray-logo.png create mode 100644 public/rules.png create mode 100644 public/solana.svg create mode 100644 public/update.png create mode 100644 scripts/ban-alts.ts create mode 100644 scripts/database-reset.ts create mode 100644 scripts/delete-miners-for-coin.ts create mode 100644 scripts/give-all-sol.ts create mode 100644 scripts/give-beta-to-all.ts create mode 100644 scripts/set-role.ts create mode 100644 scripts/verify.ts create mode 100644 server/socket-server.ts create mode 100644 server/tsconfig.json create mode 100644 styles/globals.css create mode 100644 tsconfig.json create mode 100644 types/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/READ.txt b/READ.txt new file mode 100644 index 0000000..91fb7d7 --- /dev/null +++ b/READ.txt @@ -0,0 +1,14 @@ +------------------------ +so, rn mining makes NEW tokens into the supply even though it should cap at 1 billion tokens... this causes absurd crashes if a lot of tokens are claimed! +idk how to approach mining tbf... do we make users put up/sell their tokens and we make it like a queue for mining? i got no clue... + + +uhhh recommendations +------------------------ +boost miner feature + - you can click the button every 1 hour + - it will generate 3k tokens / sec for 10 mins + +and sell your miner for 50% (1.5 sol) + +maybe make miner ratre more rn its uselss \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..8c9b26b --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useState } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { Header } from '@/components/header' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { toast } from 'sonner' +import { Crown, DollarSign, Search } from 'lucide-react' + +export default function AdminPage() { + const { data: session, status } = useSession() + const router = useRouter() + const [targetUser, setTargetUser] = useState('') + const [amount, setAmount] = useState('') + const [action, setAction] = useState('add') + const [loading, setLoading] = useState(false) + const [noticeMessage, setNoticeMessage] = useState('') + const [noticeReason, setNoticeReason] = useState('') + const [noticeLoading, setNoticeLoading] = useState(false) + + if (status === 'loading') return null + + if (!session?.user?.isAdmin) { + router.push('/') + return null + } + + const handleUpdateBalance = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + const res = await fetch('/api/admin/balance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + usernameOrId: targetUser, + amount: parseFloat(amount), + action + }) + }) + + const data = await res.json() + if (res.ok) { + toast.success(`Success! ${data.user} balance updated to ${data.newBalance.toFixed(4)} SOL`) + setAmount('') + } else { + toast.error(data.error || 'Failed') + } + } catch (e) { + toast.error('Error executing admin command') + } finally { + setLoading(false) + } + } + + const handleSetNotice = async (e: React.FormEvent) => { + e.preventDefault() + setNoticeLoading(true) + + try { + const res = await fetch('/api/notice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: noticeMessage, + reason: noticeReason || undefined + }) + }) + + const data = await res.json() + if (res.ok) { + toast.success('Notice set successfully!') + setNoticeMessage('') + setNoticeReason('') + } else { + toast.error(data.error || 'Failed to set notice') + } + } catch (e) { + toast.error('Error setting notice') + } finally { + setNoticeLoading(false) + } + } + + return ( +
+
+
+
+ +

Admin Dashboard

+
+ +
+ +

+ + Manage User Balance +

+
+
+ +
+ + setTargetUser(e.target.value)} + /> +
+
+ +
+
+ + +
+
+ + setAmount(e.target.value)} + /> +
+
+ + +
+
+ + +

Set Site Notice

+
+
+ + setNoticeMessage(e.target.value)} + /> +
+ +
+ + setNoticeReason(e.target.value)} + /> +
+ + +
+
+ + +

Quick Links

+
+

+ Use the scripts in /scripts folder for bulk actions: +

+
    +
  • npx ts-node scripts/set-role.ts <user> admin true
  • +
  • npx ts-node scripts/give-beta-to-all.ts
  • +
  • npx ts-node scripts/database-reset.ts [reason]
  • +
+
+
+
+
+
+ ) +} diff --git a/app/api/admin/balance/route.ts b/app/api/admin/balance/route.ts new file mode 100644 index 0000000..397d572 --- /dev/null +++ b/app/api/admin/balance/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session || !session.user.isAdmin) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { usernameOrId, amount, action } = await request.json() + // action: 'set', 'add', 'subtract' + + if (!usernameOrId || amount === undefined) { + return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + + let query: any = { name: usernameOrId } + if (/^\d{17,19}$/.test(usernameOrId)) { // Discord ID + query = { discordId: usernameOrId } + } else if (ObjectId.isValid(usernameOrId)) { + query = { _id: new ObjectId(usernameOrId) } + } + + const user = await db.collection('users').findOne(query) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + let newBalance = user.balance + const val = parseFloat(amount) + + if (action === 'set') newBalance = val + else if (action === 'add') newBalance += val + else if (action === 'subtract') newBalance -= val + + await db.collection('users').updateOne( + { _id: user._id }, + { $set: { balance: newBalance } } + ) + + return NextResponse.json({ success: true, newBalance, user: user.name }) + } catch (error) { + return NextResponse.json({ error: 'Failed to update balance' }, { status: 500 }) + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..e10628f --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth' +import { authOptions } from '@/lib/auth' + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/app/api/coins/[id]/boost/route.ts b/app/api/coins/[id]/boost/route.ts new file mode 100644 index 0000000..2ce1487 --- /dev/null +++ b/app/api/coins/[id]/boost/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + if ((user.balance || 0) < 1) { + return NextResponse.json({ error: 'Insufficient balance (1 SOL required)' }, { status: 400 }) + } + + const coin = await db.collection('coins').findOne({ _id: new ObjectId(id) }) + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + const now = new Date() + let newBoostedAt = now + if (coin.boosted && new Date(coin.boosted) > now) { + newBoostedAt = new Date(new Date(coin.boosted).getTime() + 24 * 60 * 60 * 1000) + } else { + newBoostedAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) + } + + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { $inc: { balance: -1 } } + ) + await db.collection('coins').updateOne( + { _id: new ObjectId(id) }, + { $set: { boosted: newBoostedAt } } + ) + + return NextResponse.json({ success: true, boosted: newBoostedAt }) + } catch (error) { + console.error('Error boosting coin:', error) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/app/api/coins/[id]/holders/route.ts b/app/api/coins/[id]/holders/route.ts new file mode 100644 index 0000000..ec3bb1f --- /dev/null +++ b/app/api/coins/[id]/holders/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const { db } = await connectToDatabase() + + const coin = await db.collection('coins').findOne({ _id: new ObjectId(id) }) + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + const holders = await db.collection('users').aggregate([ + { $unwind: '$portfolio' }, + { $match: { 'portfolio.coinId': id } }, + { $sort: { 'portfolio.amount': -1 } }, + { $limit: 20 }, + { + $project: { + name: 1, + image: 1, + verified: 1, + isAdmin: 1, + isBetaTester: 1, + isBugHunter: 1, + amount: '$portfolio.amount' + } + } + ]).toArray() + + //? calculate "system" holding (Curve/Raydium) + //? get the actual sum of ALL user tokens to derive the system balance accurately + const stats = await db.collection('users').aggregate([ + { $unwind: '$portfolio' }, + { $match: { 'portfolio.coinId': id } }, + { $group: { _id: null, totalHeld: { $sum: '$portfolio.amount' } } } + ]).toArray() + + const totalUserHeld = stats[0]?.totalHeld || 0 + const curveBalance = coin.supply - totalUserHeld + + // logic for "Raydium" or "Bonding Curve" display + const isGraduated = coin.graduated === true; + + if (curveBalance > 100) { + const curveHolder = { + _id: 'bonding-curve', + name: isGraduated ? 'Raydium Liquidity Pool' : 'Bonding Curve', + image: isGraduated ? 'https://pummmp.fun/raydium-ray-logo.png' : 'https://pummmp.fun/logo.png', + amount: curveBalance, + isSystem: true, + isLocked: isGraduated // Visual indicator that it's "Forever" + } + + holders.push(curveHolder) + holders.sort((a, b) => b.amount - a.amount) + } + + return NextResponse.json(holders.slice(0, 10)) + } catch (error) { + console.error('Error fetching holders:', error) + return NextResponse.json({ error: 'Failed to fetch holders' }, { status: 500 }) + } +} diff --git a/app/api/coins/[id]/route.ts b/app/api/coins/[id]/route.ts new file mode 100644 index 0000000..3f3b763 --- /dev/null +++ b/app/api/coins/[id]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const { db } = await connectToDatabase() + + let coin + + // try to find by id first, then by ticker + if (ObjectId.isValid(id)) { + coin = await db.collection('coins').findOne({ _id: new ObjectId(id) }) + } + + if (!coin) { + coin = await db.collection('coins').findOne({ ticker: id.toUpperCase() }) + } + + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + let creatorVerified = false; + let creatorIsAdmin = false; + let creatorIsBetaTester = false; + let creatorIsBugHunter = false; + + if (coin.creatorId) { + try { + const creator = await db.collection('users').findOne({ _id: new ObjectId(coin.creatorId) }, { projection: { verified: 1, isAdmin: 1, isBetaTester: 1, isBugHunter: 1, name: 1 } }); + if (creator) { + if (creator.verified) creatorVerified = true; + if (creator.isAdmin) creatorIsAdmin = true; + if (creator.isBetaTester) creatorIsBetaTester = true; + if (creator.isBugHunter) creatorIsBugHunter = true; + + // ALWAYS use the up-to-date name from the users collection + if (creator.name) { + coin.creatorName = creator.name; + } + } + } catch (e) { + // NOPE! + } + } + + return NextResponse.json({ + ...coin, + creatorVerified, + creatorIsAdmin, + creatorIsBetaTester, + creatorIsBugHunter + }) + } catch (error) { + console.error('Error fetching coin:', error) + return NextResponse.json({ error: 'Failed to fetch coin' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const coin = await db.collection('coins').findOne({ _id: new ObjectId(id) }) + + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + if (coin.creatorId !== session.user.id && !session.user.isAdmin) { + return NextResponse.json({ error: 'Only the creator or admin can delete this coin' }, { status: 403 }) + } + + await db.collection('coins').deleteOne({ _id: new ObjectId(id) }) + + // Remove from all portfolios to prevent portfolio page crashes + await db.collection('users').updateMany( + { 'portfolio.coinId': id }, + { $pull: { portfolio: { coinId: id } } as any } + ) + + await db.collection('trades').deleteMany({ coinId: id }) // ? clean up trade history as well + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting coin:', error) + return NextResponse.json({ error: 'Failed to delete coin' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/coins/[id]/verify/route.ts b/app/api/coins/[id]/verify/route.ts new file mode 100644 index 0000000..20cee31 --- /dev/null +++ b/app/api/coins/[id]/verify/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase(); + + // ! check if the user is actually verified in the DB (never trust client!) + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user || !user.verified) { + return NextResponse.json({ error: 'Only verified users can verify coins... nga...' }, { status: 403 }) + } + + const coinId = id + const coin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) }) + + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + const newStatus = !coin.verified //? toggle + await db.collection('coins').updateOne( + { _id: new ObjectId(coinId) }, + { $set: { verified: newStatus } } + ) + + return NextResponse.json({ verified: newStatus }) + + } catch (error) { + console.error('Error verifying coin:', error) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/app/api/coins/route.ts b/app/api/coins/route.ts new file mode 100644 index 0000000..a897039 --- /dev/null +++ b/app/api/coins/route.ts @@ -0,0 +1,186 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { coinSchema } from '@/lib/validations' +import { checkRateLimit } from '@/lib/rate-limit' + +export async function GET() { + try { + const { db } = await connectToDatabase() + const coins = await db + .collection('coins') + .find({}) + .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 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const body = await request.json() + + const validationResult = coinSchema.safeParse(body) + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map(e => e.message).join(', ') + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } + + const { name, ticker, description, website, twitter, telegram, initialBuy, useUserImage, verified, boosted } = validationResult.data + const cleanName = name + const cleanTicker = ticker + const cleanDescription = description + + if (!cleanName || !cleanTicker) { + return NextResponse.json({ error: 'Name and ticker are required' }, { status: 400 }) + } + + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + //! verify via DB for security cuz client can be manipulated + let isVerified = false + if (verified && user.verified) { + isVerified = true + } + + //? costs + const creationCost = 0.02; + const boostCost = boosted ? 1.0 : 0; + const initialBuyAmount = typeof initialBuy === 'number' && initialBuy > 0 ? initialBuy : 0; + + const totalCost = creationCost + boostCost + initialBuyAmount; + + if ((user.balance || 0) < totalCost) { + return NextResponse.json({ error: `Insufficient balance (${totalCost.toFixed(3)} SOL required)` }, { status: 400 }) + } + + const existingCoin = await db.collection('coins').findOne({ ticker: cleanTicker }) + if (existingCoin) { + return NextResponse.json({ error: 'Ticker already exists' }, { status: 400 }) + } + + if (checkRateLimit(session.user.id, 'coin_creation', 1, 60000 * 5)) { + return NextResponse.json({ error: 'Rate limit exceeded. You can only create 1 coin every 5 minutes.' }, { status: 429 }) + } + + let virtualSolReserves = 30; + const INITIAL_SUPPLY = 1000000000; + let virtualTokenReserves = 1073000000; + let initialLiquidity = 0; + let initialPrice = virtualSolReserves / virtualTokenReserves; + let initialVolume = 0; + + let boughtTokens = 0; + + // ! process IB (Snipe) + if (initialBuyAmount > 0) { + // chat wahat does calc stand for + // i hate my life + const k = virtualSolReserves * virtualTokenReserves; + const newVirtualSolReserves = virtualSolReserves + initialBuyAmount; + const newVirtualTokenReserves = k / newVirtualSolReserves; + + boughtTokens = virtualTokenReserves - newVirtualTokenReserves; + + // ? update states + virtualSolReserves = newVirtualSolReserves; + virtualTokenReserves = newVirtualTokenReserves; + initialPrice = virtualSolReserves / virtualTokenReserves; + initialLiquidity = initialBuyAmount; // ! real liquidity + initialVolume = initialBuyAmount; + } + + const now = new Date() + + const priceHistory = [{ + timestamp: now, + price: initialPrice, + volume: initialVolume, + }] + + const coinImage = (useUserImage && session.user.image) + ? session.user.image + : `https://api.dicebear.com/7.x/identicon/svg?seed=${cleanTicker}`; + + const coin = { + name: cleanName, + ticker: cleanTicker, + description: cleanDescription, + image: coinImage, + website: website || '', + twitter: twitter || '', + telegram: telegram || '', + creatorId: session.user.id, + creatorName: user.name || 'Anonymous', // Use DB name, not session name + createdAt: now, + verified: isVerified, + boosted: boosted ? new Date(Date.now() + 24 * 60 * 60 * 1000) : null, + marketCap: initialPrice * INITIAL_SUPPLY, + price: initialPrice, + priceHistory, + volume24h: initialVolume, + holders: boughtTokens > 0 ? 1 : 0, + supply: INITIAL_SUPPLY, + liquidity: initialLiquidity, + virtualSolReserves, + virtualTokenReserves, + } + + const result = await db.collection('coins').insertOne(coin) + + // deduct total cost + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { $inc: { balance: -totalCost } } + ) + + // give tokens if bought + if (boughtTokens > 0) { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $push: { + portfolio: { + coinId: result.insertedId.toString(), + amount: boughtTokens, + avgBuyPrice: initialBuyAmount / boughtTokens, + }, + } as any, + } + ) + + //? record trade + await db.collection('trades').insertOne({ + userId: session.user.id, + coinId: result.insertedId.toString(), + type: 'buy', + amount: boughtTokens, + price: initialPrice, + total: initialBuyAmount, + timestamp: now, + }) + } + + return NextResponse.json({ ...coin, _id: result.insertedId }) + } catch (error) { + console.error('Error creating coin:', error) + return NextResponse.json({ error: 'Failed to create coin' }, { status: 500 }) + } +} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..b6bdf61 --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { commentSchema } from '@/lib/validations' +import { checkRateLimit } from '@/lib/rate-limit' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const coinId = searchParams.get('coinId') + + if (!coinId) { + return NextResponse.json({ error: 'Coin ID required' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + const comments = await db.collection('comments') + .find({ coinId }) + .sort({ createdAt: -1 }) + .limit(50) + .toArray() + + return NextResponse.json(comments) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch comments' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + //! rate limiting: 5 comments per minute per user + if (checkRateLimit(session.user.id, 'comment', 5, 60000)) { + return NextResponse.json({ error: 'Rate limit exceeded. Please wait.' }, { status: 429 }) + } + + const body = await request.json() + const validationResult = commentSchema.safeParse(body) + + if (!validationResult.success) { + return NextResponse.json({ error: validationResult.error.errors[0].message }, { status: 400 }) + } + + const { coinId, text } = validationResult.data + + if ((text.match(/\n/g) || []).length > 2) { + return NextResponse.json({ error: 'Comment is too tall (max 2 new lines)' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + + + //? owns % of coin? (holder) + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + const isHolder = user?.portfolio?.some((p: any) => p.coinId === coinId && p.amount > 0) + + const comment = { + coinId, + userId: session.user.id, + userName: session.user.name || 'Anonymous', + userImage: session.user.image, + userVerified: !!user?.verified, + userIsAdmin: !!user?.isAdmin, + userIsBetaTester: !!user?.isBetaTester, + userIsBugHunter: !!user?.isBugHunter, + text, + isHolder, + createdAt: new Date(), + } + + await db.collection('comments').insertOne(comment) + + return NextResponse.json(comment) + } catch (error) { + return NextResponse.json({ error: 'Failed to post comment' }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const commentId = searchParams.get('id') + + if (!commentId) { + return NextResponse.json({ error: 'Comment ID required' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + const comment = await db.collection('comments').findOne({ _id: new ObjectId(commentId) }) + if (!comment) { + return NextResponse.json({ error: 'Comment not found' }, { status: 404 }) + } + const coin = await db.collection('coins').findOne({ _id: new ObjectId(comment.coinId) }) + + const isOwner = comment.userId === session.user.id + const isCoinCreator = coin && coin.creatorId === session.user.id + const isAdmin = session.user.isAdmin + + if (!isOwner && !isCoinCreator && !isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + await db.collection('comments').deleteOne({ _id: new ObjectId(commentId) }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Delete comment error:', error) + return NextResponse.json({ error: 'Failed to delete comment' }, { status: 500 }) + } +} diff --git a/app/api/gamble/route.ts b/app/api/gamble/route.ts new file mode 100644 index 0000000..45f3347 --- /dev/null +++ b/app/api/gamble/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import crypto from 'crypto' +import { checkRateLimit } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (checkRateLimit(session.user.id, 'gamble', 20, 60000)) { + return NextResponse.json({ error: 'Rate limit exceeded. Please slow down.' }, { status: 429 }) + } + + const { amount, chance } = await request.json() + + const betAmount = parseFloat(amount) + const winChance = parseFloat(chance) + + // validations + if (isNaN(betAmount) || betAmount <= 0) { + return NextResponse.json({ error: 'Invalid amount' }, { status: 400 }) + } + if (isNaN(winChance) || winChance < 1 || winChance > 95) { + return NextResponse.json({ error: 'Chance must be between 1% and 95%' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + if ((user.balance || 0) < betAmount) { + return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + } + + // GAMBLE LOGIC + // 100-67 = 33% house edge (heh) + const multiplier = 67 / winChance + + const buffer = crypto.randomBytes(4); + const randomInt = buffer.readUInt32BE(0); + const roll = (randomInt / 0xffffffff) * 100; + + const won = roll < winChance + + const payout = won ? (betAmount * multiplier) : 0 + // Win: + (Payout - Bet) = Bet * (Multiplier - 1) + + const netChange = won ? (payout - betAmount) : -betAmount + const finalBalance = (user.balance || 0) + netChange + + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { $set: { balance: finalBalance } } + ) + + return NextResponse.json({ + success: true, + won, + roll, + payout, + netChange, + newBalance: finalBalance, + multiplier + }) + + } catch (error) { + console.error('Error gambling:', error) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/app/api/miner/buy/route.ts b/app/api/miner/buy/route.ts new file mode 100644 index 0000000..bab6af1 --- /dev/null +++ b/app/api/miner/buy/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST(request: NextRequest) { + return NextResponse.json({ error: 'Miners are temporarily disabled by administrators' }, { status: 503 }) + + // try { + // const session = await getServerSession(authOptions) + // if (!session?.user?.id) { + // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // } + + // const { coinId } = await request.json() + // if (!coinId) { + // return NextResponse.json({ error: 'Coin ID required' }, { status: 400 }) + // } + + // const { db } = await connectToDatabase() + + // // Check if user already has a miner for this coin (allow multiple) + // // const existingMiner = await db.collection('miners').findOne({ + // // userId: session.user.id, + // // coinId: coinId, + // // isActive: true + // // }) + + // // if (existingMiner) { + // // await client.close() + // // return NextResponse.json({ error: 'You already have a miner for this coin' }, { status: 400 }) + // // } + + // // Check user's balance + // const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + // if (!user) { + // return NextResponse.json({ error: 'User not found' }, { status: 404 }) + // } + + // // Check trade ban (prevent buying miners) + // if (user.tradeBannedUntil) { + // const banExpiry = new Date(user.tradeBannedUntil); + // if (banExpiry > new Date()) { + // return NextResponse.json({ error: 'You are banned from trading/mining actions' }, { status: 403 }) + // } else { + // try { await db.collection('users').updateOne({ _id: user._id }, { $unset: { tradeBannedUntil: 1 } }) } catch (e) {} + // } + // } + + // if (user.balance < 3) { + // return NextResponse.json({ error: 'Insufficient balance. Need 3 SOL to buy a miner' }, { status: 400 }) + // } + + // // Deduct 3 SOL from balance + // await db.collection('users').updateOne( + // { _id: new ObjectId(session.user.id) }, + // { $inc: { balance: -3 } } + // ) + + // // Create miner + // const miner = { + // userId: session.user.id, + // coinId: coinId, + // purchasedAt: new Date(), + // lastCollectedAt: new Date(), + // isActive: true + // } + + // await db.collection('miners').insertOne(miner) + + // return NextResponse.json({ + // success: true, + // message: 'Miner purchased successfully! Started mining at 1000 tokens/second.' + // }) + + // } catch (error) { + // console.error('Error buying miner:', error) + // return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + // } +} \ No newline at end of file diff --git a/app/api/miner/collect/route.ts b/app/api/miner/collect/route.ts new file mode 100644 index 0000000..1b76b38 --- /dev/null +++ b/app/api/miner/collect/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST(request: NextRequest) { + return NextResponse.json({ error: 'Miners are temporarily disabled by administrators' }, { status: 503 }); + + // try { + // const session = await getServerSession(authOptions) + // if (!session?.user?.id) { + // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // } + + // const { coinId } = await request.json() + // if (!coinId) { + // return NextResponse.json({ error: 'Coin ID required' }, { status: 400 }) + // } + + // const { db } = await connectToDatabase() + + // // Find all active miners for this user and coin + // const miners = await db.collection('miners').find({ + // userId: session.user.id, + // coinId: coinId, + // isActive: true + // }).toArray() + + // // Check trade ban (prevent miner collects/actions) + // const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + // if (!user) { + // return NextResponse.json({ error: 'User not found' }, { status: 404 }) + // } + + // if (user.tradeBannedUntil) { + // const banExpiry = new Date(user.tradeBannedUntil); + // if (banExpiry > new Date()) { + // return NextResponse.json({ error: 'You are banned from trading/mining actions' }, { status: 403 }) + // } else { + // try { await db.collection('users').updateOne({ _id: user._id }, { $unset: { tradeBannedUntil: 1 } }) } catch (e) {} + // } + // } + + // if (miners.length === 0) { + // return NextResponse.json({ error: 'No active miners found for this coin' }, { status: 400 }) + // } + + // // Calculate total mined tokens from all miners (100 tokens per second per miner) + // const now = new Date() + // let totalMinedTokens = 0 + + // for (const miner of miners) { + // const timeDiffMs = now.getTime() - miner.lastCollectedAt.getTime() + // const timeDiffSeconds = Math.floor(timeDiffMs / 1000) + // totalMinedTokens += timeDiffSeconds * 1000 + // } + + // const minedTokens = totalMinedTokens + + // if (minedTokens <= 0) { + // return NextResponse.json({ error: 'No tokens to collect yet' }, { status: 400 }) + // } + + // // Get coin info for trade creation + // const coin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) }) + // if (!coin) { + // return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + // } + + // // Update user's portfolio - add mined tokens + // const existingHolding = user.portfolio?.find((p: any) => p.coinId === coinId) + // if (existingHolding) { + // await db.collection('users').updateOne( + // { _id: new ObjectId(session.user.id), 'portfolio.coinId': coinId }, + // { $inc: { 'portfolio.$.amount': minedTokens } } as any + // ) + // } else { + // // If user doesn't have portfolio array, create it with $set + // if (!user.portfolio || user.portfolio.length === 0) { + // await db.collection('users').updateOne( + // { _id: new ObjectId(session.user.id) }, + // { + // $set: { + // portfolio: [{ + // coinId: coinId, + // amount: minedTokens, + // avgBuyPrice: coin.price + // }] + // } + // } as any + // ) + // } else { + // // User has portfolio array, push to it + // await db.collection('users').updateOne( + // { _id: new ObjectId(session.user.id) }, + // { + // $push: { + // portfolio: { + // coinId: coinId, + // amount: minedTokens, + // avgBuyPrice: coin.price + // } + // } + // } as any + // ) + // } + // } + + // // Update all miners' last collected time + // await db.collection('miners').updateMany( + // { userId: session.user.id, coinId: coinId, isActive: true }, + // { $set: { lastCollectedAt: now } } + // ) + + // // Create trade record + // const trade = { + // userId: session.user.id, + // username: user.name, + // userImage: user.image, + // userVerified: user.verified || false, + // userIsAdmin: user.isAdmin || false, + // userIsBetaTester: user.isBetaTester || false, + // userIsBugHunter: user.isBugHunter || false, + // coinId: coinId, + // type: 'mined', + // amount: minedTokens, + // price: coin.price, + // total: minedTokens * coin.price, + // timestamp: now + // } + + // await db.collection('trades').insertOne(trade) + + // // Update coin stats + // await db.collection('coins').updateOne( + // { _id: new ObjectId(coinId) }, + // { + // $inc: { + // holders: existingHolding ? 0 : 1, + // volume24h: minedTokens * coin.price, + // supply: minedTokens + // } + // } + // ) + + // return NextResponse.json({ + // success: true, + // minedTokens: minedTokens, + // minerCount: miners.length, + // message: `Collected ${minedTokens.toLocaleString()} ${coin.ticker} tokens from ${miners.length} miner${miners.length > 1 ? 's' : ''}!` + // }) + + // } catch (error) { + // console.error('Error collecting miner:', error) + // return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + // } +} \ No newline at end of file diff --git a/app/api/notice/route.ts b/app/api/notice/route.ts new file mode 100644 index 0000000..693a66d --- /dev/null +++ b/app/api/notice/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'bson' +import { authOptions } from '@/lib/auth' +import { getServerSession } from 'next-auth' + +export async function GET() { + try { + const { db } = await connectToDatabase() + const notice = await db.collection('notices').findOne( + {}, + { sort: { createdAt: -1 } } + ) + + if (!notice) { + return NextResponse.json({ notice: null }) + } + + return NextResponse.json({ notice }) + } catch (error) { + console.error('Error fetching notice:', error) + return NextResponse.json({ error: 'Failed to fetch notice' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + //! this is probablyt vulnerable but whooo cares + try { + const { db } = await connectToDatabase(); + const { message, reason } = await request.json(); + const session = await getServerSession(authOptions); + + // check if admin + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await db.collection('users').findOne({ _id: new ObjectId(session?.user.id) }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + if (!user.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const notice = { + message, + reason, + createdAt: new Date(), + updatedAt: new Date() + }; + + await db.collection('notices').insertOne(notice); + + return NextResponse.json({ success: true, notice }); + } catch (error) { + console.error('Error setting notice:', error); + return NextResponse.json({ error: 'Failed to set notice' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/portfolio/route.ts b/app/api/portfolio/route.ts new file mode 100644 index 0000000..e99279e --- /dev/null +++ b/app/api/portfolio/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function GET() { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const portfolioWithCoins = await Promise.all( + (user.portfolio || []).map(async (item: any) => { + const coin = await db.collection('coins').findOne({ _id: new ObjectId(item.coinId) }) + if (!coin) return null + + const currentValue = item.amount * coin.price + const costBasis = item.amount * item.avgBuyPrice + const pnl = currentValue - costBasis + const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0 + + return { + ...item, + coin: { + _id: coin._id.toString(), + name: coin.name, + ticker: coin.ticker, + image: coin.image, + price: coin.price, + priceHistory: coin.priceHistory?.slice(-24) || [], + }, + currentValue, + pnl, + pnlPercent, + } + }) + ) + + const validPortfolio = portfolioWithCoins.filter(Boolean) + const totalPortfolioValue = validPortfolio.reduce((acc, item) => acc + item.currentValue, 0) + const totalPnL = validPortfolio.reduce((acc, item) => acc + item.pnl, 0) + + return NextResponse.json({ + portfolio: validPortfolio, + totalPortfolioValue, + totalPnL, + cashBalance: user.balance, + totalValue: user.balance + totalPortfolioValue, + }) + } catch (error) { + console.error('Error fetching portfolio:', error) + return NextResponse.json({ error: 'Failed to fetch portfolio' }, { status: 500 }) + } +} diff --git a/app/api/rain/route.ts b/app/api/rain/route.ts new file mode 100644 index 0000000..fbbe0eb --- /dev/null +++ b/app/api/rain/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { checkRateLimit } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (checkRateLimit(session.user.id, 'rain', 1, 60000 * 2)) { // 1 rain every 2 minutes + return NextResponse.json({ error: 'Rate limit exceeded. Please wait before starting another rain.' }, { status: 429 }) + } + + const { db } = await connectToDatabase() + const body = await request.json() + const { amount } = body + const numAmount = parseFloat(amount) + + if (isNaN(numAmount) || numAmount <= 0) { + return NextResponse.json({ error: 'Invalid amount' }, { status: 400 }) + } + + // Check for existing active rain + const existingRain = await db.collection('rains').findOne({ active: true }) + if (existingRain) { + return NextResponse.json({ error: 'A rain event is already active!' }, { status: 409 }) + } + + const userToCheck = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!userToCheck || userToCheck.balance < numAmount) { + return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + } + + // Deduct balance + await db.collection('users').updateOne( + { _id: userToCheck._id }, + { $inc: { balance: -numAmount } } + ) + + // Create Rain + // ! DEV MODE: 2 minutes instead of 10 + const rain = { + amount: numAmount, + hostId: userToCheck._id.toString(), + hostName: userToCheck.name, + createdAt: new Date(), + endsAt: new Date(Date.now() + 2 * 60 * 1000), + participants: [], + active: true + } + + await db.collection('rains').insertOne(rain) + + return NextResponse.json({ success: true, rain }) + } catch (error: any) { + console.error('Rain create error:', error) + return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + + // Find active rain + const activeRain = await db.collection('rains').findOne({ active: true }) + if (!activeRain) { + return NextResponse.json({ error: 'No active rain to join' }, { status: 404 }) + } + + // Check if already joined + if (activeRain.participants.includes(session.user.id)) { + return NextResponse.json({ error: 'Already joined' }, { status: 400 }) + } + + // Add to participants + await db.collection('rains').updateOne( + { _id: activeRain._id }, + { $addToSet: { participants: session.user.id } } + ) + + return NextResponse.json({ success: true }) + } catch (error: any) { + console.error('Rain join error:', error) + return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + try { + const { db } = await connectToDatabase() + const activeRain = await db.collection('rains').findOne({ active: true }) + + if (activeRain && activeRain.participants && activeRain.participants.length > 0) { + const participantIds = activeRain.participants.map((id: string) => new ObjectId(id)) + const users = await db.collection('users').find({ _id: { $in: participantIds } }, { projection: { name: 1, image: 1 } }).toArray() + activeRain.participants = users.map(user => ({ + id: user._id.toString(), + name: user.name, + image: user.image + })) + } + + return NextResponse.json({ rain: activeRain }) + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/rewards/route.ts b/app/api/rewards/route.ts new file mode 100644 index 0000000..8dafcf4 --- /dev/null +++ b/app/api/rewards/route.ts @@ -0,0 +1,104 @@ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Check cooling period (24 hours) + const lastClaim = user.lastDailyRewardClaim ? new Date(user.lastDailyRewardClaim) : null + const now = new Date() + + if (lastClaim) { + const diffTime = Math.abs(now.getTime() - lastClaim.getTime()) + const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)) + + if (diffTime < 24 * 60 * 60 * 1000) { + const remainingMs = (24 * 60 * 60 * 1000) - diffTime; + return NextResponse.json({ + error: 'Daily reward already claimed', + nextClaim: new Date(now.getTime() + remainingMs) + }, { status: 400 }) + } + } + + // Fetch SOL price to calculate $100 equivalent + let solPrice = 120; // Default fallback + try { + const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd') + if (response.ok) { + const data = await response.json() + solPrice = data.solana.usd + } + } catch (e) { + console.error("Failed to fetch SOL price", e) + } + + const rewardAmountSol = 100 / solPrice + + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $inc: { balance: rewardAmountSol }, + $set: { lastDailyRewardClaim: now } + } + ) + + return NextResponse.json({ + success: true, + amount: rewardAmountSol, + newBalance: user.balance + rewardAmountSol + }) + + } catch (error) { + console.error('Reward claim error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + // Get status + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user) return NextResponse.json({ error: 'User not found'}, { status: 404 }) + + const lastClaim = user.lastDailyRewardClaim ? new Date(user.lastDailyRewardClaim) : null + let canClaim = true + let nextClaimTime = null + + if (lastClaim) { + const now = new Date() + const diff = now.getTime() - lastClaim.getTime() + if (diff < 24 * 60 * 60 * 1000) { + canClaim = false + nextClaimTime = new Date(lastClaim.getTime() + 24 * 60 * 60 * 1000) + } + } + + return NextResponse.json({ canClaim, nextClaimTime }) + + } catch (error) { + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/sol-price/route.ts b/app/api/sol-price/route.ts new file mode 100644 index 0000000..a4f610d --- /dev/null +++ b/app/api/sol-price/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + try { + const response = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd&include_24hr_change=true', + { next: { revalidate: 60 } } //? cache for 60 seconds + ) + + if (!response.ok) { + throw new Error('Failed to fetch SOL price') + } + + const data = await response.json() + + return NextResponse.json({ + price: data.solana.usd, + change24h: data.solana.usd_24h_change, + lastUpdated: new Date(), + }) + } catch (error) { + console.error('Error fetching SOL price:', error) + //! fallback + return NextResponse.json({ + price: 120, + change24h: 0, + lastUpdated: new Date(), + }) + } +} diff --git a/app/api/swap/route.ts b/app/api/swap/route.ts new file mode 100644 index 0000000..6aa0f0d --- /dev/null +++ b/app/api/swap/route.ts @@ -0,0 +1,286 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { checkRateLimit } from '@/lib/rate-limit' + +const DISCORD_WEBHOOK_URL = ''; +const SWAP_ROLE_ID = '1465848761862590495'; +async function sendDiscordSwapAlert(fromCoin: any, toCoin: any, user: any, swapData: any) { + try { + const embed = { + title: `Massive Swap Alert!!! <:trade:1465843050604396738>`, + description: `**${user.name}** swapped **${swapData.intermediateSol.toFixed(4)} SOL**!`, + color: 0x0099ff, // Blue for swaps + fields: [ + { + name: 'From Coin', + value: `[$${fromCoin.ticker}](https://pummmp.fun/coin/${fromCoin._id}) (${fromCoin.name})`, + inline: true + }, + { + name: 'To Coin', + value: `[$${toCoin.ticker}](https://pummmp.fun/coin/${toCoin._id}) (${toCoin.name})`, + inline: true + }, + { + name: 'Trader <:trade:1465843050604396738>', + value: user.verified ? `${user.name} <:verified:1465841044275859722>` : user.name, + inline: true + }, + { + name: 'Sold Amount <:redcandle:1465843012222324767>', + value: `${swapData.sold.toLocaleString()} $${fromCoin.ticker}`, + inline: true + }, + { + name: 'Received Amount <:greencandle:1465842982556139590>', + value: `${swapData.received.toLocaleString()} $${toCoin.ticker}`, + inline: true + }, + ], + timestamp: new Date().toISOString(), + footer: { + text: 'pummmp.fun', + icon_url: 'https://pummmp.fun/logo.ico' + } + } + + await fetch(DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: `<@&${SWAP_ROLE_ID}>`, + embeds: [embed] + }) + }) + } catch (error) { + console.error('Failed to send Discord swap webhook:', error) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (checkRateLimit(session.user.id, 'swap', 10, 60000)) { + return NextResponse.json({ error: 'Rate limit exceeded. Please slow down.' }, { status: 429 }) + } + + const body = await request.json() + const { fromCoinId, toCoinId, amount } = body + + if (!fromCoinId || !toCoinId || !amount || amount <= 0) { + return NextResponse.json({ error: 'Invalid swap parameters' }, { status: 400 }) + } + + if (fromCoinId === toCoinId) { + return NextResponse.json({ error: 'Cannot swap same coin' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + + // Fetch resources + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + + // Check trade ban + if (user.tradeBannedUntil) { + const banExpiry = new Date(user.tradeBannedUntil); + const nowCheck = new Date(); + if (banExpiry > nowCheck) { + return NextResponse.json({ error: 'You are banned from swapping/trading' }, { status: 403 }) + } else { + try { await db.collection('users').updateOne({ _id: user._id }, { $unset: { tradeBannedUntil: 1 } }) } catch (e) {} + } + } + + const fromCoin = await db.collection('coins').findOne({ _id: new ObjectId(fromCoinId) }) + if (!fromCoin) return NextResponse.json({ error: 'Source coin not found' }, { status: 404 }) + + const toCoin = await db.collection('coins').findOne({ _id: new ObjectId(toCoinId) }) + if (!toCoin) return NextResponse.json({ error: 'Target coin not found' }, { status: 404 }) + + // Verify Balance + const fromPosition = user.portfolio?.find((p: any) => p.coinId === fromCoinId) + if (!fromPosition || fromPosition.amount < amount) { + return NextResponse.json({ error: 'Insufficient token balance' }, { status: 400 }) + } + + // --- STEP 1: SELL 'fromCoin' --- + const fromVSol = fromCoin.virtualSolReserves || 30; + const fromVTokens = fromCoin.virtualTokenReserves || 1073000000; + const fromK = fromVSol * fromVTokens; + + const newFromVTokens = fromVTokens + amount; + const newFromVSol = fromK / newFromVTokens; + const solProceeds = fromVSol - newFromVSol; + + if (solProceeds <= 0) { + return NextResponse.json({ error: 'Slippage too high (Zero value)' }, { status: 400 }) + } + + const newFromPrice = newFromVSol / newFromVTokens; + + // --- STEP 2: BUY 'toCoin' --- + const toVSol = toCoin.virtualSolReserves || 30; + const toVTokens = toCoin.virtualTokenReserves || 1073000000; + const toK = toVSol * toVTokens; + + // Buying with `solProceeds` + const newToVSol = toVSol + solProceeds; + const newToVTokens = toK / newToVSol; + const tokensReceived = toVTokens - newToVTokens; + + if (tokensReceived <= 0) { + return NextResponse.json({ error: 'Output amount too low' }, { status: 400 }) + } + + // Check limit + if (tokensReceived >= toVTokens * 0.9) { + return NextResponse.json({ error: 'Price impact too high' }, { status: 400 }) + } + + const newToPrice = newToVSol / newToVTokens; + + // --- EXECUTE UPDATES --- + const now = new Date(); + + // 1. Update FromCoin + await db.collection('coins').updateOne( + { _id: new ObjectId(fromCoinId) }, + { + $set: { + price: newFromPrice, + marketCap: newFromPrice * fromCoin.supply, + virtualSolReserves: newFromVSol, + virtualTokenReserves: newFromVTokens, + }, + $inc: { + volume24h: solProceeds, + liquidity: -solProceeds, // Remove liquidity from FromCoin + }, + $push: { + priceHistory: { timestamp: now, price: newFromPrice, volume: solProceeds } + } as any + } + ); + + // 2. Update ToCoin + await db.collection('coins').updateOne( + { _id: new ObjectId(toCoinId) }, + { + $set: { + price: newToPrice, + marketCap: newToPrice * toCoin.supply, + virtualSolReserves: newToVSol, + virtualTokenReserves: newToVTokens, + }, + $inc: { + volume24h: solProceeds, + liquidity: solProceeds, // Add liquidity to ToCoin + }, + $push: { + priceHistory: { timestamp: now, price: newToPrice, volume: solProceeds } + } as any + } + ); + + // 3. Update User Portfolio + // Decrement FromCoin + const remainingFrom = fromPosition.amount - amount; + if (remainingFrom > 0) { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id), 'portfolio.coinId': fromCoinId }, + { $set: { 'portfolio.$.amount': remainingFrom } } + ); + } else { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { $pull: { portfolio: { coinId: fromCoinId } } } as any + ); + } + + // Increment ToCoin + const existingToPos = user.portfolio?.find((p: any) => p.coinId === toCoinId); + if (existingToPos) { + const newTotal = existingToPos.amount + tokensReceived; + // recalculate avg buy price ? complicated for swap. + // Logic: (oldAmount * oldAvg + solProceeds) / newTotal + const newAvg = (existingToPos.amount * existingToPos.avgBuyPrice + solProceeds) / newTotal; + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id), 'portfolio.coinId': toCoinId }, + { + $inc: { 'portfolio.$.amount': tokensReceived }, + $set: { 'portfolio.$.avgBuyPrice': newAvg } + } + ); + } else { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $push: { + portfolio: { + coinId: toCoinId, + amount: tokensReceived, + avgBuyPrice: solProceeds / tokensReceived + } + } as any + } + ); + } + + // 4. Record Trades (Sell then Buy) + await db.collection('trades').insertMany([ + { + userId: session.user.id, + coinId: fromCoinId, + type: 'sell', + amount: amount, + price: newFromPrice, + total: solProceeds, + timestamp: now, + isSwap: true + }, + { + userId: session.user.id, + coinId: toCoinId, + type: 'buy', + amount: tokensReceived, + price: newToPrice, + total: solProceeds, + timestamp: now, + isSwap: true + } + ]); + + // Send Discord alert for large swaps (> 6 SOL) + if (solProceeds >= 6) { + await sendDiscordSwapAlert(fromCoin, toCoin, user, { + sold: amount, + received: tokensReceived, + intermediateSol: solProceeds + }) + } + + return NextResponse.json({ + success: true, + swapped: { + sold: amount, + received: tokensReceived, + intermediateSol: solProceeds + } + }); + + } catch (err: any) { + console.error('Swap error:', err); + return NextResponse.json({ error: 'Swap failed' }, { status: 500 }) + } +} diff --git a/app/api/tip/route.ts b/app/api/tip/route.ts new file mode 100644 index 0000000..0300ccf --- /dev/null +++ b/app/api/tip/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { recipientUsername, amount } = await request.json() + + const tipAmount = parseFloat(amount); + if (isNaN(tipAmount) || tipAmount <= 0) { + return NextResponse.json({ error: 'Invalid amount' }, { status: 400 }) + } + + // you cant tip urself brotato i msorry + if (session.user.name === recipientUsername) { + return NextResponse.json({ error: 'You cannot tip yourself' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + + const sender = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!sender) { + return NextResponse.json({ error: 'Sender not found' }, { status: 404 }) + } + + if ((sender.balance || 0) < tipAmount) { + return NextResponse.json({ error: 'Insufficient funds' }, { status: 400 }) + } + + // Check if sender is tip banned + if (sender.tipBannedUntil) { + const banExpiry = new Date(sender.tipBannedUntil); + const nowCheck = new Date(); + if (banExpiry > nowCheck) { + const isPermanent = (typeof banExpiry.getFullYear === 'function' && banExpiry.getFullYear() === 9999); + const remaining = isPermanent ? 'permanently' : `${Math.ceil((banExpiry.getTime() - Date.now()) / (1000 * 60))} minutes`; + return NextResponse.json({ error: `You are tip banned ${isPermanent ? 'permanently' : `for ${remaining} more minutes`}.` }, { status: 403 }) + } else { + // Ban expired, remove it + try { + await db.collection('users').updateOne( + { _id: sender._id }, + { $unset: { tipBannedUntil: 1 } } + ) + } catch (e) {} + } + } + + //? only 1 tip/30min + const now = new Date(); + if (sender.lastTipAt) { + const lastTip = new Date(sender.lastTipAt); + const diffMs = now.getTime() - lastTip.getTime(); + const diffMins = diffMs / 60000; + + if (diffMins < 30) { + const minutesLeft = Math.ceil(30 - diffMins); + return NextResponse.json({ error: `You can tip again in ${minutesLeft} minutes` }, { status: 429 }) + } + } + + //? get recipient + //! note: assuming username is unique enough or we trust the passed username + //! ideally we should tip by id, but the chat command uses username and im lazy asf + const recipient = await db.collection('users').findOne({ name: recipientUsername }) + if (!recipient) { + return NextResponse.json({ error: `User '${recipientUsername}' not found` }, { status: 404 }) + } + + // Check if recipient is tip banned + if (recipient.tipBannedUntil) { + const banExpiry = new Date(recipient.tipBannedUntil); + const nowCheck = new Date(); + if (banExpiry > nowCheck) { + return NextResponse.json({ error: 'Cannot tip this user - they are tip banned.' }, { status: 400 }) + } else { + // Ban expired, remove it + try { + await db.collection('users').updateOne( + { _id: recipient._id }, + { $unset: { tipBannedUntil: 1 } } + ) + } catch (e) {} + } + } + + // tip! + await db.collection('users').updateOne( // sender + { _id: new ObjectId(session.user.id) }, + { + $inc: { balance: -tipAmount }, + $set: { lastTipAt: now } + } + ) + await db.collection('users').updateOne( // reciever + { _id: recipient._id }, + { $inc: { balance: tipAmount } } + ) + + let solPrice = 0; + try { + const priceDoc = await db.collection('settings').findOne({ _id: 'sol_price' as any }); + if (priceDoc) solPrice = priceDoc.price; + } catch {} + + return NextResponse.json({ + success: true, + message: `Successfully sent ${tipAmount} SOL to ${recipientUsername}`, + newBalance: (sender.balance || 0) - tipAmount + }) + + } catch (error) { + console.error('Tip error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/trade/route.ts b/app/api/trade/route.ts new file mode 100644 index 0000000..5ca414c --- /dev/null +++ b/app/api/trade/route.ts @@ -0,0 +1,320 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { checkRateLimit } from '@/lib/rate-limit' + +const DISCORD_WEBHOOK_URL = ''; +const TRADE_ROLE_ID = '1465848690785652900'; +async function sendDiscordPriceAlert(coin: any, trade: any, priceChangePercent: number, oldPrice: number, newPrice: number) { + try { + const embed = { + title: `${coin.name} Price Alert :bangbang:`, + description: `**[$${coin.ticker}](https://pummmp.fun/coin/${coin._id})** price ${priceChangePercent > 0 ? 'increased' : 'decreased'} by **${Math.abs(priceChangePercent).toFixed(2)}%** ${priceChangePercent > 0 ? 'β†—' : 'β†˜'}`, + color: priceChangePercent > 0 ? 0x00ff00 : 0xff0000, + fields: [ + { + name: 'New Price <:price:1465842044986724425>', + value: `\`$${newPrice.toFixed(9)}\``, + inline: true + }, + { + name: 'Market Cap ', + value: `\`${(coin.marketCap * (newPrice / coin.price)).toLocaleString()} SOL\``, + inline: true + }, + { + name: 'Trader <:trade:1465843050604396738>', + value: trade.userVerified ? `${trade.username} <:verified:1465841044275859722>` : trade.username, + inline: false + }, + { + name: `Action ${trade.type === 'buy' ? '<:greencandle:1465842982556139590>' : '<:redcandle:1465843012222324767>'}`, + value: `${trade.type === 'buy' ? 'Bought' : 'Sold'} **${trade.amount.toLocaleString()}** $${coin.ticker}`, + inline: true + }, + { + name: 'Value', + value: `> **${trade.total.toFixed(4)} SOL**`, + inline: false + }, + { + name: 'Change', + value: `> \`$${oldPrice.toFixed(9)}\` β†’ \`$${newPrice.toFixed(9)}\``, + inline: true + } + ], + timestamp: new Date().toISOString(), + footer: { + text: 'pummmp.fun', + icon_url: 'https://pummmp.fun/logo.png' + } + } + + await fetch(DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: `<@&${TRADE_ROLE_ID}>`, + embeds: [embed] + }) + }) + } catch (error) { + console.error('[/api/trade] Failed to send Discord webhook:', error) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (checkRateLimit(session.user.id, 'trade', 20, 60000)) { + return NextResponse.json({ error: 'Rate limit exceeded. Please slow down.' }, { status: 429 }) + } + + const { db } = await connectToDatabase() + const body = await request.json() + const { coinId, type, amount } = body + + if (!coinId || !type || !amount || amount <= 0) { + return NextResponse.json({ error: 'Invalid trade parameters' }, { status: 400 }) + } + + const coin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) }) + if (!coin) { + return NextResponse.json({ error: 'Coin not found' }, { status: 404 }) + } + + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Check trade ban + if (user.tradeBannedUntil) { + const banExpiry = new Date(user.tradeBannedUntil); + const nowCheck = new Date(); + if (banExpiry > nowCheck) { + const isPermanent = banExpiry.getFullYear && banExpiry.getFullYear() === 9999; + return NextResponse.json({ error: `You are banned from trading ${isPermanent ? 'permanently' : 'for a while'}` }, { status: 403 }) + } else { + try { await db.collection('users').updateOne({ _id: user._id }, { $unset: { tradeBannedUntil: 1 } }) } catch (e) {} + } + } + + const vSol = coin.virtualSolReserves || 30; + const vTokens = coin.virtualTokenReserves || 1073000000; + const k = vSol * vTokens; + let solAmount = 0; + let newPrice = coin.price; + let newVSol = vSol; + let newVTokens = vTokens; + + if (type === 'buy') { + if (amount >= vTokens - 1) { + return NextResponse.json({ error: 'Cannot buy entire supply' }, { status: 400 }) + } + + newVTokens = vTokens - amount; + newVSol = k / newVTokens; + solAmount = newVSol - vSol; + + if (user.balance < solAmount) { + return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + } + + newPrice = newVSol / newVTokens; + const existingPosition = user.portfolio?.find((p: any) => p.coinId === coinId) + + if (existingPosition) { + const newAmount = existingPosition.amount + amount + const newAvgPrice = (existingPosition.amount * existingPosition.avgBuyPrice + solAmount) / newAmount + + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id), 'portfolio.coinId': coinId }, + { + $inc: { balance: -solAmount }, + $set: { + 'portfolio.$.amount': newAmount, + 'portfolio.$.avgBuyPrice': newAvgPrice, + }, + } + ) + } else { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $inc: { balance: -solAmount }, + $push: { + portfolio: { + coinId, + amount, + avgBuyPrice: solAmount / amount, + }, + } as any, + } + ) + } + + // check for graduation (if virtualSolReserves > 85) + // real pump.fun is ~85 SOL. + const GRADUATION_THRESHOLD = 85; + const shouldGraduate = !coin.graduated && newVSol >= GRADUATION_THRESHOLD; + + // if graduating, we "lock" a portion of liquidity or just mark it + // we also notify via socket (handled by client listening to trade) + + const updateData: any = { + $set: { + price: newPrice, + marketCap: newPrice * coin.supply, + virtualSolReserves: newVSol, + virtualTokenReserves: newVTokens, + }, + $inc: { + volume24h: solAmount, // volume in SOL + holders: existingPosition ? 0 : 1, + liquidity: solAmount, // increase real liquidity + }, + $push: { + priceHistory: { + timestamp: new Date(), + price: newPrice, + volume: solAmount, + }, + } as any, + } + + if (shouldGraduate) { + updateData.$set.graduated = true; + updateData.$set.graduatedAt = new Date(); + } + + await db.collection('coins').updateOne( + { _id: new ObjectId(coinId) }, + updateData + ) + + } else if (type === 'sell') { + const position = user.portfolio?.find((p: any) => p.coinId === coinId) + + if (!position || position.amount < amount) { + return NextResponse.json({ error: 'Insufficient tokens' }, { status: 400 }) + } + + newVTokens = vTokens + amount; + newVSol = k / newVTokens; + solAmount = vSol - newVSol; // ? this is what user receives + + newPrice = newVSol / newVTokens; + + const newAmount = position.amount - amount + + if (newAmount === 0) { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $inc: { balance: solAmount }, + $pull: { portfolio: { coinId } } as any, + } + ) + + await db.collection('coins').updateOne( + { _id: new ObjectId(coinId) }, + { $inc: { holders: -1 } } + ) + } else { + await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id), 'portfolio.coinId': coinId }, + { + $inc: { balance: solAmount }, + $set: { 'portfolio.$.amount': newAmount }, + } + ) + } + + await db.collection('coins').updateOne( + { _id: new ObjectId(coinId) }, + { + $set: { + price: newPrice, + marketCap: newPrice * coin.supply, + virtualSolReserves: newVSol, + virtualTokenReserves: newVTokens, + }, + $inc: { + volume24h: solAmount, + liquidity: -solAmount, // decrease real liquidity + }, + $push: { + priceHistory: { + timestamp: new Date(), + price: newPrice, + volume: solAmount, + }, + } as any, + } + ) + } + + const updatedCoin = await db.collection('coins').findOne({ _id: new ObjectId(coinId) }); + if (updatedCoin && !updatedCoin.graduated) { + const GRADUATION_THRESHOLD = 45; + const currentVirtualSol = updatedCoin.virtualSolReserves || 0; + const shouldGraduate = currentVirtualSol >= GRADUATION_THRESHOLD; + + if (shouldGraduate) { + await db.collection('coins').updateOne( + { _id: new ObjectId(coinId) }, + { + $set: { + graduated: true, + graduatedAt: new Date(), + } + } + ); + console.log(`Coin ${coinId} graduated with ${currentVirtualSol} virtual SOL reserves`); + } + } + + // record trade + const trade = { + userId: session.user.id, + username: user.name || 'Anonymous', + userImage: user.image || '', + userVerified: !!user.verified, + userIsAdmin: !!user.isAdmin, + userIsBetaTester: !!user.isBetaTester, + userIsBugHunter: !!user.isBugHunter, + coinId, + type, + amount, // ? token amount + price: newPrice, + total: solAmount, // ? SOL value + timestamp: new Date(), + } + + const result = await db.collection('trades').insertOne(trade) + const savedTrade = { ...trade, _id: result.insertedId } + + const oldPrice = coin.price + const priceChangePercent = ((newPrice - oldPrice) / oldPrice) * 100 + + // Send alert for price changes > 25% + if (Math.abs(priceChangePercent) >= 25) { + await sendDiscordPriceAlert(coin, savedTrade, priceChangePercent, oldPrice, newPrice) + } + + return NextResponse.json({ success: true, trade: savedTrade }) + } catch (error) { + console.error('Error executing trade:', error) + return NextResponse.json({ error: 'Failed to execute trade' }, { status: 500 }) + } +} diff --git a/app/api/trades/route.ts b/app/api/trades/route.ts new file mode 100644 index 0000000..71dc980 --- /dev/null +++ b/app/api/trades/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { connectToDatabase } from '@/lib/mongodb' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const coinId = searchParams.get('coinId') + + if (!coinId) { + return NextResponse.json({ error: 'Coin ID is required' }, { status: 400 }) + } + + const { db } = await connectToDatabase() + + const trades = await db + .collection('trades') + .find({ coinId }) + .sort({ timestamp: -1 }) + .limit(50) + .toArray() + + return NextResponse.json(trades) + } catch (error) { + console.error('Error fetching trades:', error) + return NextResponse.json({ error: 'Failed to fetch trades' }, { status: 500 }) + } +} diff --git a/app/api/user/onboarding/route.ts b/app/api/user/onboarding/route.ts new file mode 100644 index 0000000..9c26b01 --- /dev/null +++ b/app/api/user/onboarding/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' +import { isReservedUsername } from '@/lib/validations' + +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { username } = await req.json(); + + // validation + if (!username || typeof username !== 'string') { + return NextResponse.json({ error: 'Username is required' }, { status: 400 }) + } + + if (username.length < 3 || username.length > 20) { + return NextResponse.json({ error: 'Username must be between 3 and 20 characters' }, { status: 400 }) + } + + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(username)) { + return NextResponse.json({ error: 'Username can only contain letters, numbers, underscores, and dashes' }, { status: 400 }) + } + + if (isReservedUsername(username)) { + return NextResponse.json({ error: 'This username is reserved' }, { status: 400 }) + } + + // database checks + const { db } = await connectToDatabase(); + + // check if onboarding flag is true + const currentUser = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + if (!currentUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + if (!currentUser.isOnboarding) { + return NextResponse.json({ error: 'Onboarding already completed' }, { status: 400 }) + } + + // check if username is taken (case insensitive) + const existingUser = await db.collection('users').findOne({ + name: { $regex: new RegExp(`^${username}$`, 'i') } + }) + + // ! if a user exists with this name AND it's not the current user (unlikely if they are onboarding, but good safety) + if (existingUser && existingUser._id.toString() !== session.user.id) { + return NextResponse.json({ error: 'Username is already taken' }, { status: 409 }) + } + + // then we update the name, and remove the `isOnboarding` flag + const result = await db.collection('users').updateOne( + { _id: new ObjectId(session.user.id) }, + { + $set: { + name: username, + isOnboarding: false + } + } + ) + + if (result.modifiedCount === 0) { + // ? did we fail to find the user? + return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 }) + } + + return NextResponse.json({ success: true, username }) + + } catch (error) { + console.error('Onboarding API Error:', error) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/app/api/user/route.ts b/app/api/user/route.ts new file mode 100644 index 0000000..9372ec9 --- /dev/null +++ b/app/api/user/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function GET() { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { db } = await connectToDatabase() + const user = await db.collection('users').findOne({ _id: new ObjectId(session.user.id) }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Fetch user's miners + const miners = await db.collection('miners').find({ + userId: session.user.id, + isActive: true + }).toArray() + + let portfolioValue = 0 + if (user.portfolio && user.portfolio.length > 0) { + for (const item of user.portfolio) { + const coin = await db.collection('coins').findOne({ _id: new ObjectId(item.coinId) }) + if (coin) { + portfolioValue += item.amount * coin.price + } + } + } + + return NextResponse.json({ + ...user, + miners, + portfolioValue, + totalValue: user.balance + portfolioValue, + }) + } catch (error) { + console.error('Error fetching user:', error) + return NextResponse.json({ error: 'Failed to fetch user' }, { status: 500 }) + } +} diff --git a/app/api/users/[username]/route.ts b/app/api/users/[username]/route.ts new file mode 100644 index 0000000..bea9f17 --- /dev/null +++ b/app/api/users/[username]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { connectToDatabase } from '@/lib/mongodb' +import { ObjectId } from 'mongodb' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params + const decodedUsername = decodeURIComponent(username) + const { db } = await connectToDatabase() + + const user = await db.collection('users').findOne( + { name: { $regex: new RegExp(`^${decodedUsername}$`, 'i') } }, + { projection: { email: 0, discordId: 0, balance: 0 } } + ) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const createdCoins = await db + .collection('coins') + .find({ creatorId: user._id.toString() }) + .sort({ createdAt: -1 }) + .toArray() + + let portfolioValue = 0 + const portfolio = user.portfolio || [] + const portfolioWithData = [] + + for (const item of portfolio) { + if (item.amount > 0) { + const coin = await db.collection('coins').findOne({ _id: new ObjectId(item.coinId) }) + if (coin) { + const value = item.amount * coin.price + portfolioValue += value + portfolioWithData.push({ + ...item, + coin, + currentValue: value + }) + } + } + } + + portfolioWithData.sort((a, b) => b.currentValue - a.currentValue) + + return NextResponse.json({ + user: { + ...user, + portfolioValue, + coinsCreated: createdCoins.length + }, + createdCoins, + portfolio: portfolioWithData + }) + } catch (error) { + console.error('Error fetching user profile:', error) + return NextResponse.json({ error: 'Failed to fetch user profile' }, { status: 500 }) + } +} diff --git a/app/coin/[id]/coin-client.tsx b/app/coin/[id]/coin-client.tsx new file mode 100644 index 0000000..0d1d5d7 --- /dev/null +++ b/app/coin/[id]/coin-client.tsx @@ -0,0 +1,665 @@ +'use client' + +import { use, useState, useEffect } from 'react' +import Link from 'next/link' +import useSWR, { mutate } from 'swr' +import { io, Socket } from 'socket.io-client' +import { useSession } from 'next-auth/react' +import { Header } from '@/components/header' +import { Comments } from '@/components/comments' +import { TopHolders } from '@/components/holders' +import { TradingChart } from '@/components/trading-chart' +import { TradePanel } from '@/components/trade-panel' +import { TradeHistory } from '@/components/trade-history' +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { Coin, User } from '@/types' +import { + LgCancel, + LgComboChart, + LgGroups, + LgFolderInvoices, + LgExternalLink, + LgLike +} from '@/components/icons' +import { Trash2, ShieldCheck, Zap, Share2 } from 'lucide-react' +import { toast } from 'sonner' +import { UserBadges } from '@/components/ui/user-badges' +import { VerifiedBadge } from '@/components/ui/verified-badge' + +const fetcher = (url: string) => fetch(url).then((res) => res.json()) + +export default function CoinPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const { data: session } = useSession() + const [socket, setSocket] = useState(null) + + const { data: coin, isLoading } = useSWR( + `/api/coins/${id}`, + fetcher + ) + + const { data: userData } = useSWR( + session ? '/api/user' : null, + fetcher, + { refreshInterval: 3000 } + ) + + const { data: solPrice } = useSWR('/api/sol-price', fetcher, { + refreshInterval: 60000, + }) + + const handleDeleteCoin = async () => { + try { + const response = await fetch(`/api/coins/${id}`, { + method: 'DELETE', + }) + const data = await response.json() + + if (response.ok) { + toast.success('Coin deleted successfully') + window.location.href = '/' + } else { + toast.error(data.error) + } + } catch (error) { + toast.error('Failed to delete coin') + } + } + + const handleBoostCoin = async () => { + try { + const response = await fetch(`/api/coins/${id}/boost`, { + method: 'POST', + }) + const data = await response.json() + + if (response.ok) { + mutate(`/api/coins/${id}`) + mutate('/api/user') + toast.success(`Coin boosted until ${new Date(data.boosted).toLocaleString()}!`) + } else { + toast.error(data.error) + } + } catch (error) { + toast.error('Failed to boost coin') + } + } + + const handleVerifyCoin = async () => { + if (!session?.user?.verified) return + + try { + const response = await fetch(`/api/coins/${id}/verify`, { + method: 'POST', + }) + const data = await response.json() + + if (response.ok) { + mutate(`/api/coins/${id}`) + toast.success(data.verified ? 'Coin verified!' : 'Coin unverified') + } else { + toast.error(data.error) + } + } catch (error) { + toast.error('Failed to update verification status') + } + } + + useEffect(() => { + const newSocket= io("https://pummmp.fun/", { + path: "/socket.io", + transports: ["websocket"], + }); + + + newSocket.on('connect', () => { + newSocket.emit('join_room', `coin:${id}`) + }) + + newSocket.on('update_coin', (updatedFields: Partial & { _id: string }) => { + if (updatedFields._id === id) { + mutate( + `/api/coins/${id}`, + (currentCoin: Coin | undefined) => { + if (!currentCoin) return undefined + return { ...currentCoin, ...updatedFields } + }, + false // disable revalidation + ) + } + }) + + newSocket.on(`trade:${id}`, (trade: any) => { + mutate( + `/api/coins/${id}`, + (currentCoin: Coin | undefined) => { + if (!currentCoin) return undefined + const newHistoryPoint = { + price: trade.price, + timestamp: trade.timestamp, + volume: trade.total + } + return { + ...currentCoin, + price: trade.price, + priceHistory: [...(currentCoin.priceHistory || []), newHistoryPoint] + } + }, + false + ) + mutate(session ? '/api/user' : null) + }) + + newSocket.on(`comment:${id}`, (newComment: any) => { + mutate( + `/api/comments?coinId=${id}`, + (currentComments: any[] = []) => { + // Prevent duplicate comments if revalidation happened first + if (currentComments.some(c => c._id === newComment._id)) { + return currentComments; + } + return [newComment, ...currentComments] + }, + false + ) + }) + + setSocket(newSocket) + + return () => { + newSocket.disconnect() + } + }, [id, session]) + + const [externalUrl, setExternalUrl] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + + const handleExternalLink = (url: string) => { + if (!url) return + if (!url.startsWith('http://') && !url.startsWith('https://')) return + + setExternalUrl(url) + setIsDialogOpen(true) + } + + const userHolding = userData?.portfolio?.find( + (p) => p.coinId === coin?._id?.toString() + )?.amount ?? 0 + + const formatNumber = (num: number) => { + try { + if (num >= 1000000000) return `${(num / 1000000000).toFixed(2)}B` + if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M` + if (num >= 1000) return `${(num / 1000).toFixed(2)}K` + return num.toFixed(2) + } catch { + return '0' + } + } + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + if (isLoading) { + return ( +
+
+
+
+
+
+ ) + } + + if (coin && coin.error) { + return ( +
+
+
+ +

Coin Not Found

+

+ This coin doesn't exist or has been removed +

+ +
+
+ ) + } + + if (!coin) { + return null; + } + + return ( +
+
+ +
+ {/* Breadcrumb */} + + + {coin.graduated && ( +
+
+ +
+
+
+ +
+
+

+ Graduated to Raydium + + + + LOCKED LIQUIDITY + + +

Liquidity is "locked" meaning the liquidity pool tokens act as a permanent foundation.

+

The price can still go up legally forever! Typically, "burning" LP tokens prevents rug pulls, ensuring trading can always happen.

+
+
+
+

+

This coin has completed the bonding curve. Trading is now live on Raydium.

+
+
+
+ )} + +
+ {/* Main Content */} +
+ {/* Coin Header */} +
+
+
+
+ {coin.name} +
+
+

+ {coin.name} + {coin.verified && } + {coin.boosted && ( + + + + + + +

This coin has been boosted until {new Date(coin.boosted!).toLocaleString()}

+ Learn more about boosting β†— +
+
+
+ )} +

+

${coin.ticker}

+
+
+ +
+ {(session?.user?.id === coin.creatorId || session?.user?.isAdmin) && ( + + + + + + + + + +

{session?.user?.isAdmin ? 'Admin Delete' : 'Delete Coin'}

+
+
+
+ + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your coin + and remove it from our servers. + + + + Cancel + + Delete + + + +
+ )} + + {session && ( + + + + + + + + + +

Boost Coin (1 SOL)

+
+
+
+ + + Boost this coin? + + This will cost 1 SOL and will feature this coin on the homepage for 24 hours. + If the coin is already boosted, it will extend the duration. + + + + Cancel + + Boost Now + + + +
+ )} + + {session?.user?.verified && ( + + + + + + +

{coin.verified ? "Unverify Coin" : "Verify Coin"}

+
+
+
+ )} + + {session && userHolding > 0 && ( +
+

Your Holdings

+

+ {formatNumber(userHolding)} {coin.ticker} +

+
+ )} + + + + + + + +

Share Coin

+
+
+
+
+
+ + {/* Trading Chart */} + + + {/* Stats Grid */} +
+
+
+ + Market Cap +
+

+ {`$`}{formatNumber(coin.marketCap * (solPrice?.price || 0))} USD +

+

+ SOL + {formatNumber(coin.marketCap)} SOL +

+
+ +
+
+ + 24h Volume +
+

+ {formatNumber(coin.volume24h)} SOL SOL +

+
+ +
+
+ + Holders +
+

+ {coin.holders.toLocaleString()} +

+
+ +
+
+ + Liquidity +
+ {/* !!!! Display Virtual Reserves (Bonding Curve Pool) instead of raw liquidity to match Market Cap context */} +

+ {formatNumber(coin.virtualSolReserves)} SOL SOL +

+

+ {formatNumber(coin.virtualTokenReserves)} ${coin.ticker} +

+
+
+ + {/* Description */} + {coin.description && ( +
+

About

+

{coin.description}

+
+ )} + + {/* Coin Info */} +
+

Token Info

+
+ {coin.website && ( +
+ Website + +
+ )} + {coin.twitter && ( +
+ Twitter + +
+ )} + {coin.telegram && ( +
+ Telegram + +
+ )} +
+ Created by + + {coin.creatorName} + + + +
+
+ Created at + {formatDate(coin.createdAt)} +
+
+ Total Supply + {formatNumber(coin.supply)} +
+
+
+ + {/* Trade History */} +
+ +
+ + {/* Comments Thread */} +
+ +
+
+ + {/* sticky */} +
+
+ + + {/* Bonding Curve Progress */} +
+
+ Bonding Curve Progress + + {coin.graduated ? '100' : Math.min((coin.marketCap / 85) * 100, 100).toFixed(0)}% + +
+ div]:bg-gradient-to-r [&>div]:from-yellow-400 [&>div]:to-yellow-600' : ''}`} + /> +

+ {coin.graduated + ? "Bonding curve completed! Liquidity has been seeded to Raydium." + : "When the market cap reaches 85 SOL, all liquidity is deposited into Raydium and burned."} +

+
+ + {/* Top Holders */} + +
+
+
+
+ + + + + Wait! Are you sure? + + You are about to leave pummmp.fun and visit: +
+ + {externalUrl} + +
+ Warning: We are not responsible for the content of this link. + Always verify the URL before visiting... +
+
+ + Cancel + { + if (externalUrl) window.open(externalUrl, '_blank') + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + I understand, visit site + + +
+
+
+ ) +} diff --git a/app/coin/[id]/page.tsx b/app/coin/[id]/page.tsx new file mode 100644 index 0000000..397d4f5 --- /dev/null +++ b/app/coin/[id]/page.tsx @@ -0,0 +1,50 @@ +import { Metadata } from 'next' +import CoinClient from './coin-client' + +type Props = { + params: Promise<{ id: string }> +} + +export async function generateMetadata( + { params }: Props +): Promise { + const { id } = await params; + + // Use absolute URL for fetch in server component + // In dev/prod this might need ENV var for base URL, assume localhost:6969 for now based on context + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:6969' + + try { + const product = await fetch(`${baseUrl}/api/coins/${id}`).then((res) => res.json()) + + if (!product || product.error) { + return { + title: 'Coin Not Found | pummmp.fun', + } + } + + return { + title: `${product.name} ($${product.ticker}) | pummmp.fun`, + description: product.description || `Trade ${product.name} on pummmp.fun today!`, + openGraph: { + title: `${product.name} ($${product.ticker})`, + description: product.description || `View ${product.name} chart and trade on pummmp.fun`, + images: ['https://pummmp.fun/banner.png'], + }, + twitter: { + card: 'summary_large_image', + title: `${product.name} ($${product.ticker})`, + description: product.description || `View ${product.name} chart and trade on pummmp.fun`, + images: ['https://pummmp.fun/banner.png'], + }, + } + } catch (e) { + return { + title: 'pummmp.fun', + } + } +} + +export default async function Page({ params }: Props) { + return +} diff --git a/app/create/page.tsx b/app/create/page.tsx new file mode 100644 index 0000000..369a0ba --- /dev/null +++ b/app/create/page.tsx @@ -0,0 +1,412 @@ +'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 ( +
+
+ +
+ {/* Header */} +
+
+ Logo +
+

Launch Your Memecoin

+

+ Create a new coin and make some MULA... +

+
+ + {!session ? ( +
+ +

Sign In Required

+

+ Connect your Discord account to create coins +

+ +
+ ) : ( +
+ {/* Preview Card */} +
+

Preview

+
+ Preview +
+

+ {formData.name || 'Coin Name'} +

+

+ ${formData.ticker || 'TICKER'} +

+
+
+ + {/* Image Source Toggle */} + {session?.user?.image && ( +
+ setFormData({ ...formData, useUserImage: e.target.checked })} + className="rounded border-gray-300" + /> + +
+ )} +
+ + {/* Form Fields */} +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Doge Moon" + className="h-12 bg-muted/30" + required + /> +
+ +
+ +
+ + $ + + setFormData({ ...formData, ticker: e.target.value.toUpperCase() })} + placeholder="MOON" + className="h-12 bg-muted/30 pl-8 uppercase" + maxLength={10} + required + /> +
+

+ 3-10 characters, letters only +

+
+ +
+ +