Compare commits
2 Commits
8783fbf459
...
ea1dca974c
| Author | SHA1 | Date | |
|---|---|---|---|
| ea1dca974c | |||
| bf14b039c6 |
@@ -359,9 +359,8 @@ The items below are planned improvements roughly ordered by user value. They are
|
|||||||
|
|
||||||
### Other Ideas / Nice-to-Haves
|
### Other Ideas / Nice-to-Haves
|
||||||
|
|
||||||
- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades.
|
|
||||||
- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
|
- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
|
||||||
- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances.
|
- **Multi-instance support**: fallback to another instance if the primary instance is unavailable or throttles API calls.
|
||||||
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
|
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
|
||||||
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
|
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
|
||||||
- **Dark/light theme toggle**: currently dark-only.
|
- **Dark/light theme toggle**: currently dark-only.
|
||||||
|
|||||||
@@ -203,4 +203,7 @@ enum TradeType {
|
|||||||
LOTTERY_WIN
|
LOTTERY_WIN
|
||||||
LIQUIDATE_LONG
|
LIQUIDATE_LONG
|
||||||
LIQUIDATE_SHORT
|
LIQUIDATE_SHORT
|
||||||
|
DONATION // keepHistory reset: user was in the green — donated their portfolio
|
||||||
|
BANKRUPTCY // keepHistory reset: user was in the red — debts cleared
|
||||||
|
ACCOUNT_OPEN // keepHistory reset: new $2000 account opening entry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
const [deleteError, setDeleteError] = useState('')
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
const [accountResetConfirm, setAccountResetConfirm] = useState('')
|
||||||
|
const [accountResetError, setAccountResetError] = useState('')
|
||||||
|
const [accountResetDone, setAccountResetDone] = useState(false)
|
||||||
|
const [resetKeepHistory, setResetKeepHistory] = useState(false)
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -100,10 +104,33 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAccountReset() {
|
||||||
|
if (accountResetConfirm !== user.username) {
|
||||||
|
setAccountResetError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setAccountResetError('')
|
||||||
|
const res = await fetch(`/api/admin/users/${user.id}/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepHistory: resetKeepHistory }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) {
|
||||||
|
setAccountResetError(data.error ?? 'Reset failed.')
|
||||||
|
} else {
|
||||||
|
setAccountResetDone(true)
|
||||||
|
setAccountResetConfirm('')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpen(true); setResetUrl(null); setError(''); setDeleteConfirm(''); setDeleteError('') }}
|
onClick={() => { setOpen(true); setResetUrl(null); setError(''); setDeleteConfirm(''); setDeleteError(''); setAccountResetConfirm(''); setAccountResetError(''); setAccountResetDone(false); setResetKeepHistory(false) }}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -219,6 +246,57 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Account reset */}
|
||||||
|
<div className="border-t border-amber-500/20 pt-4">
|
||||||
|
<p className="text-sm text-amber-400 mb-1 font-medium">Reset account</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{resetKeepHistory
|
||||||
|
? 'Keeps trade history and adds DONATION/BANKRUPTCY + ACCOUNT OPEN bookmark entries. Balance resets to $2,000.'
|
||||||
|
: 'Permanently erases all trade history, positions, and fund investments, then resets the balance to $2,000.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Keep trade history</p>
|
||||||
|
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetKeepHistory((v) => !v)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
resetKeepHistory ? 'bg-amber-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
resetKeepHistory ? 'translate-x-5' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{accountResetDone ? (
|
||||||
|
<p className="text-xs text-emerald-400">✓ Account has been reset.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accountResetConfirm}
|
||||||
|
onChange={(e) => setAccountResetConfirm(e.target.value)}
|
||||||
|
placeholder={`Type "${user.username}" to confirm`}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
{accountResetError && (
|
||||||
|
<p className="text-red-400 text-xs">{accountResetError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleAccountReset}
|
||||||
|
disabled={loading || accountResetConfirm !== user.username}
|
||||||
|
className="text-sm bg-amber-700/30 hover:bg-amber-700/50 text-amber-400 border border-amber-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting…' : 'Reset account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Danger zone — delete user */}
|
{/* Danger zone — delete user */}
|
||||||
<div className="border-t border-red-500/20 pt-4">
|
<div className="border-t border-red-500/20 pt-4">
|
||||||
<p className="text-sm text-red-400 mb-1 font-medium">Danger zone</p>
|
<p className="text-sm text-red-400 mb-1 font-medium">Danger zone</p>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const STARTING_BALANCE = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/users/[userId]/reset
|
||||||
|
* Body: { keepHistory?: boolean }
|
||||||
|
*
|
||||||
|
* Admin-only. Resets a user's account. See /api/user/me/reset for full docs.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } },
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const keepHistory = body.keepHistory === true
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: params.userId },
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioValue = user.positions.reduce((sum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const totalValue = user.balance + portfolioValue
|
||||||
|
|
||||||
|
// Forfeit all fund investments — decrement each fund's sharesOutstanding
|
||||||
|
const fundUpdates = user.fundInvestments
|
||||||
|
.filter((inv) => inv.shares > 0)
|
||||||
|
.map((inv) =>
|
||||||
|
prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tradeOps = keepHistory
|
||||||
|
? [
|
||||||
|
totalValue >= STARTING_BALANCE
|
||||||
|
? prisma.trade.create({
|
||||||
|
data: { userId: params.userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
|
||||||
|
})
|
||||||
|
: prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId: params.userId,
|
||||||
|
type: 'BANKRUPTCY',
|
||||||
|
shares: 0,
|
||||||
|
price: 0,
|
||||||
|
total: STARTING_BALANCE - totalValue,
|
||||||
|
profit: STARTING_BALANCE - totalValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: params.userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [prisma.trade.deleteMany({ where: { userId: params.userId } })]
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...fundUpdates,
|
||||||
|
prisma.fundInvestment.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.position.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.userPortfolioHistory.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.user.update({ where: { id: params.userId }, data: { balance: STARTING_BALANCE } }),
|
||||||
|
...tradeOps,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
const STARTING_BALANCE = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/me/reset
|
||||||
|
* Body: { keepHistory?: boolean }
|
||||||
|
*
|
||||||
|
* Resets the current user's account. Deletes positions, fund investments, and
|
||||||
|
* portfolio history. When keepHistory is true the existing trade log is
|
||||||
|
* preserved and two bookmark entries are appended:
|
||||||
|
* • DONATION — user was in the green (totalValue ≥ $2k): records the value donated
|
||||||
|
* • BANKRUPTCY — user was in the red (totalValue < $2k): records the debt cleared
|
||||||
|
* • ACCOUNT_OPEN — always appended, records the fresh $2k grant
|
||||||
|
* When keepHistory is false all trades are also deleted.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const keepHistory = body.keepHistory === true
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioValue = user.positions.reduce((sum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const totalValue = user.balance + portfolioValue
|
||||||
|
|
||||||
|
// Forfeit all fund investments — decrement each fund's sharesOutstanding
|
||||||
|
const fundUpdates = user.fundInvestments
|
||||||
|
.filter((inv) => inv.shares > 0)
|
||||||
|
.map((inv) =>
|
||||||
|
prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tradeOps = keepHistory
|
||||||
|
? [
|
||||||
|
totalValue >= STARTING_BALANCE
|
||||||
|
? prisma.trade.create({
|
||||||
|
data: { userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
|
||||||
|
})
|
||||||
|
: prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'BANKRUPTCY',
|
||||||
|
shares: 0,
|
||||||
|
price: 0,
|
||||||
|
total: STARTING_BALANCE - totalValue,
|
||||||
|
profit: STARTING_BALANCE - totalValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [prisma.trade.deleteMany({ where: { userId } })]
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...fundUpdates,
|
||||||
|
prisma.fundInvestment.deleteMany({ where: { userId } }),
|
||||||
|
prisma.position.deleteMany({ where: { userId } }),
|
||||||
|
prisma.userPortfolioHistory.deleteMany({ where: { userId } }),
|
||||||
|
prisma.user.update({ where: { id: userId }, data: { balance: STARTING_BALANCE } }),
|
||||||
|
...tradeOps,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
{trades.map((t) => {
|
{trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
|
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -69,6 +70,10 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
? 'bg-orange-500/15 text-orange-400'
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
: isLottery
|
: isLottery
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: t.type === 'DONATION'
|
||||||
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
|
: t.type === 'ACCOUNT_OPEN'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: t.type.startsWith('BUY')
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
@@ -79,6 +84,14 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
<div>
|
<div>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
<span className="text-amber-300">Lucky Dip</span>
|
<span className="text-amber-300">Lucky Dip</span>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{t.type === 'DONATION'
|
||||||
|
? 'Account reset — donated'
|
||||||
|
: t.type === 'BANKRUPTCY'
|
||||||
|
? 'Bankruptcy declared'
|
||||||
|
: 'Account opened'}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
@@ -93,8 +106,13 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{isLottery ? (
|
{isLottery || t.type === 'ACCOUNT_OPEN' ? (
|
||||||
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetAccountForm({ username }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keepHistory, setKeepHistory] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
async function handleReset(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (confirm !== username) {
|
||||||
|
setError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/me/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepHistory }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Reset failed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDone(true)
|
||||||
|
setOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-surface-card border border-amber-500/20 rounded-xl p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen((v) => !v); setError(''); setConfirm(''); setDone(false) }}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Reset account
|
||||||
|
{done && <span className="text-emerald-400 text-xs ml-1">✓ Reset</span>}
|
||||||
|
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleReset} className="mt-4 space-y-4 max-w-sm">
|
||||||
|
{keepHistory ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
All positions and fund investments are closed. Your trade history is kept
|
||||||
|
and a <span className="text-purple-400 font-medium">DONATION</span> or{' '}
|
||||||
|
<span className="text-red-400 font-medium">BANKRUPTCY</span> entry marks the
|
||||||
|
reset, followed by an{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">ACCOUNT OPEN</span>. Balance
|
||||||
|
resets to <span className="text-white font-medium">$2,000</span>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
This will <span className="text-amber-400 font-medium">permanently erase</span> your entire
|
||||||
|
trade history, all positions, and fund investments, then reset your cash balance back to{' '}
|
||||||
|
<span className="text-white font-medium">$2,000</span>. A true clean slate.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keep history toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-300">Keep trade history</p>
|
||||||
|
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeepHistory((v) => !v)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
keepHistory ? 'bg-amber-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
keepHistory ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Type <span className="text-white font-mono">{username}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder={username}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || confirm !== username}
|
||||||
|
className="flex items-center gap-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{loading ? 'Resetting…' : 'Reset my account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
|||||||
import ChangePasswordForm from './ChangePasswordForm'
|
import ChangePasswordForm from './ChangePasswordForm'
|
||||||
import AccountSettingsForm from './AccountSettingsForm'
|
import AccountSettingsForm from './AccountSettingsForm'
|
||||||
import CloseAccountForm from './CloseAccountForm'
|
import CloseAccountForm from './CloseAccountForm'
|
||||||
|
import ResetAccountForm from './ResetAccountForm'
|
||||||
import { PriceChart } from '@/components/PriceChart'
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -275,6 +276,7 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
{user.trades.map((t) => {
|
{user.trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
|
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
|
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
|
||||||
{/* Primary row: badge · hashtag/label · total */}
|
{/* Primary row: badge · hashtag/label · total */}
|
||||||
@@ -285,6 +287,10 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
? 'bg-orange-500/15 text-orange-400'
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
: isLottery
|
: isLottery
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: t.type === 'DONATION'
|
||||||
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
|
: t.type === 'ACCOUNT_OPEN'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: t.type.startsWith('BUY')
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
@@ -294,6 +300,14 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
<span className="text-amber-300 font-medium flex-1 min-w-0">Lucky Dip</span>
|
<span className="text-amber-300 font-medium flex-1 min-w-0">Lucky Dip</span>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<span className="text-slate-300 font-medium flex-1 min-w-0">
|
||||||
|
{t.type === 'DONATION'
|
||||||
|
? 'Account reset — donated'
|
||||||
|
: t.type === 'BANKRUPTCY'
|
||||||
|
? 'Bankruptcy declared'
|
||||||
|
: 'Account opened'}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
@@ -309,12 +323,12 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
{/* Secondary row: time (left) · shares @ price (right) */}
|
{/* Secondary row: time (left) · shares @ price (right) */}
|
||||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
<span>{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
|
<span>{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
|
||||||
{!isLottery && (
|
{!isLottery && !isSystemReset && (
|
||||||
<span className="tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
<span className="tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* PnL: sell and liquidation trades */}
|
{/* PnL: sell, liquidation, and reset trades */}
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation || t.type === 'DONATION' || t.type === 'BANKRUPTCY') && (
|
||||||
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +347,7 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
currentDisplayUsername={user.displayUsername ?? null}
|
currentDisplayUsername={user.displayUsername ?? null}
|
||||||
/>
|
/>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<ResetAccountForm username={user.username} />
|
||||||
<CloseAccountForm username={user.username} />
|
<CloseAccountForm username={user.username} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user