diff --git a/src/app/api/og/fund/[slug]/route.tsx b/src/app/api/og/fund/[slug]/route.tsx new file mode 100644 index 0000000..e6523a4 --- /dev/null +++ b/src/app/api/og/fund/[slug]/route.tsx @@ -0,0 +1,98 @@ +import { ImageResponse } from 'next/og' +import { prisma } from '@/lib/prisma' +import { calcFundNav } from '@/lib/pricing' + +export const runtime = 'nodejs' + +const W = 1200 +const H = 630 + +export async function GET( + _req: Request, + { params }: { params: { slug: string } }, +) { + const slug = decodeURIComponent(params.slug).toLowerCase() + + const fund = await prisma.hedgeFund.findUnique({ + where: { slug }, + select: { + name: true, + sharesOutstanding: true, + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { + positionType: true, + shares: true, + avgBuyPrice: true, + hashtag: { select: { currentPrice: true } }, + }, + }, + }, + }, + managers: { select: { userId: true } }, + _count: { select: { investments: true } }, + }, + }) + + const name = fund?.name ?? slug + const cash = fund?.user.balance ?? 0 + const portfolioValue = fund?.user.positions.reduce((sum, p) => { + const val = p.positionType === 'LONG' + ? p.shares * p.hashtag.currentPrice + : p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + return sum + val + }, 0) ?? 0 + const totalValue = cash + portfolioValue + const nav = fund ? calcFundNav(totalValue, fund.sharesOutstanding) : 1 + const managerCount = fund?.managers.length ?? 0 + const investorCount = fund?._count.investments ?? 0 + const openPositions = fund?.user.positions.length ?? 0 + + const fmt = (n: number) => new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2, + }).format(n) + + const host = new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host + + return new ImageResponse( + ( +
+ {/* Branding + badge */} +
+
HashEx
+
Hedge Fund
+
+ + {/* Fund name */} +
{name}
+ + {/* Stats grid */} +
+ {[ + { label: 'Total Value', value: fmt(totalValue), color: '#ffffff' }, + { label: 'NAV / Share', value: fmt(nav), color: '#ffffff' }, + { label: 'Cash', value: fmt(cash), color: '#94a3b8' }, + { label: 'Positions', value: String(openPositions), color: '#94a3b8' }, + { label: 'Managers', value: String(managerCount), color: '#94a3b8' }, + { label: 'Investors', value: String(investorCount), color: '#94a3b8' }, + ].map(({ label, value, color }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {/* Footer */} +
+ {host} + Trade hashtags like stocks +
+
+ ), + { width: W, height: H }, + ) +} diff --git a/src/app/api/og/hashtag/[tag]/route.tsx b/src/app/api/og/hashtag/[tag]/route.tsx index 25fe32c..251e505 100644 --- a/src/app/api/og/hashtag/[tag]/route.tsx +++ b/src/app/api/og/hashtag/[tag]/route.tsx @@ -65,6 +65,7 @@ export async function GET( }).format(price) const polyline = buildPolyline(prices) + const host = new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host return new ImageResponse( ( @@ -142,7 +143,7 @@ export async function GET( fontSize: 22, }} > - {new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host} + {host} Trade hashtags like stocks diff --git a/src/app/api/og/leaderboard/route.tsx b/src/app/api/og/leaderboard/route.tsx new file mode 100644 index 0000000..fdda123 --- /dev/null +++ b/src/app/api/og/leaderboard/route.tsx @@ -0,0 +1,69 @@ +import { ImageResponse } from 'next/og' +import { prisma } from '@/lib/prisma' + +export const runtime = 'nodejs' + +const W = 1200 +const H = 630 + +export async function GET() { + const users = await prisma.user.findMany({ + where: { isFund: false, isHidden: false }, + select: { + displayUsername: true, + username: true, + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { shares: true, hashtag: { select: { currentPrice: true } } }, + }, + }, + }) + + const ranked = users + .map((u) => ({ + name: u.displayUsername ?? u.username, + netWorth: u.balance + u.positions.reduce((s, p) => s + p.shares * p.hashtag.currentPrice, 0), + })) + .sort((a, b) => b.netWorth - a.netWorth) + .slice(0, 5) + + const fmt = (n: number) => new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', notation: n >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2, + }).format(n) + + const medals = ['🥇', '🥈', '🥉', '4.', '5.'] + const host = new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host + + return new ImageResponse( + ( +
+ {/* Header */} +
+
HashEx
+
🏆 Leaderboard
+
+ + {/* Top 5 rows */} +
+ {ranked.map((u, i) => ( +
+
+ {medals[i]} + {u.name} +
+ {fmt(u.netWorth)} +
+ ))} +
+ + {/* Footer */} +
+ {host} + Trade hashtags like stocks +
+
+ ), + { width: W, height: H }, + ) +} diff --git a/src/app/api/og/profile/[username]/route.tsx b/src/app/api/og/profile/[username]/route.tsx new file mode 100644 index 0000000..7271759 --- /dev/null +++ b/src/app/api/og/profile/[username]/route.tsx @@ -0,0 +1,87 @@ +import { ImageResponse } from 'next/og' +import { prisma } from '@/lib/prisma' + +export const runtime = 'nodejs' + +const W = 1200 +const H = 630 + +export async function GET( + _req: Request, + { params }: { params: { username: string } }, +) { + const username = decodeURIComponent(params.username).toLowerCase() + + const user = await prisma.user.findUnique({ + where: { username }, + select: { + displayUsername: true, + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { + positionType: true, + shares: true, + avgBuyPrice: true, + hashtag: { select: { currentPrice: true } }, + }, + }, + _count: { select: { trades: true } }, + }, + }) + + const displayName = user?.displayUsername ?? username + const balance = user?.balance ?? 0 + const portfolioValue = user?.positions.reduce((sum, p) => sum + p.shares * p.hashtag.currentPrice, 0) ?? 0 + const netWorth = balance + portfolioValue + const unrealizedPnl = user?.positions.reduce((sum, p) => { + if (p.positionType === 'LONG') return sum + (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + return sum + (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares + }, 0) ?? 0 + const tradeCount = user?._count.trades ?? 0 + const openPositions = user?.positions.length ?? 0 + + const fmt = (n: number) => new Intl.NumberFormat('en-US', { + style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2, + }).format(n) + + const pnlColor = unrealizedPnl >= 0 ? '#34d399' : '#f87171' + const host = new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host + + return new ImageResponse( + ( +
+ {/* Branding */} +
HashEx
+ + {/* Name */} +
+ @{displayName} +
+ + {/* Stats grid */} +
+ {[ + { label: 'Net Worth', value: fmt(netWorth), color: '#ffffff' }, + { label: 'Cash', value: fmt(balance), color: '#94a3b8' }, + { label: 'Unrealized P&L', value: fmt(unrealizedPnl), color: pnlColor }, + { label: 'Open Positions', value: String(openPositions), color: '#94a3b8' }, + { label: 'Total Trades', value: String(tradeCount), color: '#94a3b8' }, + ].map(({ label, value, color }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {/* Footer */} +
+ {host} + Trade hashtags like stocks +
+
+ ), + { width: W, height: H }, + ) +} diff --git a/src/app/fund/[slug]/page.tsx b/src/app/fund/[slug]/page.tsx index e96e4ce..92cfb1a 100644 --- a/src/app/fund/[slug]/page.tsx +++ b/src/app/fund/[slug]/page.tsx @@ -11,8 +11,28 @@ import { calcFundNav } from '@/lib/pricing' import InvestPanel from './InvestPanel' import { PriceChart } from '@/components/PriceChart' +import type { Metadata } from 'next' + export const dynamic = 'force-dynamic' +export async function generateMetadata({ params }: { params: { slug: string } }): Promise { + const slug = decodeURIComponent(params.slug).toLowerCase() + const fund = await prisma.hedgeFund.findUnique({ + where: { slug }, + select: { name: true }, + }) + const name = fund?.name ?? slug + const title = `${name} — HashEx Hedge Fund` + const description = `${name} is a hedge fund on HashEx trading Mastodon hashtags. View their portfolio, NAV, and performance.` + const imageUrl = `/api/og/fund/${encodeURIComponent(slug)}` + return { + title, + description, + openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${name} fund overview` }] }, + twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }, + } +} + export default async function FundPage({ params }: { params: { slug: string } }) { const session = await getServerSession(authOptions) const slug = decodeURIComponent(params.slug).toLowerCase() diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 33987e7..a14ceb9 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -7,8 +7,26 @@ import Link from 'next/link' import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react' import { AutoRefresh } from '@/components/AutoRefresh' +import type { Metadata } from 'next' + export const dynamic = 'force-dynamic' +export const metadata: Metadata = { + title: 'Leaderboard — HashEx', + description: 'Top traders by net worth on HashEx, the hashtag stock exchange.', + openGraph: { + title: 'Leaderboard — HashEx', + description: 'Top traders by net worth on HashEx, the hashtag stock exchange.', + images: [{ url: '/api/og/leaderboard', width: 1200, height: 630, alt: 'HashEx leaderboard' }], + }, + twitter: { + card: 'summary_large_image', + title: 'Leaderboard — HashEx', + description: 'Top traders by net worth on HashEx, the hashtag stock exchange.', + images: ['/api/og/leaderboard'], + }, +} + async function getLeaderboard() { const users = await prisma.user.findMany({ where: { isFund: false, isHidden: false }, diff --git a/src/app/profile/[username]/page.tsx b/src/app/profile/[username]/page.tsx index dde4918..f3b6801 100644 --- a/src/app/profile/[username]/page.tsx +++ b/src/app/profile/[username]/page.tsx @@ -13,12 +13,34 @@ import CloseAccountForm from './CloseAccountForm' import ResetAccountForm from './ResetAccountForm' import { PriceChart } from '@/components/PriceChart' +import type { Metadata } from 'next' + export const dynamic = 'force-dynamic' interface Props { params: { username: string } } +export async function generateMetadata({ params }: Props): Promise { + const username = decodeURIComponent(params.username).toLowerCase() + const user = await prisma.user.findUnique({ + where: { username }, + select: { displayUsername: true, balance: true, _count: { select: { trades: true } } }, + }) + const displayName = user?.displayUsername ?? username + const title = `${displayName} — HashEx Profile` + const description = user + ? `Check out ${displayName}'s trading profile on HashEx.` + : `HashEx trader profile for @${username}.` + const imageUrl = `/api/og/profile/${encodeURIComponent(username)}` + return { + title, + description, + openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${displayName}'s profile` }] }, + twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }, + } +} + export default async function ProfilePage({ params }: Props) { const session = await getServerSession(authOptions) const username = decodeURIComponent(params.username).toLowerCase()