feat: implement balance tier system and update research points allocation in maintenance worker
This commit is contained in:
+34
-21
@@ -5,8 +5,7 @@ import { Loader2, Dices, CheckCircle2 } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const GRID_SIZE = 25
|
||||
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
|
||||
|
||||
type PickResult = {
|
||||
box: number
|
||||
@@ -86,11 +85,24 @@ export default function LotteryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const prizeLabels: Record<number, string> = {
|
||||
200: '🏆 $200',
|
||||
50: '🥈 $50',
|
||||
10: '🎁 $10',
|
||||
}
|
||||
const PRIZE_EMOJIS = ['🏆', '🥈', '🎁', '✨', '⭐']
|
||||
const uniquePrizes = [...new Set(Object.values(PRIZE_MAP))].filter(v => v > 0).sort((a, b) => b - a)
|
||||
const prizeCounts = uniquePrizes.reduce<Record<number, number>>((acc, amt) => {
|
||||
acc[amt] = Object.values(PRIZE_MAP).filter(v => v === amt).length
|
||||
return acc
|
||||
}, {})
|
||||
const prizeBoxCount = Object.values(PRIZE_MAP).filter(v => v > 0).length
|
||||
const emptyBoxCount = GRID_SIZE - prizeBoxCount
|
||||
const TIER_COLORS = [
|
||||
{ bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400' },
|
||||
{ bg: 'bg-slate-500/10', border: 'border-slate-500/20', text: 'text-slate-300' },
|
||||
{ bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400' },
|
||||
{ bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-400' },
|
||||
{ bg: 'bg-pink-500/10', border: 'border-pink-500/20', text: 'text-pink-400' },
|
||||
]
|
||||
const prizeLabels: Record<number, string> = Object.fromEntries(
|
||||
uniquePrizes.map((amt, i) => [amt, `${PRIZE_EMOJIS[i] ?? '🎁'} ${formatCurrency(amt)}`])
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
@@ -107,21 +119,22 @@ export default function LotteryPage() {
|
||||
{/* Prize table */}
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm text-center">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-amber-400">$200</p>
|
||||
<p className="text-xs text-slate-500">×1 box</p>
|
||||
</div>
|
||||
<div className="bg-slate-500/10 border border-slate-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-slate-300">$50</p>
|
||||
<p className="text-xs text-slate-500">×2 boxes</p>
|
||||
</div>
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-emerald-400">$10</p>
|
||||
<p className="text-xs text-slate-500">×3 boxes</p>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-2 text-sm text-center"
|
||||
style={{ gridTemplateColumns: `repeat(${uniquePrizes.length}, 1fr)` }}
|
||||
>
|
||||
{uniquePrizes.map((amt, i) => {
|
||||
const color = TIER_COLORS[i] ?? TIER_COLORS[TIER_COLORS.length - 1]
|
||||
const count = prizeCounts[amt]
|
||||
return (
|
||||
<div key={amt} className={`${color.bg} border ${color.border} rounded-lg p-2`}>
|
||||
<p className={`font-bold ${color.text}`}>{formatCurrency(amt)}</p>
|
||||
<p className="text-xs text-slate-500">×{count} box{count !== 1 ? 'es' : ''}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-2 text-center">Remaining 19 boxes: $0</p>
|
||||
<p className="text-xs text-slate-600 mt-2 text-center">All remaining {emptyBoxCount} boxes: $0</p>
|
||||
</div>
|
||||
|
||||
{/* Result banner */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
|
||||
import { getBalanceTier } from '@/lib/pricing'
|
||||
import Link from 'next/link'
|
||||
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
@@ -74,9 +75,28 @@ export default async function ProfilePage({ params }: Props) {
|
||||
<p className="text-slate-500 text-sm">@{user.username}</p>
|
||||
)}
|
||||
{isOwn && (
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
|
||||
</p>
|
||||
<div className="mt-1 text-sm text-slate-400 space-y-0.5">
|
||||
{(() => {
|
||||
const tier = getBalanceTier(user.balance)
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-slate-300 font-medium">Tier {tier.level}</span>
|
||||
<span className="text-slate-600 mx-1.5">·</span>
|
||||
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
|
||||
{tier.nextThreshold && (
|
||||
<span className="text-slate-600 text-xs ml-1.5">
|
||||
(next tier at {formatCurrency(tier.nextThreshold)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -25,6 +25,20 @@ export function dailyResearchPoints(balance: number): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
export interface BalanceTier {
|
||||
level: number
|
||||
pointsPerDay: number
|
||||
nextThreshold: number | null
|
||||
}
|
||||
|
||||
/** Returns the tier info for a given balance. */
|
||||
export function getBalanceTier(balance: number): BalanceTier {
|
||||
if (balance >= 1_000_000) return { level: 4, pointsPerDay: 5, nextThreshold: null }
|
||||
if (balance >= 100_000) return { level: 3, pointsPerDay: 3, nextThreshold: 1_000_000 }
|
||||
if (balance >= 10_000) return { level: 2, pointsPerDay: 2, nextThreshold: 100_000 }
|
||||
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cost/proceeds and realized P&L for a trade.
|
||||
*
|
||||
|
||||
+9
-5
@@ -170,13 +170,17 @@ const maintenanceWorker = new Worker(
|
||||
async (job) => {
|
||||
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
|
||||
|
||||
const users = await prisma.user.findMany({ select: { id: true, balance: true } })
|
||||
const MAX_RESEARCH_POINTS = 10
|
||||
const users = await prisma.user.findMany({ select: { id: true, balance: true, researchPoints: true } })
|
||||
for (const user of users) {
|
||||
const points = dailyResearchPoints(user.balance)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { researchPoints: { increment: points } },
|
||||
})
|
||||
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
|
||||
if (newTotal !== user.researchPoints) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { researchPoints: newTotal },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[maintenance] awarded research points to ${users.length} users`)
|
||||
|
||||
Reference in New Issue
Block a user