Add Open Graph metadata generation for fund, leaderboard, and profile pages
Build Images and Deploy / Update-PROD-Stack (push) Failing after 36s
Build Images and Deploy / Update-PROD-Stack (push) Failing after 36s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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(
|
||||
(
|
||||
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||
{/* Branding + badge */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
|
||||
<div style={{ fontSize: 20, color: '#818cf8', background: '#312e81', borderRadius: 8, padding: '6px 16px' }}>Hedge Fund</div>
|
||||
</div>
|
||||
|
||||
{/* Fund name */}
|
||||
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>{name}</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div style={{ display: 'flex', gap: 28, flex: 1 }}>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 20px', flex: 1 }}>
|
||||
<div style={{ fontSize: 16, color: '#475569', marginBottom: 8 }}>{label}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
|
||||
<span>{host}</span>
|
||||
<span>Trade hashtags like stocks</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ width: W, height: H },
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<span>{new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host}</span>
|
||||
<span>{host}</span>
|
||||
<span>Trade hashtags like stocks</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 40 }}>
|
||||
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
|
||||
<div style={{ fontSize: 42, fontWeight: 700, color: '#ffffff' }}>🏆 Leaderboard</div>
|
||||
</div>
|
||||
|
||||
{/* Top 5 rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
{ranked.map((u, i) => (
|
||||
<div key={u.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: i === 0 ? '#1c1a08' : '#1a1a2e', border: `1px solid ${i === 0 ? '#854d0e' : '#1e2035'}`, borderRadius: 14, padding: '18px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||
<span style={{ fontSize: 28, width: 40 }}>{medals[i]}</span>
|
||||
<span style={{ fontSize: 30, fontWeight: 600, color: i === 0 ? '#fde68a' : '#e2e8f0' }}>{u.name}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 30, fontWeight: 700, color: i === 0 ? '#fde68a' : '#ffffff' }}>{fmt(u.netWorth)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 32 }}>
|
||||
<span>{host}</span>
|
||||
<span>Trade hashtags like stocks</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ width: W, height: H },
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
(
|
||||
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||
{/* Branding */}
|
||||
<div style={{ fontSize: 26, color: '#6366f1', marginBottom: 24 }}>HashEx</div>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ fontSize: 68, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>
|
||||
@{displayName}
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div style={{ display: 'flex', gap: 32, flex: 1 }}>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 28px', flex: 1 }}>
|
||||
<div style={{ fontSize: 18, color: '#475569', marginBottom: 8 }}>{label}</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color }}>{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
|
||||
<span>{host}</span>
|
||||
<span>Trade hashtags like stocks</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ width: W, height: H },
|
||||
)
|
||||
}
|
||||
@@ -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<Metadata> {
|
||||
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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user