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 */}
+
+
+ {/* 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 }) => (
+
+ ))}
+
+
+ {/* 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 }) => (
+
+ ))}
+
+
+ {/* 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()