diff --git a/src/app/api/og/hashtag/[tag]/route.tsx b/src/app/api/og/hashtag/[tag]/route.tsx
new file mode 100644
index 0000000..25fe32c
--- /dev/null
+++ b/src/app/api/og/hashtag/[tag]/route.tsx
@@ -0,0 +1,152 @@
+import { ImageResponse } from 'next/og'
+import { prisma } from '@/lib/prisma'
+
+export const runtime = 'nodejs'
+
+const W = 1200
+const H = 630
+const CHART_X = 60
+const CHART_Y = 200
+const CHART_W = W - 120
+const CHART_H = 220
+
+function buildPolyline(prices: number[]): string {
+ if (prices.length < 2) return ''
+ const min = Math.min(...prices)
+ const max = Math.max(...prices)
+ const range = max - min || 1
+ return prices
+ .map((p, i) => {
+ const x = CHART_X + (i / (prices.length - 1)) * CHART_W
+ const y = CHART_Y + CHART_H - ((p - min) / range) * CHART_H
+ return `${x},${y}`
+ })
+ .join(' ')
+}
+
+export async function GET(
+ _req: Request,
+ { params }: { params: { tag: string } },
+) {
+ const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
+
+ const hashtag = await prisma.hashtag.findUnique({
+ where: { tag },
+ select: {
+ displayTag: true,
+ currentPrice: true,
+ isActive: true,
+ priceHistory: {
+ orderBy: { recordedAt: 'desc' },
+ take: 48,
+ select: { price: true },
+ },
+ },
+ })
+
+ const displayTag = hashtag?.displayTag ?? tag
+ const price = hashtag?.currentPrice ?? 0.25
+ const prices = (hashtag?.priceHistory ?? []).map((p) => p.price).reverse()
+ const prevPrice = prices.length >= 2 ? prices[0] : null
+ const changePct = prevPrice && prevPrice > 0
+ ? ((price - prevPrice) / prevPrice) * 100
+ : null
+ const trending = changePct === null ? null : changePct >= 0
+ const lineColor = trending === null ? '#6366f1' : trending ? '#34d399' : '#f87171'
+ const changeStr = changePct === null
+ ? ''
+ : `${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%`
+
+ const priceStr = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(price)
+
+ const polyline = buildPolyline(prices)
+
+ return new ImageResponse(
+ (
+
+ {/* Header row */}
+
+
+
HashEx
+
+ #{displayTag}
+
+
+
+
{priceStr}
+ {changeStr && (
+
+ {changeStr}
+
+ )}
+ {!hashtag?.isActive && (
+
inactive
+ )}
+
+
+
+ {/* Sparkline */}
+ {prices.length >= 2 && (
+
+ )}
+
+ {/* Footer */}
+
+ {new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000').host}
+ Trade hashtags like stocks
+
+
+ ),
+ { width: W, height: H },
+ )
+}
diff --git a/src/app/hashtag/[tag]/page.tsx b/src/app/hashtag/[tag]/page.tsx
index 1f7b0eb..61b5f8c 100644
--- a/src/app/hashtag/[tag]/page.tsx
+++ b/src/app/hashtag/[tag]/page.tsx
@@ -18,6 +18,8 @@ const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE
const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10)
+import type { Metadata } from 'next'
+
export const dynamic = 'force-dynamic'
interface Props {
@@ -25,6 +27,51 @@ interface Props {
searchParams: { fund?: string }
}
+export async function generateMetadata({ params }: Props): Promise {
+ const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
+ const hashtag = await prisma.hashtag.findUnique({
+ where: { tag },
+ select: {
+ displayTag: true,
+ currentPrice: true,
+ isActive: true,
+ priceHistory: { orderBy: { recordedAt: 'desc' }, take: 2, select: { price: true } },
+ },
+ })
+
+ const displayTag = hashtag?.displayTag ?? tag
+ const price = hashtag?.currentPrice ?? 0
+ const prevPrice = hashtag?.priceHistory[1]?.price ?? null
+ const changePct = prevPrice && prevPrice > 0
+ ? ((price - prevPrice) / prevPrice) * 100
+ : null
+ const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 }).format(price)
+ const changeStr = changePct !== null
+ ? ` ${changePct >= 0 ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%`
+ : ''
+ const status = hashtag?.isActive === false ? ' · inactive' : ''
+
+ const title = `#${displayTag} — ${priceStr}${changeStr}`
+ const description = `Trade #${displayTag} on HashEx. Current price: ${priceStr}${changeStr}${status}. Prices driven by real Mastodon activity.`
+ const imageUrl = `/api/og/hashtag/${encodeURIComponent(tag)}`
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ images: [{ url: imageUrl, width: 1200, height: 630, alt: `#${displayTag} price chart` }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title,
+ description,
+ images: [imageUrl],
+ },
+ }
+}
+
export default async function HashtagPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')