pw change, leaderboard, lotto, related ht, admin ban, add, and display changes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
This commit is contained in:
@@ -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.
|
||||
|
||||
+30
-11
@@ -8,14 +8,16 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
balance Float @default(2000)
|
||||
researchPoints Int @default(1)
|
||||
isAdmin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
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
|
||||
|
||||
positions Position[]
|
||||
trades Trade[]
|
||||
@@ -40,18 +42,35 @@ 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())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
priceHistory PriceHistory[]
|
||||
positions Position[]
|
||||
trades Trade[]
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,7 +41,9 @@ 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>
|
||||
<form className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<AdminAddHashtag />
|
||||
<form className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
@@ -63,6 +66,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
@@ -87,15 +91,21 @@ 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">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
h.isActive
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{h.isActive ? 'active' : 'inactive'}
|
||||
</span>
|
||||
{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
|
||||
? 'bg-emerald-500/15 text-emerald-400'
|
||||
: 'bg-red-500/15 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{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() } : {}),
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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, 0–24)
|
||||
*
|
||||
* 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 3–20 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 3–20 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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
+35
-13
@@ -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,18 +54,37 @@ 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">
|
||||
<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"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
{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"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
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="3–20 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="3–20 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ export const config = {
|
||||
'/api/research/:path*',
|
||||
'/api/user/:path*',
|
||||
'/api/admin/:path*',
|
||||
'/api/lottery/:path*',
|
||||
],
|
||||
}
|
||||
|
||||
+27
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user