From 74a204ea3971ab8e18828682cf7894936205bc82 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Sat, 21 Mar 2026 01:14:26 -0400 Subject: [PATCH] feat: enhance About page with Lucky Dip link and improve admin fund applications UI --- src/app/about/page.tsx | 4 +- src/app/admin/funds/AdminFundApplications.tsx | 188 ++++++++---------- src/app/admin/page.tsx | 55 +++-- src/app/api/funds/[slug]/invest/route.ts | 2 +- src/app/api/funds/[slug]/redeem/route.ts | 5 +- src/app/hashtag/[tag]/TradePanel.tsx | 6 +- src/lib/mastodon.ts | 21 +- 7 files changed, 150 insertions(+), 131 deletions(-) diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 24595a3..c35095f 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -181,7 +181,9 @@ export default function AboutPage() {

Once per day you can open the Lucky Dip lottery. Pick a box — most are empty, but a few hold cash prizes. - Winnings are added directly to your balance. + Winnings are added directly to your balance. So head over and check out our + + Lucky Dip page now or you can always find the link on our home page.

diff --git a/src/app/admin/funds/AdminFundApplications.tsx b/src/app/admin/funds/AdminFundApplications.tsx index 1a290cd..390aed8 100644 --- a/src/app/admin/funds/AdminFundApplications.tsx +++ b/src/app/admin/funds/AdminFundApplications.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' -import { FileText, Check, X, ChevronDown, ChevronUp } from 'lucide-react' +import { FileText, X, ChevronDown, ChevronUp } from 'lucide-react' interface Applicant { id: string @@ -23,7 +23,6 @@ export default function AdminFundApplications({ applications: initial }: { appli const router = useRouter() const [applications, setApplications] = useState(initial) const [expanded, setExpanded] = useState(null) - const [approveId, setApproveId] = useState(null) const [startingBalance, setStartingBalance] = useState('10000') const [error, setError] = useState('') const [loading, setLoading] = useState(false) @@ -40,11 +39,12 @@ export default function AdminFundApplications({ applications: initial }: { appli setLoading(false) if (!res.ok) { setError(data.error ?? 'Failed'); return } setApplications(applications.filter((a) => a.id !== app.id)) - setApproveId(null) + setExpanded(null) router.refresh() } - async function deny(id: string) { + async function deny(id: string, e: React.MouseEvent) { + e.stopPropagation() if (!confirm('Deny this application? The applicant will not be notified.')) return setLoading(true) const res = await fetch(`/api/admin/fund-applications/${id}`, { @@ -70,110 +70,96 @@ export default function AdminFundApplications({ applications: initial }: { appli {error && (

{error}

)} - {applications.map((app) => ( -
-
-
- -
-

{app.fundName}

-

- by{' '} - {app.user.displayUsername ?? app.user.username} - {' '}· {new Date(app.createdAt).toLocaleDateString()} -

+ {applications.map((app) => { + const isOpen = expanded === app.id + return ( +
+ {/* Clickable header row */} +
setExpanded(isOpen ? null : app.id)} + > +
+ +
+

{app.fundName}

+

+ by{' '} + {app.user.displayUsername ?? app.user.username} + {' '}· {new Date(app.createdAt).toLocaleDateString()} +

+
+
+
+ + {isOpen ? : }
-
- - - -
-
+ {/* Expanded: reason + approve form */} + {isOpen && ( +
+

{app.reason}

- {expanded === app.id && ( -
- {app.reason} -
- )} - - {approveId === app.id && ( -
-

- Approve {app.fundName} for{' '} - {app.user.displayUsername ?? app.user.username} - {app.user.managedFunds.length > 0 && ( - - {' '}(also manages{' '} - {app.user.managedFunds.map((m, i) => ( - - {i > 0 && ', '} - - {m.fund.name} - +

+

+ Approve {app.fundName} for{' '} + {app.user.displayUsername ?? app.user.username} + {app.user.managedFunds.length > 0 && ( + + {' '}(also manages{' '} + {app.user.managedFunds.map((m, i) => ( + + {i > 0 && ', '} + + {m.fund.name} + + + ))} + ) - ))} - ) - - )} -

-
-
- - setStartingBalance(e.target.value)} - className="w-32 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" - /> -
-
- - + )} +

+
+
+ + e.stopPropagation()} + onChange={(e) => setStartingBalance(e.target.value)} + className="w-32 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+
+ +
+
-
- )} -
- ))} + )} +
+ ) + })}
) } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0c4bb2d..bd97fbe 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -67,26 +67,43 @@ export default async function AdminOverviewPage() { Recent trades
- {recentTrades.map((t) => ( -
-
- - {t.type.replace(/_/g, ' ')} - - {t.user.username} - - {t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'} - + {recentTrades.map((t) => { + const isLottery = t.type === 'LOTTERY_WIN' + const isBuy = t.type.startsWith('BUY') + const isSell = t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' + const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM' + const isSystem = t.type === 'ACCOUNT_OPEN' || t.type === 'DONATION' || t.type === 'BANKRUPTCY' + const badgeClass = isLottery + ? 'bg-amber-500/15 text-amber-400' + : isFundTrade + ? 'bg-indigo-500/15 text-indigo-400' + : isSystem + ? 'bg-slate-500/15 text-slate-400' + : isBuy + ? 'bg-emerald-500/15 text-emerald-400' + : isSell + ? 'bg-red-500/15 text-red-400' + : 'bg-slate-500/15 text-slate-400' + const label = t.hashtag + ? `#${t.hashtag.displayTag}` + : isLottery + ? 'Lucky Dip' + : isFundTrade + ? 'Fund' + : null + return ( +
+
+ + {t.type.replace(/_/g, ' ')} + + {t.user.username} + {label && {label}} +
+ {formatCurrency(t.total)}
- {formatCurrency(t.total)} -
- ))} + ) + })}
diff --git a/src/app/api/funds/[slug]/invest/route.ts b/src/app/api/funds/[slug]/invest/route.ts index 2d63b53..7a926b1 100644 --- a/src/app/api/funds/[slug]/invest/route.ts +++ b/src/app/api/funds/[slug]/invest/route.ts @@ -68,7 +68,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin const [updatedInvestor] = await prisma.$transaction([ // Deduct from investor (returns updated user with new balance) - prisma.user.update({ where: { id: session.user.id }, data: { balance: { decrement: amount } } }), + prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance - amount) } }), // Add to fund's cash prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }), // Upsert FundInvestment record diff --git a/src/app/api/funds/[slug]/redeem/route.ts b/src/app/api/funds/[slug]/redeem/route.ts index 4f11d05..87e7a77 100644 --- a/src/app/api/funds/[slug]/redeem/route.ts +++ b/src/app/api/funds/[slug]/redeem/route.ts @@ -42,6 +42,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 }) } + const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true } }) + if (!investor) return NextResponse.json({ error: 'User not found' }, { status: 404 }) + const portfolioValue = fund.user.positions.reduce((sum, p) => { const val = p.positionType === 'LONG' ? p.shares * p.hashtag.currentPrice @@ -61,7 +64,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin const [updatedInvestor] = await prisma.$transaction([ // Return cash to investor - prisma.user.update({ where: { id: session.user.id }, data: { balance: { increment: payout } } }), + prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance + payout) } }), // Deduct from fund's cash prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), // Update or delete FundInvestment diff --git a/src/app/hashtag/[tag]/TradePanel.tsx b/src/app/hashtag/[tag]/TradePanel.tsx index 8dd340d..ab54b7f 100644 --- a/src/app/hashtag/[tag]/TradePanel.tsx +++ b/src/app/hashtag/[tag]/TradePanel.tsx @@ -85,7 +85,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund ))}
diff --git a/src/lib/mastodon.ts b/src/lib/mastodon.ts index a25de64..2ab0a2c 100644 --- a/src/lib/mastodon.ts +++ b/src/lib/mastodon.ts @@ -73,10 +73,11 @@ export async function getPostsPerHour(tag: string): Promise { * Strategy: * - Paginate until we have at least one post older than 1 hour (a complete picture), * OR we exhaust the timeline, OR we hit MAX_PAGES_PER_HASHTAG. - * - If the oldest fetched post is >= 1 hour old: postsPerHour = count of posts in the - * last hour (direct measurement over a full window). - * - If all fetched posts are within the last hour (hit page limit or timeline exhausted - * with a narrow window): extrapolate — postsPerHour = count / (coveredHours). + * - Oldest post >= 1 hour old: count posts in the last hour directly (full window). + * - Hit the page cap (burst): more posts exist beyond what we fetched — extrapolate from + * the covered span (postsPerHour = count / coveredHours). + * - Timeline exhausted (sparse): these are all the posts that exist — use the raw count. + * Extrapolating would artificially inflate a tag with 3 posts clustered in 10 minutes. */ export async function getPostsData( tag: string, @@ -89,6 +90,7 @@ export async function getPostsData( let allPosts: MastodonPost[] = [] let maxId: string | undefined + let hitPageCap = false for (let page = 0; page < maxPages; page++) { const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit) @@ -104,6 +106,9 @@ export async function getPostsData( if (oldestInBatch < cutoff) break maxId = nextMaxId + + // Mark if we completed the final allowed page without breaking + if (page === maxPages - 1) hitPageCap = true } if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false } @@ -116,11 +121,15 @@ export async function getPostsData( if (oldestMs < cutoff) { // We reached (or passed) the 1-hour horizon — count posts within the last hour directly postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length - } else { - // All posts are within the last hour (burst scenario or very sparse tag). + } else if (hitPageCap) { + // Hit the page limit — more posts likely exist beyond what we fetched (burst scenario). // Extrapolate from the covered span. Minimum 1-minute span to avoid divide-by-zero. const coveredMs = Math.max(newestMs - oldestMs, 60_000) postsPerHour = allPosts.length / (coveredMs / ONE_HOUR_MS) + } else { + // Timeline exhausted — these are all the posts that exist within the last hour. + // Use the raw count directly; extrapolating would inflate a sparse tag. + postsPerHour = allPosts.length } // Count co-occurring tags from the API tags object (authoritative for membership)