feat: enhance About page with Lucky Dip link and improve admin fund applications UI
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s

This commit is contained in:
2026-03-21 01:14:26 -04:00
parent 05a9d8f7af
commit 74a204ea39
7 changed files with 150 additions and 131 deletions
+3 -1
View File
@@ -181,7 +181,9 @@ export default function AboutPage() {
<Section title="Lucky Dip" icon={Shuffle}>
<p className="text-sm text-slate-300">
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
<Link href="/lucky-dip" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
Lucky Dip page</Link> now or you can always find the link on our home page.
</p>
</Section>
+23 -37
View File
@@ -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<Application[]>(initial)
const [expanded, setExpanded] = useState<string | null>(null)
const [approveId, setApproveId] = useState<string | null>(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,9 +70,15 @@ export default function AdminFundApplications({ applications: initial }: { appli
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">{error}</p>
)}
{applications.map((app) => (
{applications.map((app) => {
const isOpen = expanded === app.id
return (
<div key={app.id} className="bg-surface-card border border-amber-500/20 rounded-xl overflow-hidden">
<div className="flex items-start justify-between px-4 py-3">
{/* Clickable header row */}
<div
className="flex items-start justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors"
onClick={() => setExpanded(isOpen ? null : app.id)}
>
<div className="flex items-start gap-3 min-w-0">
<FileText className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
<div className="min-w-0">
@@ -84,42 +90,25 @@ export default function AdminFundApplications({ applications: initial }: { appli
</p>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<button
onClick={() => setExpanded(expanded === app.id ? null : app.id)}
className="text-slate-400 hover:text-slate-200 transition-colors"
aria-label="Toggle reason"
>
{expanded === app.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
<button
onClick={() => deny(app.id)}
onClick={(e) => deny(app.id, e)}
disabled={loading}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-lg transition-colors disabled:opacity-50"
title="Deny application"
>
<X className="h-4 w-4" />
</button>
<button
onClick={() => setApproveId(approveId === app.id ? null : app.id)}
disabled={loading}
className="p-1.5 text-green-400 hover:text-green-300 hover:bg-green-400/10 rounded-lg transition-colors disabled:opacity-50"
title="Approve application"
>
<Check className="h-4 w-4" />
</button>
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
</div>
</div>
{expanded === app.id && (
<div className="px-4 pb-3 text-sm text-slate-300 whitespace-pre-wrap border-t border-surface-border pt-3">
{app.reason}
</div>
)}
{/* Expanded: reason + approve form */}
{isOpen && (
<div className="border-t border-surface-border px-4 pt-3 pb-4 space-y-4">
<p className="text-sm text-slate-300 whitespace-pre-wrap">{app.reason}</p>
{approveId === app.id && (
<div className="px-4 pb-4 border-t border-surface-border pt-3 space-y-3">
<div className="space-y-2">
<p className="text-xs text-slate-400">
Approve <span className="text-white font-medium">{app.fundName}</span> for{' '}
<span className="text-slate-200">{app.user.displayUsername ?? app.user.username}</span>
@@ -150,6 +139,7 @@ export default function AdminFundApplications({ applications: initial }: { appli
type="number"
min="0"
value={startingBalance}
onClick={(e) => 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"
/>
@@ -162,18 +152,14 @@ export default function AdminFundApplications({ applications: initial }: { appli
>
Confirm Approval
</button>
<button
onClick={() => setApproveId(null)}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-100"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
))}
)
})}
</div>
)
}
+29 -12
View File
@@ -67,26 +67,43 @@ export default async function AdminOverviewPage() {
Recent trades
</h2>
<div className="divide-y divide-surface-border">
{recentTrades.map((t) => (
{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 (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span
className={`text-xs px-1.5 py-0.5 rounded ${
t.type === 'LOTTERY_WIN'
? 'bg-amber-500/15 text-amber-400'
: t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}
>
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
{t.type.replace(/_/g, ' ')}
</span>
<span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500">
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
</span>
{label && <span className="text-slate-500">{label}</span>}
</div>
<span>{formatCurrency(t.total)}</span>
</div>
))}
)
})}
</div>
</div>
</div>
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -2
View File
@@ -85,7 +85,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
<button
key={t}
onClick={() => { setTab(t); setShares(''); setError('') }}
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors ${
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors leading-tight ${
tab === t
? t.startsWith('BUY')
? 'bg-emerald-600 text-white'
@@ -93,7 +93,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t.replace('_', ' ')}
<span className="block sm:inline">{t.split('_')[0]}</span>
<span className="hidden sm:inline"> </span>
<span className="block sm:inline">{t.split('_')[1]}</span>
</button>
))}
</div>
+15 -6
View File
@@ -73,10 +73,11 @@ export async function getPostsPerHour(tag: string): Promise<number> {
* 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)