diff --git a/src/app/api/hashtags/search/route.ts b/src/app/api/hashtags/search/route.ts new file mode 100644 index 0000000..b382cf4 --- /dev/null +++ b/src/app/api/hashtags/search/route.ts @@ -0,0 +1,20 @@ +import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(req: NextRequest) { + const q = req.nextUrl.searchParams.get('q')?.trim().replace(/^#/, '').toLowerCase() + if (!q || q.length < 1) return NextResponse.json([]) + + const results = await prisma.hashtag.findMany({ + where: { + isActive: true, + isBanned: false, + tag: { startsWith: q }, + }, + orderBy: { currentPrice: 'desc' }, + take: 8, + select: { tag: true, displayTag: true, currentPrice: true }, + }) + + return NextResponse.json(results) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 100f4a0..81543e1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth' import { HashtagCard } from '@/components/HashtagCard' import { TrendingUp, Users, Hash } from 'lucide-react' import Link from 'next/link' +import { formatPnl, pnlColor } from '@/lib/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -37,9 +38,30 @@ async function getStats() { return { userCount, hashtagCount, tradeCount, topHashtags, recentTrades } } +async function getHoldings(userId: string) { + const positions = await prisma.position.findMany({ + where: { userId, shares: { gt: 0 } }, + include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true } } }, + }) + if (positions.length === 0) return null + const withPnl = positions.map((p) => ({ + ...p, + pnl: + p.positionType === 'LONG' + ? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + : (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares, + })) + const sorted = [...withPnl].sort((a, b) => b.pnl - a.pnl) + return { + biggestGain: sorted[0].pnl > 0 ? sorted[0] : null, + biggestLoss: sorted[sorted.length - 1].pnl < 0 ? sorted[sorted.length - 1] : null, + } +} + export default async function HomePage() { - const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] = - await Promise.all([getServerSession(authOptions), getStats()]) + const session = await getServerSession(authOptions) + const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] = + await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)]) return (
@@ -95,6 +117,42 @@ export default async function HomePage() { } label="Trades executed" value={tradeCount.toLocaleString()} />
+ {/* Holdings summary — biggest gain + biggest loss for signed-in users */} + {holdings && (holdings.biggestGain ?? holdings.biggestLoss) && ( +
+

+ + Your top positions +

+
+ {holdings.biggestGain && ( + +

Biggest gain

+

#{holdings.biggestGain.hashtag.displayTag}

+

+ {formatPnl(holdings.biggestGain.pnl)} +

+ + )} + {holdings.biggestLoss && ( + +

Biggest loss

+

#{holdings.biggestLoss.hashtag.displayTag}

+

+ {formatPnl(holdings.biggestLoss.pnl)} +

+ + )} +
+
+ )} + {/* Top hashtags */} {topHashtags.length > 0 && (
diff --git a/src/app/positions/page.tsx b/src/app/positions/page.tsx new file mode 100644 index 0000000..dc19305 --- /dev/null +++ b/src/app/positions/page.tsx @@ -0,0 +1,150 @@ +import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils' +import Link from 'next/link' +import { Coins } from 'lucide-react' + +export const dynamic = 'force-dynamic' + +function Sparkline({ prices }: { prices: number[] }) { + if (prices.length < 2) return + const min = Math.min(...prices) + const max = Math.max(...prices) + const range = max - min || 1 + const w = 80 + const h = 28 + const pts = prices + .map((p, i) => { + const x = (i / (prices.length - 1)) * w + const y = h - ((p - min) / range) * (h - 4) - 2 + return `${x},${y}` + }) + .join(' ') + const up = prices[prices.length - 1] >= prices[0] + return ( + + + + ) +} + +export default async function PositionsPage() { + const session = await getServerSession(authOptions) + if (!session) redirect('/auth/signin') + + const positions = await prisma.position.findMany({ + where: { userId: session.user.id, shares: { gt: 0 } }, + orderBy: { updatedAt: 'desc' }, + include: { + hashtag: { + select: { + tag: true, + displayTag: true, + currentPrice: true, + priceHistory: { + orderBy: { recordedAt: 'asc' }, + take: 20, + select: { price: true }, + }, + }, + }, + }, + }) + + return ( +
+
+ +

Open Positions

+
+ + {positions.length === 0 ? ( +
+ +

You have no open positions.

+ + Browse trending hashtags → + +
+ ) : ( +
+ {/* Header row */} +
+ Hashtag + Shares + Avg buy + Current + Cost basis + Value + P&L +
+ +
+ {positions.map((pos) => { + const pnl = + pos.positionType === 'LONG' + ? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares + : (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares + const costBasis = pos.avgBuyPrice * pos.shares + const currentValue = pos.hashtag.currentPrice * pos.shares + const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0 + const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price) + + return ( +
+ {/* Hashtag + type + sparkline */} +
+ +
+ + #{pos.hashtag.displayTag} + + + {pos.positionType} + +
+
+ + {formatNumber(pos.shares)} + {formatCurrency(pos.avgBuyPrice)} + {formatCurrency(pos.hashtag.currentPrice)} + {formatCurrency(costBasis)} + {formatCurrency(currentValue)} + +
+

{formatPnl(pnl)}

+

+ {pnlPct >= 0 ? '+' : ''} + {pnlPct.toFixed(1)}% +

+
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/src/app/profile/[username]/page.tsx b/src/app/profile/[username]/page.tsx index a2b103e..3f1e2f5 100644 --- a/src/app/profile/[username]/page.tsx +++ b/src/app/profile/[username]/page.tsx @@ -107,6 +107,14 @@ export default async function ProfilePage({ params }: Props) {

Open positions + {isOwn && ( + + View all → + + )}

diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 87fa42e..9e86a51 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -3,15 +3,20 @@ import Link from 'next/link' import { useSession, signOut } from 'next-auth/react' import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' -import { useState } from 'react' +import { useState, useRef } from 'react' import { useRouter } from 'next/navigation' import { formatCurrency } from '@/lib/utils' import { normalizeTag } from '@/lib/utils' +type Suggestion = { tag: string; displayTag: string; currentPrice: number } + export function Navbar() { const { data: session } = useSession() const router = useRouter() const [query, setQuery] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const debounceRef = useRef | null>(null) function handleSearch(e: React.FormEvent) { e.preventDefault() @@ -19,9 +24,33 @@ export function Navbar() { if (tag) { router.push(`/hashtag/${tag}`) setQuery('') + setSuggestions([]) + setShowSuggestions(false) } } + function handleQueryChange(e: React.ChangeEvent) { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + const normalized = val.replace(/^#/, '').trim().toLowerCase() + if (normalized.length < 1) { + setSuggestions([]) + setShowSuggestions(false) + return + } + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/hashtags/search?q=${encodeURIComponent(normalized)}`) + if (res.ok) { + const data: Suggestion[] = await res.json() + setSuggestions(data) + setShowSuggestions(data.length > 0) + } + } catch {} + }, 300) + } + return (