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 (