pw change, leaderboard, lotto, related ht, admin ban, add, and display changes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s

This commit is contained in:
2026-03-18 17:54:02 -04:00
parent 50ab9b38ac
commit 355a4b1b32
25 changed files with 1442 additions and 45 deletions
+119
View File
@@ -320,3 +320,122 @@ npm run db:migrate # Create and apply a migration
npm run db:seed # Seed the admin user
npm run db:studio # Open Prisma Studio (GUI)
```
---
## Feature Roadmap
The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase.
---
### 1. Username Display Names & Password Changes on Profile
**Problem:** Usernames are forced lowercase on registration (some users want "JohnDoe" not "johndoe"). Users also have no self-service way to change their password.
**Plan:**
- Add a `displayUsername` field to `User` — stores the original mixed-case version typed at signup. Lookups/URLs continue using the lowercase `username` for uniqueness and consistency.
- Profile settings page (`/profile/[username]/settings` or a tab on the existing profile) with:
- **Change display name** — updates `displayUsername` only; the canonical `username` remains lowercase and immutable (avoids breaking existing links/positions).
- **Change username** — updates both `username` and `displayUsername`; requires confirming no conflicts; all foreign key relations in Prisma use the DB `id` so no cascade issues.
- **Change password** — classic current-password + new-password + confirm form; server endpoint `PATCH /api/user/me` validating current hash before updating.
- All places that show a username (Navbar, profile page, leaderboard, trade history) should render `displayUsername` if set, falling back to `username`.
---
### 2. Leaderboard
**Problem:** No public way to see who the top players are or browse other users' portfolios.
**Plan:**
- New page `/leaderboard` showing top N players sorted by **net worth** (balance + sum of position value at current prices).
- Table columns: rank, avatar/username link → their public profile, net worth, total trades, biggest single position.
- Public profiles — any user can view `/profile/[username]`; sensitive data (exact balance) optionally hidden from non-owners/non-admins.
- Navbar: show **Leaderboard** link when signed in (replacing or supplementing the current unauthenticated-only links).
- Consider a separate "all-time best trade" leaderboard tab for engagement.
---
### 3. Lucky Dip Lottery
**Problem:** Players who go broke have no recovery mechanic; there is no daily engagement hook beyond trading.
**Plan:**
- New page `/lottery`.
- Once per day (tracked by a `lastLotteryAt` timestamp on `User`), a player who has **$0 or less** can reveal one hidden box from a grid (e.g. 5 × 5 = 25 boxes).
- One box is the winner (selected server-side at draw time, stored encrypted or revealed only on pick to prevent client-side cheating).
- **Prizes** (configurable): e.g. one box pays $100, a few pay $10, the rest pay $0.
- API: `POST /api/lottery/pick` — validates cooldown, selects winner server-side, awards balance, returns outcome.
- Consider allowing players with any balance to play for a small stake (e.g. pay $5 to play, win up to $200) to make it useful beyond the broke scenario.
- Future: admin-configurable prize pool and grid size.
---
### 4. Home Page Nav Changes (Signed-In vs Signed-Out)
**Problem:** Home page shows the same call-to-action links regardless of auth state.
**Plan:**
- When **signed out**: show Sign Up / Sign In CTAs as currently.
- When **signed in**: replace or supplement with quick links to **Leaderboard**, **Lottery**, and the user's own profile.
- Navbar already hides/shows some items; extend that logic to the hero section of the home page.
---
### 5. Related Hashtags
**Problem:** No discovery path from one hashtag to related ones.
**Plan:**
- During price update jobs the worker already fetches up to 200 posts. Parse each post's `tags` array (present in the Mastodon API response) to collect co-occurring hashtags.
- Store co-occurrence counts in a new `RelatedHashtag` table: `(hashtagId, relatedTag, coOccurrences)` — upsert on each price update, incrementing counts.
- On the hashtag detail page, show the top 5 most co-occurring tags as "Related" chips that link to their own pages.
- Only surface related tags that are themselves active (or researchable) — avoid surfacing banned/inactive noise.
- Worker change: `MastodonPost` type already has `content`; add `tags: { name: string }[]` to the interface and parse it in `mastodon.ts`.
---
### 6. Admin: Price Audit Log
**Problem:** Admins have no visibility into the math behind a given price update.
**Plan:**
- The `PriceHistory` table already stores `postsPerHour` alongside `price`. Surface this in two places:
1. **Hashtag detail page** — show the most recent `postsPerHour` reading and the formula result as a tooltip or sub-line under the current price (e.g. _"$1.49 — 5.96 posts/hr at last update"_).
2. **Admin stocks page** — add a "History" drawer/modal per hashtag showing the last N `PriceHistory` rows as a table: `recordedAt | postsPerHour | price | Δ price` so admins can see the full audit trail.
- No schema changes needed — `postsPerHour` is already stored.
---
### 7. Admin: Add Hashtag Manually
**Problem:** Admins cannot bypass the research-point system to seed the exchange with interesting hashtags.
**Plan:**
- "Add hashtag" button on `/admin/stocks` that opens a modal with a tag input.
- API: `POST /api/admin/stocks` — normalizes tag, queries Mastodon immediately (same as research flow), upserts hashtag, queues an initial price-update job. Returns error if Mastodon returns nothing.
- Optionally allow admin to force-add with a manual starting price even if Mastodon has no results (useful for bootstrapping/testing).
---
### 8. Admin: Ban / Block Hashtags
**Problem:** No way to prevent abusive or NSFW hashtags from being researched into the exchange.
**Plan:**
- Add `isBanned: Boolean @default(false)` field to the `Hashtag` model.
- Admin UI: "Ban" button on the stocks page that sets `isBanned = true` and `isActive = false`. Banned hashtags still exist in the DB for record-keeping but are not researchable.
- Research API (`/api/research`): check `isBanned` on any existing matching hashtag — if banned return a `403` with a user-friendly message (e.g. _"This hashtag is not available on HashEx."_) without spending a research point.
- New pre-ban blocklist: a static list (or admin-editable table) of tags that are auto-banned on first research attempt, before any Mastodon query.
- Banned tags should still appear in the admin stocks list with a distinct visual indicator and an "Unban" toggle.
---
### 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.
- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances.
- **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.
- **Dark/light theme toggle**: currently dark-only.
+20 -1
View File
@@ -9,11 +9,13 @@ datasource db {
model User {
id String @id @default(cuid())
username String @unique
username String @unique // lowercase, used for URLs/lookups
displayUsername String? // original casing chosen by user
passwordHash String
balance Float @default(2000)
researchPoints Int @default(1)
isAdmin Boolean @default(false)
lastLotteryAt DateTime? // tracks daily lottery cooldown
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -40,6 +42,7 @@ model Hashtag {
displayTag String // original case as entered
currentPrice Float @default(0.25)
isActive Boolean @default(true)
isBanned Boolean @default(false)
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
zeroCount Int @default(0)
lastUpdated DateTime @default(now())
@@ -48,10 +51,26 @@ model Hashtag {
priceHistory PriceHistory[]
positions Position[]
trades Trade[]
relatedFrom RelatedHashtag[] @relation("RelatedFrom")
relatedTo RelatedHashtag[] @relation("RelatedTo")
@@index([isActive, lastUpdated])
}
model RelatedHashtag {
id String @id @default(cuid())
hashtagId String
hashtag Hashtag @relation("RelatedFrom", fields: [hashtagId], references: [id], onDelete: Cascade)
relatedTag String // lowercase tag name (may not yet be in Hashtag table)
relatedId String? // set if the related hashtag exists in the DB
related Hashtag? @relation("RelatedTo", fields: [relatedId], references: [id], onDelete: SetNull)
coOccurrences Int @default(1)
updatedAt DateTime @updatedAt
@@unique([hashtagId, relatedTag])
@@index([hashtagId, coOccurrences])
}
model PriceHistory {
id String @id @default(cuid())
hashtagId String
+127
View File
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Loader2 } from 'lucide-react'
export function AdminAddHashtag() {
const router = useRouter()
const [open, setOpen] = useState(false)
const [tag, setTag] = useState('')
const [forcePrice, setForcePrice] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
async function handleAdd() {
setLoading(true)
setError('')
setMessage('')
const body: { tag: string; forcePrice?: number } = { tag }
if (forcePrice && parseFloat(forcePrice) > 0) {
body.forcePrice = parseFloat(forcePrice)
}
const res = await fetch('/api/admin/stocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Failed to add hashtag.')
} else {
setMessage(data.alreadyActive ? `#${tag} is already active.` : `#${tag} added successfully!`)
setTag('')
setForcePrice('')
router.refresh()
setTimeout(() => { setOpen(false); setMessage('') }, 1500)
}
}
return (
<>
<button
onClick={() => { setOpen(true); setError(''); setMessage('') }}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add hashtag
</button>
{open && (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
onClick={() => setOpen(false)}
>
<div
className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-sm space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-lg">Add hashtag</h3>
<p className="text-sm text-slate-400">
Queries Mastodon and sets the price automatically. Provide an override price if you
want to add a tag with no recent activity.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-400 mb-1">Hashtag</label>
<input
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="photography"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">
Override price (optional)
</label>
<input
type="number"
step="0.01"
min="0.01"
value={forcePrice}
onChange={(e) => setForcePrice(e.target.value)}
placeholder="e.g. 1.00"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{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>
)}
{message && (
<p className="text-emerald-400 text-sm">{message}</p>
)}
<div className="flex justify-end gap-3">
<button
onClick={() => setOpen(false)}
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={loading || !tag.trim()}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Add
</button>
</div>
</div>
</div>
)}
</>
)
}
+14 -1
View File
@@ -9,6 +9,7 @@ interface HashtagData {
displayTag: string
currentPrice: number
isActive: boolean
isBanned: boolean
zeroCount: number
}
@@ -17,6 +18,7 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) {
const [open, setOpen] = useState(false)
const [price, setPrice] = useState(String(hashtag.currentPrice))
const [isActive, setIsActive] = useState(hashtag.isActive)
const [isBanned, setIsBanned] = useState(hashtag.isBanned)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -26,7 +28,7 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) {
const res = await fetch(`/api/admin/stocks/${hashtag.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPrice: parseFloat(price), isActive }),
body: JSON.stringify({ currentPrice: parseFloat(price), isActive, isBanned }),
})
const data = await res.json()
setLoading(false)
@@ -90,6 +92,17 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) {
<label htmlFor="isActive" className="text-sm text-slate-300">Active</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="isBanned"
checked={isBanned}
onChange={(e) => setIsBanned(e.target.checked)}
className="rounded"
/>
<label htmlFor="isBanned" className="text-sm text-red-400">Banned (blocks research + deactivates)</label>
</div>
<button
onClick={handleForceUpdate}
disabled={loading}
+10
View File
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { AdminStockActions } from './AdminStockActions'
import { AdminAddHashtag } from './AdminAddHashtag'
interface Props {
searchParams: { q?: string; page?: string; filter?: string }
@@ -40,6 +41,8 @@ export default async function AdminStocksPage({ searchParams }: Props) {
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-lg font-semibold">Stocks ({total})</h2>
<div className="flex gap-2 flex-wrap items-center">
<AdminAddHashtag />
<form className="flex gap-2 flex-wrap">
<input
name="q"
@@ -64,6 +67,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
</button>
</form>
</div>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
@@ -87,6 +91,11 @@ export default async function AdminStocksPage({ searchParams }: Props) {
</td>
<td className="px-4 py-3 font-medium">{formatCurrency(h.currentPrice)}</td>
<td className="px-4 py-3 hidden md:table-cell">
{h.isBanned ? (
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/15 text-orange-400">
banned
</span>
) : (
<span
className={`text-xs px-2 py-0.5 rounded ${
h.isActive
@@ -96,6 +105,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
>
{h.isActive ? 'active' : 'inactive'}
</span>
)}
</td>
<td className="px-4 py-3 hidden md:table-cell">{h._count.positions}</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-400">
@@ -7,6 +7,7 @@ import { z } from 'zod'
const schema = z.object({
currentPrice: z.number().min(0.01).optional(),
isActive: z.boolean().optional(),
isBanned: z.boolean().optional(),
})
export async function PATCH(req: NextRequest, { params }: { params: { hashtagId: string } }) {
@@ -25,6 +26,8 @@ export async function PATCH(req: NextRequest, { params }: { params: { hashtagId:
where: { id: params.hashtagId },
data: {
...parsed.data,
// Banning also deactivates
...(parsed.data.isBanned === true ? { isActive: false } : {}),
// Reset zero count if manually re-activating
...(parsed.data.isActive === true ? { zeroCount: 0, lastUpdated: new Date() } : {}),
},
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getPostsPerHour } from '@/lib/mastodon'
import { calcPrice } from '@/lib/pricing'
import { normalizeTag } from '@/lib/utils'
import { priceUpdateQueue } from '@/lib/queue'
/**
* POST /api/admin/stocks
* Body: { tag: string, forcePrice?: number }
*
* Admin-only: add a hashtag to the exchange, bypassing the research-point system.
* If forcePrice is provided, uses that price regardless of Mastodon results.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json().catch(() => null)
const raw: string = body?.tag ?? ''
const tag = normalizeTag(raw)
const forcePrice: number | undefined =
typeof body?.forcePrice === 'number' && body.forcePrice > 0 ? body.forcePrice : undefined
if (!tag || tag.length < 2 || tag.length > 100) {
return NextResponse.json({ error: 'Invalid hashtag.' }, { status: 400 })
}
// Already exists and active?
const existing = await prisma.hashtag.findUnique({ where: { tag } })
if (existing?.isActive) {
return NextResponse.json({ ok: true, hashtagId: existing.id, alreadyActive: true })
}
let price = forcePrice
if (!price) {
let postsPerHour = 0
try {
postsPerHour = await getPostsPerHour(tag)
} catch (err) {
console.error('[admin/stocks] Mastodon fetch failed:', err)
return NextResponse.json(
{ error: 'Could not reach Mastodon. Use forcePrice to add without a live reading.' },
{ status: 502 },
)
}
if (postsPerHour === 0 && !forcePrice) {
return NextResponse.json(
{ error: 'No recent Mastodon activity found. Pass forcePrice to add anyway.' },
{ status: 404 },
)
}
price = calcPrice(postsPerHour)
}
const displayTag = raw.replace(/^#+/, '') || tag
const hashtag = await prisma.hashtag.upsert({
where: { tag },
create: {
tag,
displayTag,
currentPrice: price,
isActive: true,
zeroCount: 0,
},
update: {
isActive: true,
isBanned: false,
currentPrice: price,
zeroCount: 0,
lastUpdated: new Date(),
},
})
await prisma.priceHistory.create({
data: { hashtagId: hashtag.id, price, postsPerHour: 0 },
})
await priceUpdateQueue.add(
'update-price',
{ hashtagId: hashtag.id, tag: hashtag.tag },
{ priority: 1 },
)
return NextResponse.json({ ok: true, hashtagId: hashtag.id })
}
+2 -1
View File
@@ -12,6 +12,7 @@ export async function POST(req: NextRequest) {
}
const username: string = body.username.toLowerCase().trim()
const displayUsername: string = (body.username as string).trim()
const password: string = body.password
if (!USERNAME_RE.test(username)) {
@@ -36,7 +37,7 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12)
await prisma.user.create({
data: { username, passwordHash },
data: { username, displayUsername, passwordHash },
})
return NextResponse.json({ ok: true }, { status: 201 })
+101
View File
@@ -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'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
function buildPrizes(): number[] {
const prizes = Array(GRID_SIZE).fill(0)
for (const [index, amount] of Object.entries(PRIZE_MAP)) {
prizes[Number(index)] = amount
}
// Shuffle prizes into random positions using Fisher-Yates (seeded per-pick, not pre-stored)
return prizes
}
function isSameDay(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
)
}
/**
* POST /api/lottery/pick
* Body: { box: number } (0-indexed, 024)
*
* One free play per calendar day (UTC). Reveals prize at the chosen box.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json().catch(() => null)
const box: number = body?.box ?? -1
if (!Number.isInteger(box) || box < 0 || box >= GRID_SIZE) {
return NextResponse.json({ error: 'Invalid box selection.' }, { status: 400 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, balance: true, lastLotteryAt: true },
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
const now = new Date()
if (user.lastLotteryAt && isSameDay(user.lastLotteryAt, now)) {
return NextResponse.json(
{ error: 'You have already played today. Come back tomorrow!' },
{ status: 400 },
)
}
// Build a fresh shuffled prize array for this pick.
// We shuffle it here (server-side, never exposed until after pick) and reveal all after.
const prizes = buildPrizes()
// Fisher-Yates shuffle using Math.random (cryptographically sufficient for a game)
for (let i = prizes.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[prizes[i], prizes[j]] = [prizes[j], prizes[i]]
}
const winAmount = prizes[box]
await prisma.user.update({
where: { id: user.id },
data: {
balance: { increment: winAmount },
lastLotteryAt: now,
},
})
return NextResponse.json({
box,
prize: winAmount,
prizes, // reveal the full board after pick
newBalance: user.balance + winAmount,
})
}
/**
* GET /api/lottery/pick — returns whether the user can play today
*/
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { lastLotteryAt: true },
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
const now = new Date()
const canPlay = !user.lastLotteryAt || !isSameDay(user.lastLotteryAt, now)
return NextResponse.json({ canPlay, lastPlayedAt: user.lastLotteryAt })
}
+6
View File
@@ -37,6 +37,12 @@ export async function POST(req: NextRequest) {
// Check if already active — no need to spend a point
const existing = await prisma.hashtag.findUnique({ where: { tag } })
if (existing?.isBanned) {
return NextResponse.json(
{ error: 'This hashtag is not available on HashEx.' },
{ status: 403 },
)
}
if (existing?.isActive) {
return NextResponse.json({ ok: true, hashtagId: existing.id, alreadyActive: true })
}
+71 -1
View File
@@ -1,8 +1,10 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase
/**
* GET /api/user/me — returns the current user's balance and research points
* Used by the Navbar balance badge.
@@ -20,3 +22,71 @@ export async function GET() {
return NextResponse.json(user)
}
/**
* PATCH /api/user/me
* Body: { displayUsername?: string, username?: string }
*
* Allows the current user to update their display name or change their username.
*/
export async function PATCH(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json().catch(() => null)
const updates: { displayUsername?: string; username?: string } = {}
// Display name update
if (typeof body?.displayUsername === 'string') {
const display = body.displayUsername.trim()
if (display.length < 3 || display.length > 20) {
return NextResponse.json({ error: 'Display name must be 320 characters.' }, { status: 400 })
}
if (!/^[a-zA-Z0-9_]+$/.test(display)) {
return NextResponse.json(
{ error: 'Display name may only contain letters, numbers, and underscores.' },
{ status: 400 },
)
}
updates.displayUsername = display
}
// Username change
if (typeof body?.username === 'string') {
const newUsername = body.username.toLowerCase().trim()
if (!USERNAME_RE.test(newUsername)) {
return NextResponse.json(
{ error: 'Username must be 320 characters: letters, numbers, underscores.' },
{ status: 400 },
)
}
const current = await prisma.user.findUnique({
where: { id: session.user.id },
select: { username: true },
})
if (current?.username === newUsername) {
return NextResponse.json({ error: 'That is already your username.' }, { status: 400 })
}
const conflict = await prisma.user.findUnique({ where: { username: newUsername } })
if (conflict) {
return NextResponse.json({ error: 'That username is already taken.' }, { status: 409 })
}
updates.username = newUsername
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'Nothing to update.' }, { status: 400 })
}
const updated = await prisma.user.update({
where: { id: session.user.id },
data: updates,
select: { username: true, displayUsername: true },
})
return NextResponse.json({ ok: true, ...updated })
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
/**
* POST /api/user/password
* Body: { currentPassword: string, newPassword: string }
*
* Allows an authenticated user to change their own password.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json().catch(() => null)
const currentPassword: string = body?.currentPassword ?? ''
const newPassword: string = body?.newPassword ?? ''
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: 'Both fields are required.' }, { status: 400 })
}
if (newPassword.length < 8) {
return NextResponse.json(
{ error: 'New password must be at least 8 characters.' },
{ status: 400 },
)
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
const valid = await bcrypt.compare(currentPassword, user.passwordHash)
if (!valid) {
return NextResponse.json({ error: 'Current password is incorrect.' }, { status: 400 })
}
const newHash = await bcrypt.hash(newPassword, 12)
await prisma.user.update({
where: { id: session.user.id },
data: { passwordHash: newHash },
})
return NextResponse.json({ ok: true })
}
+1 -1
View File
@@ -33,7 +33,7 @@ export default function SignUpPage() {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.toLowerCase().trim(), password }),
body: JSON.stringify({ username: username.trim(), password }),
})
const data = await res.json()
+28 -1
View File
@@ -6,8 +6,9 @@ import { formatCurrency, formatNumber } from '@/lib/utils'
import { PriceChart } from '@/components/PriceChart'
import { TradePanel } from './TradePanel'
import { ResearchPanel } from './ResearchPanel'
import { Hash, Clock } from 'lucide-react'
import { Hash, Clock, Link as LinkIcon } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link'
export const dynamic = 'force-dynamic'
@@ -30,6 +31,11 @@ export default async function HashtagPage({ params }: Props) {
_count: {
select: { positions: true },
},
relatedFrom: {
orderBy: { coOccurrences: 'desc' },
take: 8,
select: { relatedTag: true, coOccurrences: true },
},
},
}),
session
@@ -143,6 +149,27 @@ export default async function HashtagPage({ params }: Props) {
</div>
)}
{/* Related hashtags */}
{hashtag.relatedFrom.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-1.5">
<LinkIcon className="h-3.5 w-3.5" />
Often seen with
</h2>
<div className="flex flex-wrap gap-2">
{hashtag.relatedFrom.map((r) => (
<Link
key={r.relatedTag}
href={`/hashtag/${r.relatedTag}`}
className="bg-surface border border-surface-border hover:border-indigo-500/50 text-slate-300 hover:text-indigo-300 text-sm px-3 py-1 rounded-full transition-colors"
>
#{r.relatedTag}
</Link>
))}
</div>
</div>
)}
{/* Recent trades */}
<RecentTradesSection hashtagId={hashtag.id} />
</div>
+170
View File
@@ -0,0 +1,170 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { formatCurrency } from '@/lib/utils'
import Link from 'next/link'
import { Trophy, TrendingUp, TrendingDown } from 'lucide-react'
export const dynamic = 'force-dynamic'
async function getLeaderboard() {
// Fetch all users with their open positions (to calculate net worth)
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
displayUsername: true,
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
positionType: true,
shares: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
_count: { select: { trades: true } },
},
})
return users
.map((u) => {
const portfolioValue = u.positions.reduce((sum, p) => sum + p.shares * p.hashtag.currentPrice, 0)
const unrealizedPnl = u.positions.reduce((sum, p) => {
if (p.positionType === 'LONG') {
return sum + (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
}
return sum + (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
}, 0)
return {
id: u.id,
username: u.username,
displayUsername: u.displayUsername,
balance: u.balance,
portfolioValue,
netWorth: u.balance + portfolioValue,
unrealizedPnl,
tradeCount: u._count.trades,
}
})
.sort((a, b) => b.netWorth - a.netWorth)
.slice(0, 50)
}
export default async function LeaderboardPage() {
const [session, players] = await Promise.all([
getServerSession(authOptions),
getLeaderboard(),
])
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Trophy className="h-7 w-7 text-amber-400" />
<div>
<h1 className="text-2xl font-bold">Leaderboard</h1>
<p className="text-sm text-slate-400">Top 50 players by net worth (cash + open positions)</p>
</div>
</div>
{session && myRank > 0 && (
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
<span className="font-bold text-indigo-300">
{formatCurrency(players[myRank - 1].netWorth)}
</span>
</div>
)}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Player</span>
<span className="text-right">Net worth</span>
<span className="text-right hidden sm:block">Cash</span>
<span className="text-right hidden sm:block">Trades</span>
</div>
{players.length === 0 && (
<p className="text-center py-12 text-slate-500">No players yet.</p>
)}
<div className="divide-y divide-surface-border">
{players.map((player, i) => {
const rank = i + 1
const isMe = session?.user.id === player.id
const rankIcon =
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
key={player.id}
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
} transition-colors`}
>
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
{rankIcon ?? `#${rank}`}
</span>
<Link
href={`/profile/${player.username}`}
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
>
{player.displayUsername ?? player.username}
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
</Link>
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
<span className="text-right text-slate-400 hidden sm:block">
{formatCurrency(player.balance)}
</span>
<span className="text-right text-slate-400 hidden sm:block">
{player.tradeCount}
</span>
</div>
)
})}
</div>
</div>
{/* Unrealized P&L podium */}
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
<section>
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Top unrealized gainers (from open positions)
</h2>
<div className="grid grid-cols-3 gap-3">
{players
.slice()
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
.slice(0, 3)
.map((p) => (
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
{p.displayUsername ?? p.username}
</Link>
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{p.unrealizedPnl >= 0 ? (
<span className="flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" />
+{formatCurrency(p.unrealizedPnl)}
</span>
) : (
<span className="flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" />
{formatCurrency(p.unrealizedPnl)}
</span>
)}
</p>
</div>
))}
</div>
</section>
)}
</div>
)
}
+212
View File
@@ -0,0 +1,212 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2, Ticket, CheckCircle2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
const GRID_SIZE = 25
type PickResult = {
box: number
prize: number
prizes: number[]
newBalance: number
}
export default function LotteryPage() {
const { data: session } = useSession()
const [canPlay, setCanPlay] = useState<boolean | null>(null)
const [loading, setLoading] = useState(true)
const [picking, setPicking] = useState(false)
const [selected, setSelected] = useState<number | null>(null)
const [result, setResult] = useState<PickResult | null>(null)
const [error, setError] = useState('')
useEffect(() => {
if (!session) return
fetch('/api/lottery/pick')
.then((r) => r.json())
.then((d) => {
setCanPlay(d.canPlay)
setLoading(false)
})
.catch(() => setLoading(false))
}, [session])
async function pickBox(box: number) {
if (!canPlay || result) return
setSelected(box)
setPicking(true)
setError('')
try {
const res = await fetch('/api/lottery/pick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ box }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Something went wrong.')
setSelected(null)
} else {
setResult(data)
setCanPlay(false)
}
} catch {
setError('Network error. Please try again.')
setSelected(null)
} finally {
setPicking(false)
}
}
if (!session) {
return (
<div className="max-w-xl mx-auto text-center py-20">
<Ticket className="h-12 w-12 text-indigo-400 mx-auto mb-4" />
<p className="text-slate-400 mb-4">Sign in to play the Lucky Dip.</p>
<Link
href="/auth/signin"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Sign in
</Link>
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
)
}
const prizeLabels: Record<number, string> = {
200: '🏆 $200',
50: '🥈 $50',
10: '🎁 $10',
}
return (
<div className="max-w-xl mx-auto space-y-8">
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Ticket className="h-7 w-7 text-indigo-400" />
<h1 className="text-2xl font-bold">Lucky Dip</h1>
</div>
<p className="text-slate-400 text-sm">
One free play per day. Pick a box to reveal your prize.
</p>
</div>
{/* 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>
<p className="text-xs text-slate-600 mt-2 text-center">Remaining 19 boxes: $0</p>
</div>
{/* Result banner */}
{result && (
<div
className={`rounded-xl p-4 text-center border ${
result.prize > 0
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-surface-card border-surface-border'
}`}
>
{result.prize > 0 ? (
<>
<CheckCircle2 className="h-8 w-8 text-emerald-400 mx-auto mb-2" />
<p className="text-xl font-bold text-emerald-400">
You won {formatCurrency(result.prize)}!
</p>
<p className="text-sm text-slate-400 mt-1">
New balance: {formatCurrency(result.newBalance)}
</p>
</>
) : (
<>
<p className="text-lg font-semibold text-slate-300">No prize this time 😔</p>
<p className="text-sm text-slate-500 mt-1">Come back tomorrow for another try!</p>
</>
)}
</div>
)}
{error && <p className="text-red-400 text-sm text-center">{error}</p>}
{!canPlay && !result && (
<div className="text-center py-4 text-slate-400 text-sm">
You&apos;ve already played today. Come back tomorrow!
</div>
)}
{/* Grid */}
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: GRID_SIZE }, (_, i) => {
const isSelected = selected === i
const isRevealed = result !== null
const prize = result?.prizes[i] ?? 0
const isWinner = isRevealed && prize > 0
const isPicked = isRevealed && result?.box === i
return (
<button
key={i}
disabled={!canPlay || picking || isRevealed}
onClick={() => pickBox(i)}
className={`
aspect-square rounded-xl text-sm font-bold transition-all border
${isRevealed
? isWinner
? isPicked
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
: 'bg-emerald-500/5 border-emerald-500/10 text-emerald-600/50'
: isPicked
? 'bg-red-500/10 border-red-500/30 text-red-400/70'
: 'bg-surface border-surface-border text-slate-600'
: canPlay
? 'bg-indigo-600/20 border-indigo-500/40 hover:bg-indigo-600/40 hover:border-indigo-400/60 cursor-pointer text-indigo-300'
: 'bg-surface border-surface-border text-slate-600 cursor-not-allowed'
}
${isSelected && picking ? 'animate-pulse' : ''}
`}
>
{isRevealed
? prize > 0
? (prizeLabels[prize] ?? `$${prize}`)
: '—'
: isPicked && picking
? <Loader2 className="h-4 w-4 animate-spin mx-auto" />
: '?'
}
</button>
)
})}
</div>
{canPlay && !result && (
<p className="text-center text-sm text-slate-500">Pick any box to play</p>
)}
</div>
)
}
+23 -1
View File
@@ -1,4 +1,6 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { HashtagCard } from '@/components/HashtagCard'
import { TrendingUp, Users, Hash } from 'lucide-react'
import Link from 'next/link'
@@ -36,7 +38,8 @@ async function getStats() {
}
export default async function HomePage() {
const { userCount, hashtagCount, tradeCount, topHashtags, recentTrades } = await getStats()
const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] =
await Promise.all([getServerSession(authOptions), getStats()])
return (
<div className="space-y-10">
@@ -51,6 +54,23 @@ export default async function HomePage() {
Research a tag to unlock it, then buy long or short.
</p>
<div className="flex justify-center gap-4 mt-6">
{session ? (
<>
<Link
href="/leaderboard"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Leaderboard
</Link>
<Link
href="/lottery"
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Lucky Dip
</Link>
</>
) : (
<>
<Link
href="/auth/signup"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-lg font-medium transition-colors"
@@ -63,6 +83,8 @@ export default async function HomePage() {
>
Sign in
</Link>
</>
)}
</div>
</div>
@@ -0,0 +1,142 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { UserCog, Loader2, CheckCircle2 } from 'lucide-react'
interface Props {
currentUsername: string
currentDisplayUsername: string | null
}
export default function AccountSettingsForm({ currentUsername, currentDisplayUsername }: Props) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [displayUsername, setDisplayUsername] = useState(currentDisplayUsername ?? currentUsername)
const [username, setUsername] = useState(currentUsername)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess('')
const updates: { displayUsername?: string; username?: string } = {}
if (displayUsername.trim() !== (currentDisplayUsername ?? currentUsername)) {
updates.displayUsername = displayUsername.trim()
}
const newUsername = username.toLowerCase().trim()
if (newUsername !== currentUsername) {
updates.username = newUsername
}
if (Object.keys(updates).length === 0) {
setError('No changes to save.')
return
}
setLoading(true)
try {
const res = await fetch('/api/user/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Something went wrong.')
} else {
setSuccess('Settings saved!')
// If username changed, navigate to new profile URL
if (updates.username) {
router.push(`/profile/${data.username}`)
} else {
router.refresh()
}
setTimeout(() => setOpen(false), 1200)
}
} catch {
setError('Network error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<section className="bg-surface-card border border-surface-border rounded-xl p-6">
<button
onClick={() => { setOpen((v) => !v); setError(''); setSuccess('') }}
className="flex items-center gap-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
<UserCog className="h-4 w-4 text-indigo-400" />
Account settings
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
</button>
{open && (
<form onSubmit={handleSubmit} className="mt-4 space-y-4 max-w-sm">
<div>
<label className="block text-xs text-slate-400 mb-1">
Display name{' '}
<span className="text-slate-600">(shown publicly; any capitalisation)</span>
</label>
<input
type="text"
value={displayUsername}
onChange={(e) => setDisplayUsername(e.target.value)}
minLength={3}
maxLength={20}
pattern="^[a-zA-Z0-9_]{3,20}$"
title="320 characters: letters, numbers, underscores"
required
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">
Username{' '}
<span className="text-slate-600">(used for your URL changing will redirect)</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
minLength={3}
maxLength={20}
pattern="^[a-zA-Z0-9_]{3,20}$"
title="320 characters: letters, numbers, underscores"
required
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<p className="text-xs text-slate-600 mt-1">
Will be stored as lowercase. Profile URL: /profile/{username.toLowerCase()}
</p>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && (
<p className="text-emerald-400 text-sm flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" /> {success}
</p>
)}
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</button>
</form>
)}
</section>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useState } from 'react'
import { KeyRound, Loader2, CheckCircle2 } from 'lucide-react'
export default function ChangePasswordForm() {
const [open, setOpen] = useState(false)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess(false)
if (next !== confirm) {
setError('New passwords do not match.')
return
}
setLoading(true)
try {
const res = await fetch('/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Something went wrong.')
} else {
setSuccess(true)
setCurrent('')
setNext('')
setConfirm('')
setTimeout(() => setOpen(false), 1800)
}
} catch {
setError('Network error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<section className="bg-surface-card border border-surface-border rounded-xl p-6">
<button
onClick={() => { setOpen((v) => !v); setError(''); setSuccess(false) }}
className="flex items-center gap-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
<KeyRound className="h-4 w-4 text-indigo-400" />
Change password
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
</button>
{open && (
<form onSubmit={handleSubmit} className="mt-4 space-y-3 max-w-sm">
<div>
<label className="block text-xs text-slate-400 mb-1">Current password</label>
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">New password</label>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && (
<p className="text-emerald-400 text-sm flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" /> Password updated!
</p>
)}
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Update password
</button>
</form>
)}
</section>
)
}
+18 -1
View File
@@ -5,6 +5,8 @@ import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
import ChangePasswordForm from './ChangePasswordForm'
import AccountSettingsForm from './AccountSettingsForm'
export const dynamic = 'force-dynamic'
@@ -21,6 +23,7 @@ export default async function ProfilePage({ params }: Props) {
select: {
id: true,
username: true,
displayUsername: true,
balance: true,
researchPoints: true,
createdAt: true,
@@ -66,7 +69,10 @@ export default async function ProfilePage({ params }: Props) {
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">{user.username}</h1>
<h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1>
{user.displayUsername && user.displayUsername.toLowerCase() !== user.username && (
<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
@@ -95,6 +101,17 @@ export default async function ProfilePage({ params }: Props) {
/>
</div>
{/* Account settings — only shown to the profile owner */}
{isOwn && (
<>
<AccountSettingsForm
currentUsername={user.username}
currentDisplayUsername={user.displayUsername ?? null}
/>
<ChangePasswordForm />
</>
)}
{/* Positions */}
{user.positions.length > 0 && (
<section>
+9 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield } from 'lucide-react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
@@ -53,6 +53,14 @@ export function Navbar() {
{/* Balance chip */}
<BalanceBadge userId={session.user.id} />
<Link
href="/leaderboard"
className="text-slate-400 hover:text-slate-200 transition-colors"
title="Leaderboard"
>
<Trophy className="h-5 w-5" />
</Link>
{session.user.isAdmin && (
<Link
href="/admin"
+12
View File
@@ -0,0 +1,12 @@
/** Lottery configuration shared between the API route and the UI page */
export const GRID_SIZE = 25 // 5×5
/** Map from shuffle-index → prize amount. All other indexes = $0 */
export const PRIZE_MAP: Record<number, number> = {
0: 200, // jackpot
1: 50,
2: 50,
3: 10,
4: 10,
5: 10,
}
+32 -2
View File
@@ -2,6 +2,7 @@ export interface MastodonPost {
id: string
created_at: string
content: string
tags?: { name: string }[]
}
interface TimelineResult {
@@ -49,6 +50,17 @@ async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
* (e.g., #happynewyear at midnight) up to MAX_PAGES_PER_HASHTAG pages.
*/
export async function getPostsPerHour(tag: string): Promise<number> {
const { postsPerHour } = await getPostsData(tag)
return postsPerHour
}
/**
* Returns posts-per-hour AND a sorted list of co-occurring tag names
* (lowercased, excluding the queried tag itself).
*/
export async function getPostsData(
tag: string,
): Promise<{ postsPerHour: number; relatedTags: string[] }> {
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
let allPosts: MastodonPost[] = []
@@ -71,7 +83,7 @@ export async function getPostsPerHour(tag: string): Promise<number> {
maxId = nextMaxId
}
if (allPosts.length === 0) return 0
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [] }
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const newestMs = Math.max(...times)
@@ -79,6 +91,24 @@ export async function getPostsPerHour(tag: string): Promise<number> {
// Minimum 1-minute span to handle flood scenario (all same timestamp)
const spanHours = Math.max((newestMs - oldestMs) / (1000 * 60 * 60), 1 / 60)
const postsPerHour = allPosts.length / spanHours
return allPosts.length / spanHours
// Count co-occurring tags
const counts = new Map<string, number>()
const lowerTag = tag.toLowerCase()
for (const post of allPosts) {
for (const t of post.tags ?? []) {
const name = t.name.toLowerCase()
if (name !== lowerTag && name.length >= 2 && name.length <= 100) {
counts.set(name, (counts.get(name) ?? 0) + 1)
}
}
}
// Return top 10 by count
const relatedTags = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name]) => name)
return { postsPerHour, relatedTags }
}
+1
View File
@@ -8,5 +8,6 @@ export const config = {
'/api/research/:path*',
'/api/user/:path*',
'/api/admin/:path*',
'/api/lottery/:path*',
],
}
+27 -2
View File
@@ -12,7 +12,7 @@
import { Worker, Queue } from 'bullmq'
import { PrismaClient } from '@prisma/client'
import { getPostsPerHour } from '../lib/mastodon'
import { getPostsData } from '../lib/mastodon'
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
// ── Connection options ────────────────────────────────────────────────────────
@@ -61,8 +61,9 @@ const priceWorker = new Worker(
console.log(`[price] updating #${tag}`)
let postsPerHour = 0
let relatedTags: string[] = []
try {
postsPerHour = await getPostsPerHour(tag)
;({ postsPerHour, relatedTags } = await getPostsData(tag))
} catch (err) {
console.error(`[price] mastodon error for #${tag}:`, err)
throw err // BullMQ will retry
@@ -107,6 +108,30 @@ const priceWorker = new Worker(
}),
])
// Upsert related hashtag co-occurrences (outside transaction — non-critical)
if (relatedTags.length > 0) {
for (const relTag of relatedTags) {
// Try to resolve to an existing hashtag id
const relHashtag = await prisma.hashtag.findUnique({
where: { tag: relTag },
select: { id: true },
})
await prisma.relatedHashtag.upsert({
where: { hashtagId_relatedTag: { hashtagId, relatedTag: relTag } },
create: {
hashtagId,
relatedTag: relTag,
relatedId: relHashtag?.id ?? null,
coOccurrences: 1,
},
update: {
coOccurrences: { increment: 1 },
relatedId: relHashtag?.id ?? undefined,
},
})
}
}
console.log(`[price] #${tag}$${newPrice.toFixed(2)} (${postsPerHour.toFixed(1)} posts/hr)`)
// Honour the configured rate limit by sleeping after each job