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
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
This commit is contained in:
@@ -181,7 +181,9 @@ export default function AboutPage() {
|
|||||||
<Section title="Lucky Dip" icon={Shuffle}>
|
<Section title="Lucky Dip" icon={Shuffle}>
|
||||||
<p className="text-sm text-slate-300">
|
<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.
|
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>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { FileText, Check, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
interface Applicant {
|
interface Applicant {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,7 +23,6 @@ export default function AdminFundApplications({ applications: initial }: { appli
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [applications, setApplications] = useState<Application[]>(initial)
|
const [applications, setApplications] = useState<Application[]>(initial)
|
||||||
const [expanded, setExpanded] = useState<string | null>(null)
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
const [approveId, setApproveId] = useState<string | null>(null)
|
|
||||||
const [startingBalance, setStartingBalance] = useState('10000')
|
const [startingBalance, setStartingBalance] = useState('10000')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -40,11 +39,12 @@ export default function AdminFundApplications({ applications: initial }: { appli
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||||
setApplications(applications.filter((a) => a.id !== app.id))
|
setApplications(applications.filter((a) => a.id !== app.id))
|
||||||
setApproveId(null)
|
setExpanded(null)
|
||||||
router.refresh()
|
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
|
if (!confirm('Deny this application? The applicant will not be notified.')) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const res = await fetch(`/api/admin/fund-applications/${id}`, {
|
const res = await fetch(`/api/admin/fund-applications/${id}`, {
|
||||||
@@ -70,9 +70,15 @@ export default function AdminFundApplications({ applications: initial }: { appli
|
|||||||
{error && (
|
{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>
|
<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 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">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<FileText className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
<FileText className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -84,42 +90,25 @@ export default function AdminFundApplications({ applications: initial }: { appli
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4 shrink-0">
|
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(expanded === app.id ? null : app.id)}
|
onClick={(e) => deny(app.id, e)}
|
||||||
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)}
|
|
||||||
disabled={loading}
|
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"
|
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"
|
title="Deny application"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded === app.id && (
|
{/* Expanded: reason + approve form */}
|
||||||
<div className="px-4 pb-3 text-sm text-slate-300 whitespace-pre-wrap border-t border-surface-border pt-3">
|
{isOpen && (
|
||||||
{app.reason}
|
<div className="border-t border-surface-border px-4 pt-3 pb-4 space-y-4">
|
||||||
</div>
|
<p className="text-sm text-slate-300 whitespace-pre-wrap">{app.reason}</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{approveId === app.id && (
|
<div className="space-y-2">
|
||||||
<div className="px-4 pb-4 border-t border-surface-border pt-3 space-y-3">
|
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Approve <span className="text-white font-medium">{app.fundName}</span> for{' '}
|
Approve <span className="text-white font-medium">{app.fundName}</span> for{' '}
|
||||||
<span className="text-slate-200">{app.user.displayUsername ?? app.user.username}</span>
|
<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"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
value={startingBalance}
|
value={startingBalance}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setStartingBalance(e.target.value)}
|
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"
|
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
|
Confirm Approval
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -67,26 +67,43 @@ export default async function AdminOverviewPage() {
|
|||||||
Recent trades
|
Recent trades
|
||||||
</h2>
|
</h2>
|
||||||
<div className="divide-y divide-surface-border">
|
<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 key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.type.replace(/_/g, ' ')}
|
{t.type.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-300">{t.user.username}</span>
|
<span className="text-slate-300">{t.user.username}</span>
|
||||||
<span className="text-slate-500">
|
{label && <span className="text-slate-500">{label}</span>}
|
||||||
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span>{formatCurrency(t.total)}</span>
|
<span>{formatCurrency(t.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const [updatedInvestor] = await prisma.$transaction([
|
const [updatedInvestor] = await prisma.$transaction([
|
||||||
// Deduct from investor (returns updated user with new balance)
|
// 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
|
// Add to fund's cash
|
||||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }),
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }),
|
||||||
// Upsert FundInvestment record
|
// Upsert FundInvestment record
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
|
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 portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
const val = p.positionType === 'LONG'
|
const val = p.positionType === 'LONG'
|
||||||
? p.shares * p.hashtag.currentPrice
|
? p.shares * p.hashtag.currentPrice
|
||||||
@@ -61,7 +64,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const [updatedInvestor] = await prisma.$transaction([
|
const [updatedInvestor] = await prisma.$transaction([
|
||||||
// Return cash to investor
|
// 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
|
// Deduct from fund's cash
|
||||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
|
||||||
// Update or delete FundInvestment
|
// Update or delete FundInvestment
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => { setTab(t); setShares(''); setError('') }}
|
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
|
tab === t
|
||||||
? t.startsWith('BUY')
|
? t.startsWith('BUY')
|
||||||
? 'bg-emerald-600 text-white'
|
? 'bg-emerald-600 text-white'
|
||||||
@@ -93,7 +93,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
: 'text-slate-400 hover:text-slate-200'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-6
@@ -73,10 +73,11 @@ export async function getPostsPerHour(tag: string): Promise<number> {
|
|||||||
* Strategy:
|
* Strategy:
|
||||||
* - Paginate until we have at least one post older than 1 hour (a complete picture),
|
* - 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.
|
* 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
|
* - Oldest post >= 1 hour old: count posts in the last hour directly (full window).
|
||||||
* last hour (direct measurement over a full window).
|
* - Hit the page cap (burst): more posts exist beyond what we fetched — extrapolate from
|
||||||
* - If all fetched posts are within the last hour (hit page limit or timeline exhausted
|
* the covered span (postsPerHour = count / coveredHours).
|
||||||
* with a narrow window): extrapolate — 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(
|
export async function getPostsData(
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -89,6 +90,7 @@ export async function getPostsData(
|
|||||||
|
|
||||||
let allPosts: MastodonPost[] = []
|
let allPosts: MastodonPost[] = []
|
||||||
let maxId: string | undefined
|
let maxId: string | undefined
|
||||||
|
let hitPageCap = false
|
||||||
|
|
||||||
for (let page = 0; page < maxPages; page++) {
|
for (let page = 0; page < maxPages; page++) {
|
||||||
const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
|
const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
|
||||||
@@ -104,6 +106,9 @@ export async function getPostsData(
|
|||||||
if (oldestInBatch < cutoff) break
|
if (oldestInBatch < cutoff) break
|
||||||
|
|
||||||
maxId = nextMaxId
|
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 }
|
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false }
|
||||||
@@ -116,11 +121,15 @@ export async function getPostsData(
|
|||||||
if (oldestMs < cutoff) {
|
if (oldestMs < cutoff) {
|
||||||
// We reached (or passed) the 1-hour horizon — count posts within the last hour directly
|
// 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
|
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
|
||||||
} else {
|
} else if (hitPageCap) {
|
||||||
// All posts are within the last hour (burst scenario or very sparse tag).
|
// 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.
|
// Extrapolate from the covered span. Minimum 1-minute span to avoid divide-by-zero.
|
||||||
const coveredMs = Math.max(newestMs - oldestMs, 60_000)
|
const coveredMs = Math.max(newestMs - oldestMs, 60_000)
|
||||||
postsPerHour = allPosts.length / (coveredMs / ONE_HOUR_MS)
|
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)
|
// Count co-occurring tags from the API tags object (authoritative for membership)
|
||||||
|
|||||||
Reference in New Issue
Block a user