Compare commits
91 Commits
f54a332345
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d4acc1d61c | |||
| b25f300edf | |||
| 1bed1f2040 | |||
| 54839e6034 | |||
| d51e0d6507 | |||
| 3f542289a8 | |||
| 1dcabdf6db | |||
| a03ab09d05 | |||
| ad15792621 | |||
| e6e1b895e7 | |||
| 9e93c3db57 | |||
| f4c379b0d4 | |||
| 15378c1eec | |||
| 100f149c53 | |||
| d68bc99817 | |||
| 72885ed0b0 | |||
| 3ce7bd36b8 | |||
| 8fd5484e86 | |||
| 34ecec2da6 | |||
| e2dc3ea492 | |||
| efdef6149a | |||
| 02119e3b56 | |||
| e067d3f5c7 | |||
| 997f1041f0 | |||
| 2eb3ebad48 | |||
| a280891359 | |||
| 468b8b6677 | |||
| 9c3312ed75 | |||
| 74a204ea39 | |||
| 05a9d8f7af | |||
| 81f7d90be1 | |||
| c1ca92b8a0 | |||
| c6a0cf51a9 | |||
| cd8e23747b | |||
| 5020f38090 | |||
| ef01ea9a3a | |||
| 840345d093 | |||
| 9dd9cf5ed9 | |||
| 14d79acc63 | |||
| 1d0b160ba8 | |||
| c5076e330c | |||
| 898f99049d | |||
| 7367e4d7c6 | |||
| 5974e8fd87 | |||
| ba8fa8e253 | |||
| c1bcac8a30 | |||
| 682c76128c | |||
| f0cf1f6461 | |||
| 621d3a9120 | |||
| c28720be5a | |||
| ea1dca974c | |||
| bf14b039c6 | |||
| 8783fbf459 | |||
| a6cf9f8db7 | |||
| eaff278e3d | |||
| ee0eb47a4b | |||
| 8cf116de2d | |||
| 84aa69884d | |||
| 0e7c9dd890 | |||
| a682ac9b22 | |||
| 125e62c19c | |||
| c5e5f9eaf6 | |||
| 005b4543f6 | |||
| d0dc52f82b | |||
| 82b1953c8b | |||
| 5c853bd1ee | |||
| 1cf7892c8b | |||
| e1e6790628 | |||
| d55e3dfef2 | |||
| c3b0055572 | |||
| 54ecf35cf3 | |||
| f3f3591e34 | |||
| 422d85e97e | |||
| a0695fd11e | |||
| 873b86f85e | |||
| aa7a80c3e7 | |||
| 9a87c0c7c5 | |||
| 5e9421801e | |||
| 40a1034000 | |||
| fdb234641f | |||
| 249909f3de | |||
| 6bfbfcc8a0 | |||
| da568646e2 | |||
| 1475c17a9c | |||
| 589763fa44 | |||
| c78045e2b9 | |||
| e06caa6d0c | |||
| eb9e8eeecb | |||
| 64ae9c1082 | |||
| ec275dd858 | |||
| 20f939799d |
@@ -21,6 +21,11 @@ PRICE_UPDATE_INTERVAL_MINUTES=60
|
|||||||
HASHTAG_ACTIVE_HOURS=24
|
HASHTAG_ACTIVE_HOURS=24
|
||||||
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
|
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
|
||||||
MAX_PAGES_PER_HASHTAG=5
|
MAX_PAGES_PER_HASHTAG=5
|
||||||
|
# Price history retention: days to keep for active hashtags, hours for inactive ones
|
||||||
|
PRICE_HISTORY_ACTIVE_DAYS=7
|
||||||
|
PRICE_HISTORY_INACTIVE_HOURS=24
|
||||||
|
# Consecutive zero-post updates before all positions are force-closed and the hashtag retired
|
||||||
|
ZOMBIE_ZERO_COUNT=1000
|
||||||
|
|
||||||
# Initial admin user — only used by `npm run db:seed`, not the running app.
|
# Initial admin user — only used by `npm run db:seed`, not the running app.
|
||||||
# Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed
|
# Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed
|
||||||
|
|||||||
@@ -152,27 +152,32 @@ All variables are documented in `.env.example`. Key ones:
|
|||||||
|
|
||||||
## Pricing Formula
|
## Pricing Formula
|
||||||
|
|
||||||
|
Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices:
|
||||||
|
|
||||||
```
|
```
|
||||||
price = max($0.25, round(postsPerHour × $0.25, 2))
|
price = max($0.25, round((base × pph) / (1 + k × pph), 2))
|
||||||
```
|
```
|
||||||
|
|
||||||
Examples:
|
`k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second).
|
||||||
|
|
||||||
| Posts/hr | Price |
|
| Posts/hr | Price |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1 | $0.25 |
|
| 1 | ~$0.25 |
|
||||||
| 10 | $2.50 |
|
| 10 | ~$2.48 |
|
||||||
| 100 | $25.00 |
|
| 100 | ~$23.32 |
|
||||||
| 1,000 | $250.00 |
|
| 1,000 | ~$145 |
|
||||||
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 |
|
| 3,600 (one post/sec) | ~$250 |
|
||||||
|
| ∞ (theoretical) | ~$346 (asymptote) |
|
||||||
|
|
||||||
**Burst handling:** when all fetched posts share a very tight timestamp window the worker paginates up to `MAX_PAGES_PER_HASHTAG` pages to get a realistic count before the span grows to > 5 minutes.
|
At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market.
|
||||||
|
|
||||||
|
**Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Research System
|
## Research System
|
||||||
|
|
||||||
- Every player earns **1 research point per day** (awarded at 00:05 UTC by the maintenance worker).
|
- Every player earns **1 research point per day** (awarded at midnight EST by the maintenance worker).
|
||||||
- Balance milestones unlock extra daily points:
|
- Balance milestones unlock extra daily points:
|
||||||
|
|
||||||
| Balance | Daily points |
|
| Balance | Daily points |
|
||||||
@@ -217,7 +222,7 @@ Three BullMQ queues:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
|
| `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
|
||||||
| `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. |
|
| `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. |
|
||||||
| `hashex-maintenance` | Runs daily at 00:05 UTC. Awards research points based on each player's balance. |
|
| `hashex-maintenance` | Runs daily at midnight EST. Awards research points based on each player's balance. |
|
||||||
|
|
||||||
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
|
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
|
||||||
|
|
||||||
@@ -359,9 +364,8 @@ The items below are planned improvements roughly ordered by user value. They are
|
|||||||
|
|
||||||
### Other Ideas / Nice-to-Haves
|
### Other Ideas / Nice-to-Haves
|
||||||
|
|
||||||
- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades.
|
|
||||||
- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
|
- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
|
||||||
- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances.
|
- **Multi-instance support**: fallback to another instance if the primary instance is unavailable or throttles API calls.
|
||||||
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
|
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
|
||||||
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
|
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
|
||||||
- **Dark/light theme toggle**: currently dark-only.
|
- **Dark/light theme toggle**: currently dark-only.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ model User {
|
|||||||
managedFunds FundManager[]
|
managedFunds FundManager[]
|
||||||
fund HedgeFund?
|
fund HedgeFund?
|
||||||
fundInvestments FundInvestment[]
|
fundInvestments FundInvestment[]
|
||||||
|
portfolioHistory UserPortfolioHistory[]
|
||||||
|
fundApplication FundApplication?
|
||||||
}
|
}
|
||||||
|
|
||||||
model HedgeFund {
|
model HedgeFund {
|
||||||
@@ -41,6 +43,8 @@ model HedgeFund {
|
|||||||
|
|
||||||
managers FundManager[]
|
managers FundManager[]
|
||||||
investments FundInvestment[]
|
investments FundInvestment[]
|
||||||
|
navHistory FundNavHistory[]
|
||||||
|
trades Trade[]
|
||||||
|
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
}
|
}
|
||||||
@@ -131,6 +135,28 @@ model PriceHistory {
|
|||||||
@@index([hashtagId, recordedAt])
|
@@index([hashtagId, recordedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FundNavHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fundId String
|
||||||
|
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
|
||||||
|
nav Float
|
||||||
|
totalValue Float
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([fundId, recordedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserPortfolioHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
totalValue Float
|
||||||
|
portfolioValue Float
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, recordedAt])
|
||||||
|
}
|
||||||
|
|
||||||
model Position {
|
model Position {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -154,6 +180,8 @@ model Trade {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
hashtagId String?
|
hashtagId String?
|
||||||
hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
|
hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
|
||||||
|
fundId String?
|
||||||
|
fund HedgeFund? @relation(fields: [fundId], references: [id], onDelete: SetNull)
|
||||||
type TradeType
|
type TradeType
|
||||||
shares Float
|
shares Float
|
||||||
price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
|
price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
|
||||||
@@ -163,6 +191,7 @@ model Trade {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hashtagId])
|
@@index([hashtagId])
|
||||||
|
@@index([fundId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,4 +206,20 @@ enum TradeType {
|
|||||||
BUY_SHORT
|
BUY_SHORT
|
||||||
SELL_SHORT
|
SELL_SHORT
|
||||||
LOTTERY_WIN
|
LOTTERY_WIN
|
||||||
|
LIQUIDATE_LONG
|
||||||
|
LIQUIDATE_SHORT
|
||||||
|
DONATION // keepHistory reset: user was in the green — donated their portfolio
|
||||||
|
BANKRUPTCY // keepHistory reset: user was in the red — debts cleared
|
||||||
|
ACCOUNT_OPEN // keepHistory reset: new $2000 account opening entry
|
||||||
|
FUND_INVEST // invested cash into a hedge fund
|
||||||
|
FUND_REDEEM // redeemed shares from a hedge fund
|
||||||
|
}
|
||||||
|
|
||||||
|
model FundApplication {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
fundName String
|
||||||
|
reason String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -13,6 +13,14 @@ services:
|
|||||||
REDIS_URL: "${REDIS_URL}"
|
REDIS_URL: "${REDIS_URL}"
|
||||||
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
|
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
|
||||||
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
|
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
|
||||||
|
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-15}"
|
||||||
|
ZOMBIE_ZERO_COUNT: "${ZOMBIE_ZERO_COUNT:-1000}"
|
||||||
|
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
|
||||||
|
MAX_POSITION_SHARES: "${MAX_POSITION_SHARES:-100}"
|
||||||
|
MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}"
|
||||||
|
FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}"
|
||||||
|
FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}"
|
||||||
|
TZ: "America/Toronto"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -29,9 +37,14 @@ services:
|
|||||||
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
|
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
|
||||||
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
|
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
|
||||||
WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}"
|
WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}"
|
||||||
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}"
|
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-15}"
|
||||||
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
|
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
|
||||||
MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}"
|
MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}"
|
||||||
|
ZOMBIE_ZERO_COUNT: "${ZOMBIE_ZERO_COUNT:-1000}"
|
||||||
|
MASTODON_POST_LIMIT: "${MASTODON_POST_LIMIT:-20}"
|
||||||
|
PRICE_HISTORY_ACTIVE_DAYS: "${PRICE_HISTORY_ACTIVE_DAYS:-7}"
|
||||||
|
PRICE_HISTORY_INACTIVE_HOURS: "${PRICE_HISTORY_INACTIVE_HOURS:-24}"
|
||||||
|
TZ: "America/Toronto"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { BookOpen, TrendingUp, TrendingDown, Coins, Shuffle, RotateCcw, Building2, ExternalLink, Github } from 'lucide-react'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'About — HashEx',
|
||||||
|
description: 'How HashEx works: rules, features, and quirks of the hashtag stock market.',
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 border-b border-surface-border pb-2">
|
||||||
|
<Icon className="h-5 w-5 text-indigo-400 shrink-0" />
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rule({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 text-sm">
|
||||||
|
<span className="text-indigo-400 font-medium shrink-0 w-32">{label}</span>
|
||||||
|
<span className="text-slate-300">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-10 py-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<BookOpen className="h-7 w-7 text-indigo-400" />
|
||||||
|
<h1 className="text-3xl font-bold">About HashEx</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
HashEx is a stock-market simulation game where the "stocks" are Mastodon hashtags.
|
||||||
|
Prices update automatically based on real post activity. Start with{' '}
|
||||||
|
<span className="text-white font-medium">$2,000</span> and see how much you can grow it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Getting started */}
|
||||||
|
<Section title="Getting Started" icon={TrendingUp}>
|
||||||
|
<div className="space-y-2 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
Every new account starts with <span className="text-white font-medium">$2,000</span> in play money and{' '}
|
||||||
|
<span className="text-white font-medium">1 research point</span>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Use research points to unlock hashtags. Each point lets you search for a tag on Mastodon — if it has
|
||||||
|
activity, it gets added to the exchange with a live price. You earn more points each day based on your
|
||||||
|
account balance.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-lg p-3 space-y-1 mt-2">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Daily research points</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 text-xs">
|
||||||
|
<span className="text-slate-400">Balance under $10k</span><span>1 pt / day</span>
|
||||||
|
<span className="text-slate-400">$10k+</span><span>2 pts / day</span>
|
||||||
|
<span className="text-slate-400">$100k+</span><span>3 pts / day</span>
|
||||||
|
<span className="text-slate-400">$1M+</span><span>5 pts / day</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<Section title="How Prices Work" icon={Coins}>
|
||||||
|
<div className="space-y-2 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
Every hashtag has a price derived from its posts-per-hour rate on Mastodon using a
|
||||||
|
{' '}<span className="text-white font-medium">saturating curve</span> — prices rise quickly at
|
||||||
|
low activity and flatten at high activity so a single viral tag can't dominate the market.
|
||||||
|
</p>
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300 text-xs">
|
||||||
|
price = (0.25 × pph) / (1 + k × pph) · floor $0.25
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 text-xs mt-1">
|
||||||
|
<span className="text-slate-400">1 post / hr</span><span>~$0.25</span>
|
||||||
|
<span className="text-slate-400">10 posts / hr</span><span>~$2.48</span>
|
||||||
|
<span className="text-slate-400">100 posts / hr</span><span>~$23</span>
|
||||||
|
<span className="text-slate-400">1,000 posts / hr</span><span>~$145</span>
|
||||||
|
<span className="text-slate-400">3,600 posts / hr (1/sec)</span><span>~$250</span>
|
||||||
|
<span className="text-slate-400">∞ (asymptote)</span><span>~$346</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be
|
||||||
|
automatically <span className="text-orange-400">deactivated</span> — you'll get a warning on the
|
||||||
|
home page if any of your positions are at risk. Research it again to reactivate it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Trade types */}
|
||||||
|
<Section title="Trade Types" icon={TrendingUp}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Rule label="Buy Long">
|
||||||
|
Bet the price goes <span className="text-emerald-400">up</span>. You buy shares and profit when the
|
||||||
|
price rises above your average buy price.
|
||||||
|
</Rule>
|
||||||
|
<Rule label="Sell Long">
|
||||||
|
Close or reduce a long position. Profit = (current price − avg buy price) × shares.
|
||||||
|
</Rule>
|
||||||
|
<Rule label="Buy Short">
|
||||||
|
Bet the price goes <span className="text-red-400">down</span>. You put up collateral and profit when
|
||||||
|
the price falls below your entry.
|
||||||
|
</Rule>
|
||||||
|
<Rule label="Sell Short">
|
||||||
|
Close a short. Profit = (avg entry − current price) × shares. If the price rose above your entry you
|
||||||
|
take a loss — and your balance <span className="text-red-400">can go negative</span>.
|
||||||
|
</Rule>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
All trades are validated server-side. You cannot buy more than your balance, sell more shares than you hold,
|
||||||
|
or trade a hashtag you haven't researched.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Short selling specifics */}
|
||||||
|
<Section title="Short Selling — Quirks & Risks" icon={TrendingDown}>
|
||||||
|
<div className="space-y-2 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
Shorts use a <span className="text-white font-medium">collateral model</span>. When you buy short, the
|
||||||
|
cost is <code className="text-xs bg-surface-card px-1 py-0.5 rounded">price × shares</code>. When you
|
||||||
|
close, you receive back <code className="text-xs bg-surface-card px-1 py-0.5 rounded">(2 × entry − current) × shares</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This means losses are <span className="text-red-400">uncapped</span>. If the price doubles, your
|
||||||
|
payout is zero. If it triples, your balance goes negative.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A <span className="text-red-400">negative balance</span> isn't game-over — you can still trade, but
|
||||||
|
your total portfolio value will show in red. You can reset your account at any time from your profile page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Hedge Funds */}
|
||||||
|
<Section title="Hedge Funds" icon={Building2}>
|
||||||
|
<div className="space-y-2 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
Admins can create <span className="text-white font-medium">Hedge Funds</span> — shared pools of capital
|
||||||
|
that multiple players can manage together.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
As a fund manager you trade on behalf of the fund by appending{' '}
|
||||||
|
<code className="text-xs bg-surface-card px-1 py-0.5 rounded">?fund=[slug]</code> to any hashtag page
|
||||||
|
(there are quick links on the fund page). A banner confirms you're in Fund Mode.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Outside investors can buy and sell <span className="text-white font-medium">fund shares</span> from the
|
||||||
|
fund's page. The NAV (net asset value) per share is calculated live from the fund's total
|
||||||
|
portfolio. Fund investments show up in your Holdings and Trade History.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-500 text-xs">
|
||||||
|
Fund shares are stored to 6 decimal places. Fund accounts cannot sign in directly and do not earn
|
||||||
|
research points or play the lottery.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Want to run your own fund?{' '}
|
||||||
|
<Link href="/fund/apply" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
|
||||||
|
Apply here →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Account reset */}
|
||||||
|
<Section title="Account Reset" icon={RotateCcw}>
|
||||||
|
<div className="space-y-2 text-sm text-slate-300">
|
||||||
|
<p>
|
||||||
|
You can reset your account from your profile page at any time. All positions are closed and your
|
||||||
|
balance returns to $2,000.
|
||||||
|
</p>
|
||||||
|
<Rule label="Keep history">
|
||||||
|
Your trade log is preserved. A{' '}
|
||||||
|
<span className="text-purple-400">Donation</span> entry is recorded if you were in profit, or a{' '}
|
||||||
|
<span className="text-red-400">Bankruptcy</span> if you were in the red, followed by an{' '}
|
||||||
|
<span className="text-emerald-400">Account Open</span>.
|
||||||
|
</Rule>
|
||||||
|
<Rule label="Erase history">
|
||||||
|
All trade records are deleted along with the reset.
|
||||||
|
</Rule>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Lucky Dip */}
|
||||||
|
<Section title="Lucky Dip" icon={Shuffle}>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Once per day you can open the Lucky Dip lottery. Pick a box — most are empty, but a few hold cash prizes.
|
||||||
|
Winnings are added directly to your balance. So head over and check out our
|
||||||
|
<Link href="/lucky-dip" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
|
||||||
|
Lucky Dip page</Link> now or you can always find the link on our home page.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<section className="bg-surface-card border border-surface-border rounded-xl p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-300 uppercase tracking-wider">Links</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<a
|
||||||
|
href="https://mastodon.nervesocket.com/@ThaMunsta"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 shrink-0" />
|
||||||
|
@ThaMunsta on Mastodon — questions, feedback, bug reports
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.dev.nervesocket.com/ThaMunsta/hashex"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Github className="h-4 w-4 shrink-0" />
|
||||||
|
Source code — contribute or run your own instance
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/" className="text-sm text-indigo-400 hover:text-indigo-300">
|
||||||
|
← Back to the exchange
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { FileText, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Applicant {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayUsername: string | null
|
||||||
|
managedFunds: { fund: { name: string; slug: string } }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Application {
|
||||||
|
id: string
|
||||||
|
fundName: string
|
||||||
|
reason: string
|
||||||
|
createdAt: string
|
||||||
|
user: Applicant
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminFundApplications({ applications: initial }: { applications: Application[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [applications, setApplications] = useState<Application[]>(initial)
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const [startingBalance, setStartingBalance] = useState('10000')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function approve(app: Application) {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
const res = await fetch(`/api/admin/fund-applications/${app.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'approve', startingBalance: parseFloat(startingBalance) || 0 }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||||
|
setApplications(applications.filter((a) => a.id !== app.id))
|
||||||
|
setExpanded(null)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deny(id: string, e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('Deny this application? The applicant will not be notified.')) return
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch(`/api/admin/fund-applications/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'deny' }),
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
if (res.ok) {
|
||||||
|
setApplications(applications.filter((a) => a.id !== id))
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applications.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-slate-500 text-sm">No pending fund applications.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{applications.map((app) => {
|
||||||
|
const isOpen = expanded === app.id
|
||||||
|
return (
|
||||||
|
<div key={app.id} className="bg-surface-card border border-amber-500/20 rounded-xl overflow-hidden">
|
||||||
|
{/* Clickable header row */}
|
||||||
|
<div
|
||||||
|
className="flex items-start justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors"
|
||||||
|
onClick={() => setExpanded(isOpen ? null : app.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
<FileText className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-sm">{app.fundName}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
by{' '}
|
||||||
|
<span className="text-slate-300">{app.user.displayUsername ?? app.user.username}</span>
|
||||||
|
{' '}· {new Date(app.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={(e) => deny(app.id, e)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
title="Deny application"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded: reason + approve form */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-surface-border px-4 pt-3 pb-4 space-y-4">
|
||||||
|
<p className="text-sm text-slate-300 whitespace-pre-wrap">{app.reason}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Approve <span className="text-white font-medium">{app.fundName}</span> for{' '}
|
||||||
|
<span className="text-slate-200">{app.user.displayUsername ?? app.user.username}</span>
|
||||||
|
{app.user.managedFunds.length > 0 && (
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{' '}(also manages{' '}
|
||||||
|
{app.user.managedFunds.map((m, i) => (
|
||||||
|
<span key={m.fund.slug}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<a
|
||||||
|
href={`/fund/${m.fund.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{m.fund.name}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 block mb-1">Starting Balance ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={startingBalance}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setStartingBalance(e.target.value)}
|
||||||
|
className="w-32 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => approve(app)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs rounded-lg disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Confirm Approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import AdminFundActions from './AdminFundActions'
|
import AdminFundActions from './AdminFundActions'
|
||||||
|
import AdminFundApplications from './AdminFundApplications'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function AdminFundsPage() {
|
export default async function AdminFundsPage() {
|
||||||
const funds = await prisma.hedgeFund.findMany({
|
const [funds, applications] = await Promise.all([
|
||||||
|
prisma.hedgeFund.findMany({
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { balance: true } },
|
user: { select: { balance: true } },
|
||||||
@@ -13,12 +15,45 @@ export default async function AdminFundsPage() {
|
|||||||
orderBy: { addedAt: 'asc' },
|
orderBy: { addedAt: 'asc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
prisma.fundApplication.findMany({
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayUsername: true,
|
||||||
|
managedFunds: { select: { fund: { select: { name: true, slug: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const serialisedApplications = applications.map((a) => ({
|
||||||
|
...a,
|
||||||
|
createdAt: a.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
Fund Applications
|
||||||
|
{applications.length > 0 && (
|
||||||
|
<span className="text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded-full px-2 py-0.5">
|
||||||
|
{applications.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<AdminFundApplications applications={serialisedApplications} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">Hedge Funds</h2>
|
<h2 className="text-lg font-semibold">Hedge Funds</h2>
|
||||||
<AdminFundActions funds={funds} />
|
<AdminFundActions funds={funds} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -67,26 +67,43 @@ export default async function AdminOverviewPage() {
|
|||||||
Recent trades
|
Recent trades
|
||||||
</h2>
|
</h2>
|
||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{recentTrades.map((t) => (
|
{recentTrades.map((t) => {
|
||||||
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
|
const isBuy = t.type.startsWith('BUY')
|
||||||
|
const isSell = t.type === 'SELL_LONG' || t.type === 'SELL_SHORT'
|
||||||
|
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
|
||||||
|
const isSystem = t.type === 'ACCOUNT_OPEN' || t.type === 'DONATION' || t.type === 'BANKRUPTCY'
|
||||||
|
const badgeClass = isLottery
|
||||||
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: isFundTrade
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
|
: isSystem
|
||||||
|
? 'bg-slate-500/15 text-slate-400'
|
||||||
|
: isBuy
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
|
: isSell
|
||||||
|
? 'bg-red-500/15 text-red-400'
|
||||||
|
: 'bg-slate-500/15 text-slate-400'
|
||||||
|
const label = t.hashtag
|
||||||
|
? `#${t.hashtag.displayTag}`
|
||||||
|
: isLottery
|
||||||
|
? 'Lucky Dip'
|
||||||
|
: isFundTrade
|
||||||
|
? 'Fund'
|
||||||
|
: null
|
||||||
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
|
||||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
|
||||||
t.type === 'LOTTERY_WIN'
|
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
|
||||||
: t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.type.replace(/_/g, ' ')}
|
{t.type.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-300">{t.user.username}</span>
|
<span className="text-slate-300">{t.user.username}</span>
|
||||||
<span className="text-slate-500">
|
{label && <span className="text-slate-500">{label}</span>}
|
||||||
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span>{formatCurrency(t.total)}</span>
|
<span>{formatCurrency(t.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue'
|
import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import RetryFailedButton from '@/components/admin/RetryFailedButton'
|
import RetryFailedButton from '@/components/admin/RetryFailedButton'
|
||||||
|
import TriggerJobButton from '@/components/admin/TriggerJobButton'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -69,13 +70,21 @@ async function getQueueSummary(queue: typeof priceUpdateQueue): Promise<QueueSum
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminQueuePage() {
|
export default async function AdminQueuePage() {
|
||||||
const [priceSummary, maintenanceSummary, schedulerSummary] = await Promise.all([
|
const [priceSummary, maintenanceSummary, schedulerSummary, fundNavSummary] = await Promise.all([
|
||||||
getQueueSummary(priceUpdateQueue),
|
getQueueSummary(priceUpdateQueue),
|
||||||
getQueueSummary(maintenanceQueue),
|
getQueueSummary(maintenanceQueue),
|
||||||
getQueueSummary(schedulerQueue),
|
getQueueSummary(schedulerQueue),
|
||||||
|
getQueueSummary(fundNavSnapshotQueue),
|
||||||
])
|
])
|
||||||
|
|
||||||
const queues = [priceSummary, maintenanceSummary, schedulerSummary]
|
const queues = [priceSummary, maintenanceSummary, schedulerSummary, fundNavSummary]
|
||||||
|
|
||||||
|
// Queues that support a manual trigger, and the label to show
|
||||||
|
const triggerLabels: Record<string, string> = {
|
||||||
|
'hashex-scheduler': 'Trigger sweep',
|
||||||
|
'hashex-maintenance': 'Run maintenance',
|
||||||
|
'hashex-fund-nav-snapshot': 'Snapshot NAVs',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -90,6 +99,9 @@ export default async function AdminQueuePage() {
|
|||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border">
|
||||||
<h3 className="font-medium text-sm">{q.name}</h3>
|
<h3 className="font-medium text-sm">{q.name}</h3>
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
{triggerLabels[q.name] && (
|
||||||
|
<TriggerJobButton queueName={q.name} label={triggerLabels[q.name]} />
|
||||||
|
)}
|
||||||
<RetryFailedButton queueName={q.name} count={q.failed} />
|
<RetryFailedButton queueName={q.name} count={q.failed} />
|
||||||
<Badge label="waiting" count={q.waiting} color="slate" />
|
<Badge label="waiting" count={q.waiting} color="slate" />
|
||||||
<Badge label="active" count={q.active} color="indigo" />
|
<Badge label="active" count={q.active} color="indigo" />
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
|
|||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { positions: true, trades: true } },
|
_count: { select: { positions: { where: { shares: { gt: 0 } } }, trades: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.hashtag.count({ where }),
|
prisma.hashtag.count({ where }),
|
||||||
@@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) {
|
|||||||
{hashtags.map((h) => (
|
{hashtags.map((h) => (
|
||||||
<tr key={h.id} className="hover:bg-surface-hover">
|
<tr key={h.id} className="hover:bg-surface-hover">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<a href={`/hashtag/${h.tag}`} className="hover:text-indigo-300">
|
<a
|
||||||
|
href={`/hashtag/${h.tag}`}
|
||||||
|
className={`hover:text-indigo-300 ${!h.isActive && !h.isBanned ? 'text-slate-500' : ''}`}
|
||||||
|
>
|
||||||
#{h.displayTag}
|
#{h.displayTag}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
const [balance, setBalance] = useState(String(user.balance))
|
const [balance, setBalance] = useState(String(user.balance))
|
||||||
const [points, setPoints] = useState(String(user.researchPoints))
|
const [points, setPoints] = useState(String(user.researchPoints))
|
||||||
const [hidden, setHidden] = useState(user.isHidden)
|
const [hidden, setHidden] = useState(user.isHidden)
|
||||||
|
const [isAdmin, setIsAdmin] = useState(user.isAdmin)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
||||||
const [lotteryReset, setLotteryReset] = useState(false)
|
const [lotteryReset, setLotteryReset] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
|
const [deleteError, setDeleteError] = useState('')
|
||||||
|
const [accountResetConfirm, setAccountResetConfirm] = useState('')
|
||||||
|
const [accountResetError, setAccountResetError] = useState('')
|
||||||
|
const [accountResetDone, setAccountResetDone] = useState(false)
|
||||||
|
const [resetKeepHistory, setResetKeepHistory] = useState(false)
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -34,6 +41,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
balance: parseFloat(balance),
|
balance: parseFloat(balance),
|
||||||
researchPoints: parseInt(points, 10),
|
researchPoints: parseInt(points, 10),
|
||||||
isHidden: hidden,
|
isHidden: hidden,
|
||||||
|
isAdmin,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -78,17 +86,58 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (deleteConfirm !== user.username) {
|
||||||
|
setDeleteError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setDeleteError('')
|
||||||
|
const res = await fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) {
|
||||||
|
setDeleteError(data.error ?? 'Delete failed.')
|
||||||
|
} else {
|
||||||
|
setOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccountReset() {
|
||||||
|
if (accountResetConfirm !== user.username) {
|
||||||
|
setAccountResetError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setAccountResetError('')
|
||||||
|
const res = await fetch(`/api/admin/users/${user.id}/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepHistory: resetKeepHistory }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) {
|
||||||
|
setAccountResetError(data.error ?? 'Reset failed.')
|
||||||
|
} else {
|
||||||
|
setAccountResetDone(true)
|
||||||
|
setAccountResetConfirm('')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpen(true); setResetUrl(null); setError('') }}
|
onClick={() => { setOpen(true); setResetUrl(null); setError(''); setDeleteConfirm(''); setDeleteError(''); setAccountResetConfirm(''); setAccountResetError(''); setAccountResetDone(false); setResetKeepHistory(false) }}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||||
<div
|
<div
|
||||||
className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-md space-y-4"
|
className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-md space-y-4"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -125,11 +174,28 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setHidden((h) => !h)}
|
onClick={() => setHidden((h) => !h)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
hidden ? 'bg-slate-600' : 'bg-indigo-600'
|
hidden ? 'bg-indigo-600' : 'bg-slate-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
hidden ? 'translate-x-1' : 'translate-x-6'
|
hidden ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Admin</label>
|
||||||
|
<p className="text-xs text-slate-500">Full access to the admin panel</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAdmin((a) => !a)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
isAdmin ? 'bg-amber-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
isAdmin ? 'translate-x-6' : 'translate-x-1'
|
||||||
}`} />
|
}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,6 +246,86 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Account reset */}
|
||||||
|
<div className="border-t border-amber-500/20 pt-4">
|
||||||
|
<p className="text-sm text-amber-400 mb-1 font-medium">Reset account</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
|
{resetKeepHistory
|
||||||
|
? 'Keeps trade history and adds DONATION/BANKRUPTCY + ACCOUNT OPEN bookmark entries. Balance resets to $2,000.'
|
||||||
|
: 'Permanently erases all trade history, positions, and fund investments, then resets the balance to $2,000.'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Keep trade history</p>
|
||||||
|
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetKeepHistory((v) => !v)}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
resetKeepHistory ? 'bg-amber-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||||
|
resetKeepHistory ? 'translate-x-5' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{accountResetDone ? (
|
||||||
|
<p className="text-xs text-emerald-400">✓ Account has been reset.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accountResetConfirm}
|
||||||
|
onChange={(e) => setAccountResetConfirm(e.target.value)}
|
||||||
|
placeholder={`Type "${user.username}" to confirm`}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
{accountResetError && (
|
||||||
|
<p className="text-red-400 text-xs">{accountResetError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleAccountReset}
|
||||||
|
disabled={loading || accountResetConfirm !== user.username}
|
||||||
|
className="text-sm bg-amber-700/30 hover:bg-amber-700/50 text-amber-400 border border-amber-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting…' : 'Reset account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone — delete user */}
|
||||||
|
<div className="border-t border-red-500/20 pt-4">
|
||||||
|
<p className="text-sm text-red-400 mb-1 font-medium">Danger zone</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Permanently deletes this account, all trades, positions, and history.
|
||||||
|
Fund investments will be forfeited to their respective funds.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deleteConfirm}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
placeholder={`Type "${user.username}" to confirm`}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
{deleteError && (
|
||||||
|
<p className="text-red-400 text-xs">{deleteError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading || deleteConfirm !== user.username}
|
||||||
|
className="text-sm bg-red-700/30 hover:bg-red-700/50 text-red-400 border border-red-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting…' : 'Delete account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
function toSlug(name: string) {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const approveSchema = z.object({
|
||||||
|
action: z.literal('approve'),
|
||||||
|
startingBalance: z.number().min(0).default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
const denySchema = z.object({
|
||||||
|
action: z.literal('deny'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/fund-applications/[applicationId]
|
||||||
|
* action: 'approve' — creates the fund, adds applicant as manager, deletes the application
|
||||||
|
* action: 'deny' — deletes the application
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { applicationId: string } }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user?.isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = await prisma.fundApplication.findUnique({
|
||||||
|
where: { id: params.applicationId },
|
||||||
|
include: { user: { select: { id: true, username: true } } },
|
||||||
|
})
|
||||||
|
if (!application) {
|
||||||
|
return NextResponse.json({ error: 'Application not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
if (body.action === 'deny') {
|
||||||
|
await prisma.fundApplication.delete({ where: { id: params.applicationId } })
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = approveSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startingBalance } = parsed.data
|
||||||
|
const name = application.fundName
|
||||||
|
const slug = toSlug(name)
|
||||||
|
const shadowUsername = `fund:${slug}`
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
const [existingFund, existingSlug, existingUser] = await Promise.all([
|
||||||
|
prisma.hedgeFund.findFirst({ where: { name: { equals: name, mode: 'insensitive' } } }),
|
||||||
|
prisma.hedgeFund.findUnique({ where: { slug } }),
|
||||||
|
prisma.user.findUnique({ where: { username: shadowUsername } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (existingFund) return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
|
||||||
|
if (existingSlug) return NextResponse.json({ error: 'A fund with that slug already exists.' }, { status: 409 })
|
||||||
|
if (existingUser) return NextResponse.json({ error: 'Shadow user conflict.' }, { status: 409 })
|
||||||
|
|
||||||
|
const fund = await prisma.$transaction(async (tx) => {
|
||||||
|
const shadowUser = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
username: shadowUsername,
|
||||||
|
displayUsername: name,
|
||||||
|
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10),
|
||||||
|
balance: startingBalance,
|
||||||
|
isFund: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newFund = await tx.hedgeFund.create({
|
||||||
|
data: { name, slug, userId: shadowUser.id, sharesOutstanding: 0 },
|
||||||
|
include: {
|
||||||
|
user: { select: { balance: true } },
|
||||||
|
managers: { include: { user: { select: { id: true, username: true, displayUsername: true } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.fundManager.create({
|
||||||
|
data: { fundId: newFund.id, userId: application.userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.fundApplication.delete({ where: { id: application.id } })
|
||||||
|
|
||||||
|
return { ...newFund, managers: [{ id: 'new', userId: application.userId, user: application.user }] }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(fund, { status: 201 })
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
const patchSchema = z.object({
|
const patchSchema = z.object({
|
||||||
addManagerUsername: z.string().optional(),
|
addManagerUsername: z.string().optional(),
|
||||||
@@ -57,7 +58,7 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof balance === 'number') {
|
if (typeof balance === 'number') {
|
||||||
await prisma.user.update({ where: { id: fund.userId }, data: { balance } })
|
await prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(balance) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.hedgeFund.findUnique({
|
const updated = await prisma.hedgeFund.findUnique({
|
||||||
@@ -87,10 +88,45 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fund = await prisma.hedgeFund.findUnique({ where: { id: params.fundId } })
|
const fund = await prisma.hedgeFund.findUnique({
|
||||||
|
where: { id: params.fundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
investments: { select: { userId: true, shares: true } },
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
|
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
|
||||||
|
|
||||||
// Delete shadow user cascades positions, trades, and the fund record
|
// Compute mark-to-market NAV so investors are paid their fair share
|
||||||
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
|
||||||
|
|
||||||
|
// Pay out each investor at current NAV before wiping records
|
||||||
|
for (const inv of fund.investments) {
|
||||||
|
const payout = Math.max(0, round2(inv.shares * nav))
|
||||||
|
if (payout > 0) {
|
||||||
|
await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete fund first (FK constraint), then shadow user (cascades positions/trades)
|
||||||
|
await prisma.hedgeFund.delete({ where: { id: fund.id } })
|
||||||
await prisma.user.delete({ where: { id: fund.userId } })
|
await prisma.user.delete({ where: { id: fund.userId } })
|
||||||
|
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
|
|||||||
@@ -61,12 +61,13 @@ export async function POST(req: NextRequest) {
|
|||||||
displayUsername: name,
|
displayUsername: name,
|
||||||
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable
|
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable
|
||||||
balance: initialBalance,
|
balance: initialBalance,
|
||||||
|
researchPoints: 0,
|
||||||
isFund: true,
|
isFund: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return tx.hedgeFund.create({
|
return tx.hedgeFund.create({
|
||||||
data: { name, slug, userId: shadowUser.id },
|
data: { name, slug, userId: shadowUser.id, sharesOutstanding: initialBalance },
|
||||||
include: { user: { select: { balance: true } }, managers: true },
|
include: { user: { select: { balance: true } }, managers: true },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue'
|
import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
|
||||||
import { Queue } from 'bullmq'
|
import { Queue } from 'bullmq'
|
||||||
|
|
||||||
const QUEUES: Record<string, Queue> = {
|
const QUEUES: Record<string, Queue> = {
|
||||||
'hashex-price-updates': priceUpdateQueue,
|
'hashex-price-updates': priceUpdateQueue,
|
||||||
'hashex-maintenance': maintenanceQueue,
|
'hashex-maintenance': maintenanceQueue,
|
||||||
'hashex-scheduler': schedulerQueue,
|
'hashex-scheduler': schedulerQueue,
|
||||||
|
'hashex-fund-nav-snapshot': fundNavSnapshotQueue,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job name to add when manually triggering each queue
|
||||||
|
const TRIGGER_JOB: Record<string, string> = {
|
||||||
|
'hashex-scheduler': 'trigger-sweep',
|
||||||
|
'hashex-maintenance': 'daily-maintenance',
|
||||||
|
'hashex-fund-nav-snapshot': 'fund-nav-snapshot',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -36,5 +44,12 @@ export async function POST(
|
|||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'trigger') {
|
||||||
|
const jobName = TRIGGER_JOB[params.name]
|
||||||
|
if (!jobName) return NextResponse.json({ error: 'Queue does not support manual trigger' }, { status: 400 })
|
||||||
|
await queue.add(jobName, {}, { jobId: `manual-${Date.now()}` })
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
|
const STARTING_BALANCE = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/users/[userId]/reset
|
||||||
|
* Body: { keepHistory?: boolean }
|
||||||
|
*
|
||||||
|
* Admin-only. Resets a user's account. See /api/user/me/reset for full docs.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } },
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const keepHistory = body.keepHistory === true
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: params.userId },
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
fundId: true,
|
||||||
|
shares: true,
|
||||||
|
fund: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioValue = user.positions.reduce((sum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const fundInvestmentValue = user.fundInvestments.reduce((sum, inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return psum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
return sum + Math.max(0, inv.shares * nav)
|
||||||
|
}, 0)
|
||||||
|
const totalValue = user.balance + portfolioValue + fundInvestmentValue
|
||||||
|
|
||||||
|
// Forfeit all fund investments — decrement sharesOutstanding and withdraw cash from fund
|
||||||
|
const fundUpdates = user.fundInvestments
|
||||||
|
.filter((inv) => inv.shares > 0)
|
||||||
|
.flatMap((inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return psum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
const payout = Math.max(0, round2(inv.shares * nav))
|
||||||
|
return [
|
||||||
|
prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
}),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: inv.fund.userId },
|
||||||
|
data: { balance: { decrement: payout } },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tradeOps = keepHistory
|
||||||
|
? [
|
||||||
|
totalValue >= STARTING_BALANCE
|
||||||
|
? prisma.trade.create({
|
||||||
|
data: { userId: params.userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
|
||||||
|
})
|
||||||
|
: prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId: params.userId,
|
||||||
|
type: 'BANKRUPTCY',
|
||||||
|
shares: 0,
|
||||||
|
price: 0,
|
||||||
|
total: STARTING_BALANCE - totalValue,
|
||||||
|
profit: STARTING_BALANCE - totalValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: params.userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE, createdAt: new Date(Date.now() + 1000) },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
prisma.trade.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: params.userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...fundUpdates,
|
||||||
|
prisma.fundInvestment.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.position.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.userPortfolioHistory.deleteMany({ where: { userId: params.userId } }),
|
||||||
|
prisma.user.update({ where: { id: params.userId }, data: { balance: STARTING_BALANCE } }),
|
||||||
|
...tradeOps,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
balance: z.number().min(0).optional(),
|
balance: z.number().min(0).optional(),
|
||||||
@@ -37,3 +38,72 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
|
|||||||
|
|
||||||
return NextResponse.json(updated)
|
return NextResponse.json(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/users/[userId]
|
||||||
|
* Permanently deletes a user account.
|
||||||
|
* Fund investments are reconciled (sharesOutstanding decremented) before cascade deletion.
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: params.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Use the fund deletion endpoint to remove fund accounts.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem each fund investment at current NAV — deduct payout from fund cash
|
||||||
|
for (const inv of user.fundInvestments) {
|
||||||
|
const fund = await prisma.hedgeFund.findUnique({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!fund) continue
|
||||||
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
|
||||||
|
const payout = round2(Math.max(0, inv.shares * nav))
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: round2(Math.max(0, payout)) } } }),
|
||||||
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: params.userId } })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,9 +36,20 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12)
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
|
||||||
await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: { username, displayUsername, passwordHash },
|
data: { username, displayUsername, passwordHash },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'ACCOUNT_OPEN',
|
||||||
|
shares: 0,
|
||||||
|
price: 0,
|
||||||
|
total: 2000,
|
||||||
|
profit: 2000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ ok: true }, { status: 201 })
|
return NextResponse.json({ ok: true }, { status: 201 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const submitSchema = z.object({
|
||||||
|
fundName: z.string().min(1).max(60),
|
||||||
|
reason: z.string().min(10).max(1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/fund-applications
|
||||||
|
* Returns the current user's pending application, or null.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const application = await prisma.fundApplication.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(application)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/fund-applications
|
||||||
|
* Submit a fund application. One per user at a time.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
const parsed = submitSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fundName, reason } = parsed.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
const application = await prisma.fundApplication.create({
|
||||||
|
data: { userId: session.user.id, fundName: fundName.trim(), reason: reason.trim() },
|
||||||
|
})
|
||||||
|
return NextResponse.json(application, { status: 201 })
|
||||||
|
} catch {
|
||||||
|
// Unique constraint violation — already has a pending application
|
||||||
|
return NextResponse.json({ error: 'You already have a pending application.' }, { status: 409 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/fund-applications
|
||||||
|
* Withdraw the current user's pending application.
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
await prisma.fundApplication.deleteMany({ where: { userId: session.user.id } })
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { calcFundNav } from '@/lib/pricing'
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
@@ -10,7 +10,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const slug = decodeURIComponent(params.slug).toLowerCase()
|
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const amount = Number(body.amount)
|
const amount = round2(Number(body.amount))
|
||||||
|
|
||||||
if (!amount || amount < 1) {
|
if (!amount || amount < 1) {
|
||||||
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
|
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
|
||||||
@@ -45,12 +45,12 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
const val = p.positionType === 'LONG'
|
const val = p.positionType === 'LONG'
|
||||||
? p.shares * p.hashtag.currentPrice
|
? p.shares * p.hashtag.currentPrice
|
||||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
return sum + val
|
return sum + val
|
||||||
}, 0)
|
}, 0)
|
||||||
const totalValue = fund.user.balance + portfolioValue
|
const totalValue = fund.user.balance + portfolioValue
|
||||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||||
const sharesToMint = amount / nav
|
const sharesToMint = Math.round((amount / nav) * 1e6) / 1e6
|
||||||
|
|
||||||
// Weighted average NAV at buy for display
|
// Weighted average NAV at buy for display
|
||||||
const existingInvestment = await prisma.fundInvestment.findUnique({
|
const existingInvestment = await prisma.fundInvestment.findUnique({
|
||||||
@@ -68,9 +68,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const [updatedInvestor] = await prisma.$transaction([
|
const [updatedInvestor] = await prisma.$transaction([
|
||||||
// Deduct from investor (returns updated user with new balance)
|
// Deduct from investor (returns updated user with new balance)
|
||||||
prisma.user.update({ where: { id: session.user.id }, data: { balance: { decrement: amount } } }),
|
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance - amount) } }),
|
||||||
// Add to fund's cash
|
// Add to fund's cash
|
||||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }),
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance + amount) } }),
|
||||||
// Upsert FundInvestment record
|
// Upsert FundInvestment record
|
||||||
prisma.fundInvestment.upsert({
|
prisma.fundInvestment.upsert({
|
||||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||||
@@ -79,6 +79,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
}),
|
}),
|
||||||
// Increment fund shares outstanding
|
// Increment fund shares outstanding
|
||||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
|
||||||
|
// Log trade in activity history
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_INVEST', shares: sharesToMint, price: nav, total: amount, profit: 0 },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { calcFundNav } from '@/lib/pricing'
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
@@ -35,34 +35,38 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const investment = await prisma.fundInvestment.findUnique({
|
const investment = await prisma.fundInvestment.findUnique({
|
||||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||||
select: { shares: true },
|
select: { shares: true, avgNavAtBuy: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!investment || investment.shares < sharesToRedeem) {
|
if (!investment || investment.shares < sharesToRedeem) {
|
||||||
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
|
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true } })
|
||||||
|
if (!investor) return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
|
||||||
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
const val = p.positionType === 'LONG'
|
const val = p.positionType === 'LONG'
|
||||||
? p.shares * p.hashtag.currentPrice
|
? p.shares * p.hashtag.currentPrice
|
||||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
return sum + val
|
return sum + val
|
||||||
}, 0)
|
}, 0)
|
||||||
const totalValue = fund.user.balance + portfolioValue
|
const totalValue = fund.user.balance + portfolioValue
|
||||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||||
const payout = sharesToRedeem * nav
|
const payout = round2(sharesToRedeem * nav)
|
||||||
|
|
||||||
if (fund.user.balance < payout) {
|
if (fund.user.balance < payout) {
|
||||||
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
|
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingShares = investment.shares - sharesToRedeem
|
const remainingShares = Math.round((investment.shares - sharesToRedeem) * 1e6) / 1e6
|
||||||
|
const profit = round2(payout - sharesToRedeem * investment.avgNavAtBuy)
|
||||||
|
|
||||||
const [updatedInvestor] = await prisma.$transaction([
|
const [updatedInvestor] = await prisma.$transaction([
|
||||||
// Return cash to investor
|
// Return cash to investor
|
||||||
prisma.user.update({ where: { id: session.user.id }, data: { balance: { increment: payout } } }),
|
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance + payout) } }),
|
||||||
// Deduct from fund's cash
|
// Deduct from fund's cash
|
||||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance - payout) } }),
|
||||||
// Update or delete FundInvestment
|
// Update or delete FundInvestment
|
||||||
...(remainingShares > 0
|
...(remainingShares > 0
|
||||||
? [prisma.fundInvestment.update({
|
? [prisma.fundInvestment.update({
|
||||||
@@ -74,6 +78,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
})]),
|
})]),
|
||||||
// Decrement fund shares outstanding
|
// Decrement fund shares outstanding
|
||||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
|
||||||
|
// Log trade in activity history
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_REDEEM', shares: sharesToRedeem, price: nav, total: payout, profit },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { round2 } from '@/lib/pricing'
|
||||||
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
|
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
|
||||||
|
|
||||||
function buildPrizes(): number[] {
|
function buildPrizes(): number[] {
|
||||||
@@ -15,9 +16,9 @@ function buildPrizes(): number[] {
|
|||||||
|
|
||||||
function isSameDay(a: Date, b: Date) {
|
function isSameDay(a: Date, b: Date) {
|
||||||
return (
|
return (
|
||||||
a.getUTCFullYear() === b.getUTCFullYear() &&
|
a.getFullYear() === b.getFullYear() &&
|
||||||
a.getUTCMonth() === b.getUTCMonth() &&
|
a.getMonth() === b.getMonth() &&
|
||||||
a.getUTCDate() === b.getUTCDate()
|
a.getDate() === b.getDate()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ function isSameDay(a: Date, b: Date) {
|
|||||||
* POST /api/lottery/pick
|
* POST /api/lottery/pick
|
||||||
* Body: { box: number } (0-indexed, 0–24)
|
* Body: { box: number } (0-indexed, 0–24)
|
||||||
*
|
*
|
||||||
* One free play per calendar day (UTC). Reveals prize at the chosen box.
|
* One free play per calendar day (Eastern Time). Reveals prize at the chosen box.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
@@ -68,7 +69,7 @@ export async function POST(req: NextRequest) {
|
|||||||
prisma.user.update({
|
prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
balance: { increment: winAmount },
|
balance: { increment: round2(winAmount) },
|
||||||
lastLotteryAt: now,
|
lastLotteryAt: now,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const W = 1200
|
||||||
|
const H = 630
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: { slug: string } },
|
||||||
|
) {
|
||||||
|
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||||
|
|
||||||
|
const fund = await prisma.hedgeFund.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
positionType: true,
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
managers: { select: { userId: true } },
|
||||||
|
_count: { select: { investments: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const name = fund?.name ?? slug
|
||||||
|
const cash = fund?.user.balance ?? 0
|
||||||
|
const portfolioValue = fund?.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0) ?? 0
|
||||||
|
const totalValue = cash + portfolioValue
|
||||||
|
const nav = fund ? calcFundNav(totalValue, fund.sharesOutstanding) : 1
|
||||||
|
const managerCount = fund?.managers.length ?? 0
|
||||||
|
const investorCount = fund?._count.investments ?? 0
|
||||||
|
const openPositions = fund?.user.positions.length ?? 0
|
||||||
|
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
|
||||||
|
}).format(n)
|
||||||
|
|
||||||
|
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||||
|
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||||
|
{/* Branding + badge */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
|
||||||
|
<div style={{ fontSize: 20, color: '#818cf8', background: '#312e81', borderRadius: 8, padding: '6px 16px' }}>Hedge Fund</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fund name */}
|
||||||
|
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>{name}</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div style={{ display: 'flex', gap: 28, flex: 1 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Total Value', value: fmt(totalValue), color: '#ffffff' },
|
||||||
|
{ label: 'NAV / Share', value: fmt(nav), color: '#ffffff' },
|
||||||
|
{ label: 'Cash', value: fmt(cash), color: '#94a3b8' },
|
||||||
|
{ label: 'Positions', value: String(openPositions), color: '#94a3b8' },
|
||||||
|
{ label: 'Managers', value: String(managerCount), color: '#94a3b8' },
|
||||||
|
{ label: 'Investors', value: String(investorCount), color: '#94a3b8' },
|
||||||
|
].map(({ label, value, color }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 20px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 16, color: '#475569', marginBottom: 8 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
|
||||||
|
<span>{host}</span>
|
||||||
|
<span>Trade hashtags like stocks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ width: W, height: H },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const W = 1200
|
||||||
|
const H = 630
|
||||||
|
const CHART_X = 60
|
||||||
|
const CHART_Y = 200
|
||||||
|
const CHART_W = W - 120
|
||||||
|
const CHART_H = 220
|
||||||
|
|
||||||
|
function buildPolyline(prices: number[]): string {
|
||||||
|
if (prices.length < 2) return ''
|
||||||
|
const min = Math.min(...prices)
|
||||||
|
const max = Math.max(...prices)
|
||||||
|
const range = max - min || 1
|
||||||
|
return prices
|
||||||
|
.map((p, i) => {
|
||||||
|
const x = CHART_X + (i / (prices.length - 1)) * CHART_W
|
||||||
|
const y = CHART_Y + CHART_H - ((p - min) / range) * CHART_H
|
||||||
|
return `${x},${y}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: { tag: string } },
|
||||||
|
) {
|
||||||
|
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
||||||
|
|
||||||
|
const hashtag = await prisma.hashtag.findUnique({
|
||||||
|
where: { tag },
|
||||||
|
select: {
|
||||||
|
displayTag: true,
|
||||||
|
currentPrice: true,
|
||||||
|
isActive: true,
|
||||||
|
priceHistory: {
|
||||||
|
orderBy: { recordedAt: 'desc' },
|
||||||
|
take: 48,
|
||||||
|
select: { price: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayTag = hashtag?.displayTag ?? tag
|
||||||
|
const price = hashtag?.currentPrice ?? 0.25
|
||||||
|
const prices = (hashtag?.priceHistory ?? []).map((p) => p.price).reverse()
|
||||||
|
const prevPrice = prices.length >= 2 ? prices[0] : null
|
||||||
|
const changePct = prevPrice && prevPrice > 0
|
||||||
|
? ((price - prevPrice) / prevPrice) * 100
|
||||||
|
: null
|
||||||
|
const trending = changePct === null ? null : changePct >= 0
|
||||||
|
const lineColor = trending === null ? '#6366f1' : trending ? '#34d399' : '#f87171'
|
||||||
|
const changeStr = changePct === null
|
||||||
|
? ''
|
||||||
|
: `${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%`
|
||||||
|
|
||||||
|
const priceStr = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(price)
|
||||||
|
|
||||||
|
const polyline = buildPolyline(prices)
|
||||||
|
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||||
|
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
background: '#0f0f17',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '60px',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ fontSize: 28, color: '#6366f1', marginBottom: 8 }}>HashEx</div>
|
||||||
|
<div style={{ fontSize: 72, fontWeight: 700, color: '#ffffff' }}>
|
||||||
|
{'#' + displayTag}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff' }}>{priceStr}</div>
|
||||||
|
{changeStr && (
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 600, color: lineColor, marginTop: 4 }}>
|
||||||
|
{changeStr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hashtag?.isActive && (
|
||||||
|
<div style={{ fontSize: 22, color: '#f97316', marginTop: 8 }}>inactive</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sparkline */}
|
||||||
|
{prices.length >= 2 && (
|
||||||
|
<svg
|
||||||
|
width={W}
|
||||||
|
height={CHART_H + 40}
|
||||||
|
style={{ position: 'absolute', left: 0, top: 280 }}
|
||||||
|
>
|
||||||
|
{/* Subtle grid line at mid-price */}
|
||||||
|
<line
|
||||||
|
x1={CHART_X}
|
||||||
|
y1={CHART_Y + CHART_H / 2}
|
||||||
|
x2={CHART_X + CHART_W}
|
||||||
|
y2={CHART_Y + CHART_H / 2}
|
||||||
|
stroke="#1e1e2e"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points={polyline}
|
||||||
|
fill="none"
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 40,
|
||||||
|
left: 60,
|
||||||
|
right: 60,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
color: '#475569',
|
||||||
|
fontSize: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{host}</span>
|
||||||
|
<span>Trade hashtags like stocks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ width: W, height: H },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const W = 1200
|
||||||
|
const H = 630
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { isFund: false, isHidden: false },
|
||||||
|
select: {
|
||||||
|
displayUsername: true,
|
||||||
|
username: true,
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: { shares: true, hashtag: { select: { currentPrice: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ranked = users
|
||||||
|
.map((u) => ({
|
||||||
|
name: u.displayUsername ?? u.username,
|
||||||
|
netWorth: u.balance + u.positions.reduce((s, p) => s + p.shares * p.hashtag.currentPrice, 0),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.netWorth - a.netWorth)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency', currency: 'USD', notation: n >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
|
||||||
|
}).format(n)
|
||||||
|
|
||||||
|
const medals = ['🥇', '🥈', '🥉', '4.', '5.']
|
||||||
|
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||||
|
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 40 }}>
|
||||||
|
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
|
||||||
|
<div style={{ fontSize: 42, fontWeight: 700, color: '#ffffff' }}>🏆 Leaderboard</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 5 rows */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||||
|
{ranked.map((u, i) => (
|
||||||
|
<div key={u.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: i === 0 ? '#1c1a08' : '#1a1a2e', border: `1px solid ${i === 0 ? '#854d0e' : '#1e2035'}`, borderRadius: 14, padding: '18px 28px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||||
|
<span style={{ fontSize: 28, width: 40 }}>{medals[i]}</span>
|
||||||
|
<span style={{ fontSize: 30, fontWeight: 600, color: i === 0 ? '#fde68a' : '#e2e8f0' }}>{u.name}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 30, fontWeight: 700, color: i === 0 ? '#fde68a' : '#ffffff' }}>{fmt(u.netWorth)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 32 }}>
|
||||||
|
<span>{host}</span>
|
||||||
|
<span>Trade hashtags like stocks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ width: W, height: H },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
const W = 1200
|
||||||
|
const H = 630
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: { username: string } },
|
||||||
|
) {
|
||||||
|
const username = decodeURIComponent(params.username).toLowerCase()
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: {
|
||||||
|
displayUsername: true,
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
positionType: true,
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { trades: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayName = user?.displayUsername ?? username
|
||||||
|
const balance = user?.balance ?? 0
|
||||||
|
const portfolioValue = user?.positions.reduce((sum, p) => sum + p.shares * p.hashtag.currentPrice, 0) ?? 0
|
||||||
|
const netWorth = balance + portfolioValue
|
||||||
|
const unrealizedPnl = user?.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) ?? 0
|
||||||
|
const tradeCount = user?._count.trades ?? 0
|
||||||
|
const openPositions = user?.positions.length ?? 0
|
||||||
|
|
||||||
|
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
|
||||||
|
}).format(n)
|
||||||
|
|
||||||
|
const pnlColor = unrealizedPnl >= 0 ? '#34d399' : '#f87171'
|
||||||
|
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||||
|
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
|
||||||
|
{/* Branding */}
|
||||||
|
<div style={{ fontSize: 26, color: '#6366f1', marginBottom: 24 }}>HashEx</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ fontSize: 68, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>
|
||||||
|
{'@' + displayName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div style={{ display: 'flex', gap: 32, flex: 1 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Net Worth', value: fmt(netWorth), color: '#ffffff' },
|
||||||
|
{ label: 'Cash', value: fmt(balance), color: '#94a3b8' },
|
||||||
|
{ label: 'Unrealized P&L', value: fmt(unrealizedPnl), color: pnlColor },
|
||||||
|
{ label: 'Open Positions', value: String(openPositions), color: '#94a3b8' },
|
||||||
|
{ label: 'Total Trades', value: String(tradeCount), color: '#94a3b8' },
|
||||||
|
].map(({ label, value, color }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 28px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 18, color: '#475569', marginBottom: 8 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 700, color }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
|
||||||
|
<span>{host}</span>
|
||||||
|
<span>Trade hashtags like stocks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ width: W, height: H },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getPostsPerHour } from '@/lib/mastodon'
|
import { getPostsData } from '@/lib/mastodon'
|
||||||
import { calcPrice } from '@/lib/pricing'
|
import { calcPrice } from '@/lib/pricing'
|
||||||
import { normalizeTag } from '@/lib/utils'
|
import { normalizeTag } from '@/lib/utils'
|
||||||
import { priceUpdateQueue } from '@/lib/queue'
|
import { priceUpdateQueue } from '@/lib/queue'
|
||||||
@@ -49,8 +49,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// Query Mastodon
|
// Query Mastodon
|
||||||
let postsPerHour = 0
|
let postsPerHour = 0
|
||||||
|
let hasAnyPosts = false
|
||||||
try {
|
try {
|
||||||
postsPerHour = await getPostsPerHour(tag)
|
const data = await getPostsData(tag)
|
||||||
|
postsPerHour = data.postsPerHour
|
||||||
|
hasAnyPosts = data.hasAnyPosts
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[research] Mastodon fetch failed:', err)
|
console.error('[research] Mastodon fetch failed:', err)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -59,18 +62,19 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postsPerHour === 0) {
|
if (!hasAnyPosts) {
|
||||||
// Deduct point for failed research
|
// Deduct point for failed research
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { researchPoints: { decrement: 1 } },
|
data: { researchPoints: { decrement: 1 } },
|
||||||
})
|
})
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'No recent posts found for this hashtag. Research point spent.' },
|
{ error: 'No posts found for this hashtag anywhere recently. Research point spent.' },
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the last-hour price, or $0.25 minimum if active but currently quiet
|
||||||
const price = calcPrice(postsPerHour)
|
const price = calcPrice(postsPerHour)
|
||||||
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
|
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { calcTrade } from '@/lib/pricing'
|
import { calcTrade, round2 } from '@/lib/pricing'
|
||||||
|
import { formatCurrency } from '@/lib/utils'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const MAX_POSITION_SHARES = parseInt(process.env.MAX_POSITION_SHARES ?? '100', 10)
|
||||||
|
const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE ?? '1000', 10)
|
||||||
|
const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
|
||||||
|
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10)
|
||||||
|
|
||||||
const tradeSchema = z.object({
|
const tradeSchema = z.object({
|
||||||
hashtagId: z.string().min(1),
|
hashtagId: z.string().min(1),
|
||||||
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
|
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
|
||||||
@@ -63,6 +69,25 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 })
|
return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'BUY_LONG' || type === 'BUY_SHORT') {
|
||||||
|
const maxShares = fundId ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES
|
||||||
|
const maxValue = fundId ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE
|
||||||
|
const newTotalShares = (existingPosition?.shares ?? 0) + shares
|
||||||
|
const newTotalValue = newTotalShares * hashtag.currentPrice
|
||||||
|
if (newTotalShares > maxShares) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Position limit: max ${maxShares.toLocaleString()} shares per hashtag.` },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (newTotalValue > maxValue) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Position limit: max ${formatCurrency(maxValue)} position value per hashtag.` },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'SELL_LONG') {
|
if (type === 'SELL_LONG') {
|
||||||
if (!existingPosition || existingPosition.shares < shares) {
|
if (!existingPosition || existingPosition.shares < shares) {
|
||||||
return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 })
|
return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 })
|
||||||
@@ -80,7 +105,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// Update user balance
|
// Update user balance
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { balance: { increment: balanceDelta } },
|
data: { balance: { increment: round2(balanceDelta) } },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update / create position
|
// Update / create position
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
|
const STARTING_BALANCE = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/me/reset
|
||||||
|
* Body: { keepHistory?: boolean }
|
||||||
|
*
|
||||||
|
* Resets the current user's account. Deletes positions, fund investments, and
|
||||||
|
* portfolio history. When keepHistory is true the existing trade log is
|
||||||
|
* preserved and two bookmark entries are appended:
|
||||||
|
* • DONATION — user was in the green (totalValue ≥ $2k): records the value donated
|
||||||
|
* • BANKRUPTCY — user was in the red (totalValue < $2k): records the debt cleared
|
||||||
|
* • ACCOUNT_OPEN — always appended, records the fresh $2k grant
|
||||||
|
* When keepHistory is false all trades are also deleted.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const keepHistory = body.keepHistory === true
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
fundId: true,
|
||||||
|
shares: true,
|
||||||
|
fund: {
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
if (user.isFund) {
|
||||||
|
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolioValue = user.positions.reduce((sum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const fundInvestmentValue = user.fundInvestments.reduce((sum, inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return psum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
return sum + Math.max(0, inv.shares * nav)
|
||||||
|
}, 0)
|
||||||
|
const totalValue = user.balance + portfolioValue + fundInvestmentValue
|
||||||
|
|
||||||
|
// Forfeit all fund investments — decrement sharesOutstanding and withdraw cash from fund
|
||||||
|
const fundUpdates = user.fundInvestments
|
||||||
|
.filter((inv) => inv.shares > 0)
|
||||||
|
.flatMap((inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
|
||||||
|
const val =
|
||||||
|
p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return psum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
const payout = Math.max(0, round2(inv.shares * nav))
|
||||||
|
return [
|
||||||
|
prisma.hedgeFund.update({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
data: { sharesOutstanding: { decrement: inv.shares } },
|
||||||
|
}),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: inv.fund.userId },
|
||||||
|
data: { balance: { decrement: payout } },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tradeOps = keepHistory
|
||||||
|
? [
|
||||||
|
totalValue >= STARTING_BALANCE
|
||||||
|
? prisma.trade.create({
|
||||||
|
data: { userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
|
||||||
|
})
|
||||||
|
: prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'BANKRUPTCY',
|
||||||
|
shares: 0,
|
||||||
|
price: 0,
|
||||||
|
total: STARTING_BALANCE - totalValue,
|
||||||
|
profit: STARTING_BALANCE - totalValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE, createdAt: new Date(Date.now() + 1000) },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
prisma.trade.deleteMany({ where: { userId } }),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...fundUpdates,
|
||||||
|
prisma.fundInvestment.deleteMany({ where: { userId } }),
|
||||||
|
prisma.position.deleteMany({ where: { userId } }),
|
||||||
|
prisma.userPortfolioHistory.deleteMany({ where: { userId } }),
|
||||||
|
prisma.user.update({ where: { id: userId }, data: { balance: STARTING_BALANCE } }),
|
||||||
|
...tradeOps,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { calcFundNav, round2 } from '@/lib/pricing'
|
||||||
|
|
||||||
const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase
|
const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase
|
||||||
|
|
||||||
@@ -90,3 +91,62 @@ export async function PATCH(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true, ...updated })
|
return NextResponse.json({ ok: true, ...updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/me
|
||||||
|
* Permanently deletes the authenticated user's account.
|
||||||
|
* Fund investments are reconciled (sharesOutstanding decremented) before deletion.
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
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: {
|
||||||
|
id: true,
|
||||||
|
isFund: true,
|
||||||
|
fundInvestments: { select: { fundId: true, shares: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
if (user.isFund) return NextResponse.json({ error: 'Fund accounts cannot be self-deleted.' }, { status: 400 })
|
||||||
|
|
||||||
|
// Redeem each fund investment at current NAV — deduct payout from fund cash
|
||||||
|
for (const inv of user.fundInvestments) {
|
||||||
|
const fund = await prisma.hedgeFund.findUnique({
|
||||||
|
where: { id: inv.fundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!fund) continue
|
||||||
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
|
||||||
|
const payout = round2(Math.max(0, inv.shares * nav))
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
|
||||||
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: session.user.id } })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
|
|||||||
</div>
|
</div>
|
||||||
{amountNum >= 1 && (
|
{amountNum >= 1 && (
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
You'll receive ≈ <span className="text-white font-medium">{previewShares.toFixed(4)} shares</span>
|
You'll receive ≈ <span className="text-white font-medium">{previewShares.toFixed(6)} shares</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -154,7 +154,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.0001"
|
step="0.000001"
|
||||||
max={userShares}
|
max={userShares}
|
||||||
value={shares}
|
value={shares}
|
||||||
onChange={(e) => setShares(e.target.value)}
|
onChange={(e) => setShares(e.target.value)}
|
||||||
@@ -167,7 +167,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
|
|||||||
onClick={() => setShares(String(userShares))}
|
onClick={() => setShares(String(userShares))}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
|
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
|
||||||
>
|
>
|
||||||
Max ({userShares.toFixed(4)})
|
Max ({userShares.toFixed(6)})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,11 +6,33 @@ import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
|
|||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
import { calcFundNav } from '@/lib/pricing'
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
import InvestPanel from './InvestPanel'
|
import InvestPanel from './InvestPanel'
|
||||||
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
|
||||||
|
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||||
|
const fund = await prisma.hedgeFund.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { name: true },
|
||||||
|
})
|
||||||
|
const name = fund?.name ?? slug
|
||||||
|
const title = `${name} — HashEx Hedge Fund`
|
||||||
|
const description = `${name} is a hedge fund on HashEx trading Mastodon hashtags. View their portfolio, NAV, and performance.`
|
||||||
|
const imageUrl = `/api/og/fund/${encodeURIComponent(slug)}`
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${name} fund overview` }] },
|
||||||
|
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function FundPage({ params }: { params: { slug: string } }) {
|
export default async function FundPage({ params }: { params: { slug: string } }) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
const slug = decodeURIComponent(params.slug).toLowerCase()
|
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||||
@@ -42,6 +64,16 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
|
|
||||||
if (!fund) notFound()
|
if (!fund) notFound()
|
||||||
|
|
||||||
|
// Fetch NAV history for the chart (last 7 days)
|
||||||
|
const navHistory = await prisma.fundNavHistory.findMany({
|
||||||
|
where: {
|
||||||
|
fundId: fund.id,
|
||||||
|
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
orderBy: { recordedAt: 'asc' },
|
||||||
|
select: { nav: true, recordedAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
// Fetch current user's balance and investment in this fund
|
// Fetch current user's balance and investment in this fund
|
||||||
const [currentUser, userInvestment] = session
|
const [currentUser, userInvestment] = session
|
||||||
? await Promise.all([
|
? await Promise.all([
|
||||||
@@ -77,6 +109,7 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -97,6 +130,15 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NAV history chart */}
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||||
|
<h2 className="text-sm font-medium text-slate-400 mb-3">NAV / Share — Last 7 Days</h2>
|
||||||
|
<PriceChart
|
||||||
|
data={navHistory.map((p) => ({ price: p.nav, recordedAt: p.recordedAt.toISOString() }))}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
{[
|
{[
|
||||||
@@ -125,7 +167,7 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
Search for a hashtag below and trade using the fund's balance.
|
Search for a hashtag below and trade using the fund's balance.
|
||||||
All positions and profit belong to the fund.
|
All positions and profit belong to the fund.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{positions.map((p) => (
|
{positions.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Building2, Clock, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
existing: { fundName: string; reason: string; createdAt: string } | null
|
||||||
|
managedFund: { name: string; slug: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FundApplicationClient({ existing, managedFund }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [fundName, setFundName] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [withdrawing, setWithdrawing] = useState(false)
|
||||||
|
|
||||||
|
// Pending application
|
||||||
|
if (existing) {
|
||||||
|
async function withdraw() {
|
||||||
|
setWithdrawing(true)
|
||||||
|
await fetch('/api/fund-applications', { method: 'DELETE' })
|
||||||
|
setWithdrawing(false)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{managedFund && (
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
You already manage{' '}
|
||||||
|
<Link href={`/fund/${managedFund.slug}`} className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
|
||||||
|
{managedFund.name}
|
||||||
|
</Link>
|
||||||
|
. You can still apply for an additional fund.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-surface-card border border-indigo-500/30 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-5 w-5 text-amber-400" />
|
||||||
|
<p className="font-medium">Application pending review</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs uppercase tracking-wide">Fund Name</span>
|
||||||
|
<p className="text-white mt-0.5">{existing.fundName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-xs uppercase tracking-wide">Reason</span>
|
||||||
|
<p className="text-slate-300 mt-0.5 whitespace-pre-wrap">{existing.reason}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Submitted {new Date(existing.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={withdraw}
|
||||||
|
disabled={withdrawing}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{withdrawing ? 'Withdrawing…' : 'Withdraw application'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!fundName.trim() || !reason.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
const res = await fetch('/api/fund-applications', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fundName: fundName.trim(), reason: reason.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
setLoading(false)
|
||||||
|
if (!res.ok) { setError(data.error ?? 'Failed to submit'); return }
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{managedFund && (
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
You already manage{' '}
|
||||||
|
<Link href={`/fund/${managedFund.slug}`} className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
|
||||||
|
{managedFund.name}
|
||||||
|
</Link>
|
||||||
|
. You can still apply for an additional fund below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-slate-300">
|
||||||
|
<Building2 className="h-5 w-5 text-indigo-400" />
|
||||||
|
<span className="font-medium">New Fund Application</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">
|
||||||
|
Fund Name <span className="normal-case">(max 60 chars)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={fundName}
|
||||||
|
onChange={(e) => setFundName(e.target.value)}
|
||||||
|
maxLength={60}
|
||||||
|
placeholder="TechAlpha Capital"
|
||||||
|
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="text-xs text-slate-500 uppercase tracking-wide block mb-1">
|
||||||
|
Why do you want to run this fund?
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Describe your strategy, what hashtags you plan to focus on, and why you'd be a good fund manager…"
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-600 mt-0.5 text-right">{reason.length}/1000</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !fundName.trim() || reason.trim().length < 10}
|
||||||
|
className="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Submitting…' : 'Submit Application'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import FundApplicationClient from './FundApplicationClient'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function FundApplyPage() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) redirect('/auth/signin?callbackUrl=/fund/apply')
|
||||||
|
|
||||||
|
const [application, managedFund] = await Promise.all([
|
||||||
|
prisma.fundApplication.findUnique({ where: { userId: session.user.id } }),
|
||||||
|
prisma.fundManager.findFirst({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: { fund: { select: { name: true, slug: true } } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto space-y-6 py-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Apply for a Hedge Fund</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
Propose a new fund. Admins will review your application and approve or deny it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FundApplicationClient
|
||||||
|
existing={application ? { fundName: application.fundName, reason: application.reason, createdAt: application.createdAt.toISOString() } : null}
|
||||||
|
managedFund={managedFund ? { name: managedFund.fund.name, slug: managedFund.fund.slug } : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,26 +12,37 @@ interface Props {
|
|||||||
shortPosition: { shares: number; avgBuyPrice: number } | null
|
shortPosition: { shares: number; avgBuyPrice: number } | null
|
||||||
fundId?: string
|
fundId?: string
|
||||||
fundName?: string
|
fundName?: string
|
||||||
|
managedFunds?: { slug: string; name: string }[]
|
||||||
|
maxPositionShares: number
|
||||||
|
maxPositionValue: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
|
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
|
||||||
|
|
||||||
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName }: Props) {
|
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, managedFunds, maxPositionShares, maxPositionValue }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [tab, setTab] = useState<Tab>('BUY_LONG')
|
const [tab, setTab] = useState<Tab>('BUY_LONG')
|
||||||
const [shares, setShares] = useState('')
|
const [shares, setShares] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [showFundMenu, setShowFundMenu] = useState(false)
|
||||||
|
|
||||||
const sharesNum = parseFloat(shares) || 0
|
const sharesNum = parseFloat(shares) || 0
|
||||||
const cost = sharesNum * hashtag.currentPrice
|
const cost = sharesNum * hashtag.currentPrice
|
||||||
|
|
||||||
const maxBuyShares = hashtag.currentPrice > 0 ? Math.floor((balance / hashtag.currentPrice) * 100) / 100 : 0
|
// For buys: max is the lowest of (balance cap, shares cap, value cap) minus existing position
|
||||||
|
const existingBuyShares = tab === 'BUY_LONG' ? (longPosition?.shares ?? 0) : (shortPosition?.shares ?? 0)
|
||||||
|
const remainingShareCap = Math.max(0, maxPositionShares - existingBuyShares)
|
||||||
|
const remainingValueCap = Math.max(0, maxPositionValue - existingBuyShares * hashtag.currentPrice)
|
||||||
|
const sharesFromValueCap = hashtag.currentPrice > 0 ? remainingValueCap / hashtag.currentPrice : 0
|
||||||
|
const sharesFromBalance = hashtag.currentPrice > 0 ? Math.max(0, balance) / hashtag.currentPrice : 0
|
||||||
|
const maxBuyShares = Math.floor(Math.min(remainingShareCap, sharesFromValueCap, sharesFromBalance) * 100) / 100
|
||||||
|
|
||||||
const maxSellShares =
|
const maxSellShares =
|
||||||
tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0
|
tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0
|
||||||
|
|
||||||
const canAfford =
|
const canAfford =
|
||||||
tab === 'BUY_LONG' || tab === 'BUY_SHORT' ? cost <= balance : sharesNum <= (maxSellShares ?? 0)
|
tab === 'BUY_LONG' || tab === 'BUY_SHORT' ? cost <= balance && sharesNum <= maxBuyShares : sharesNum <= (maxSellShares ?? 0)
|
||||||
|
|
||||||
async function handleTrade() {
|
async function handleTrade() {
|
||||||
if (sharesNum <= 0) return
|
if (sharesNum <= 0) return
|
||||||
@@ -56,17 +68,57 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
|
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
|
||||||
{fundName && (
|
{fundName ? (
|
||||||
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
|
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
|
||||||
<span className="text-lg">🏦</span>
|
<span className="text-base">🏦</span>
|
||||||
Trading as <span className="font-semibold">{fundName}</span>
|
<span>Trading as <span className="font-semibold">{fundName}</span></span>
|
||||||
<span className="text-indigo-500 ml-auto">Fund mode</span>
|
<Link
|
||||||
|
href={`/hashtag/${hashtag.tag}`}
|
||||||
|
className="ml-auto text-indigo-400 hover:text-indigo-200 transition-colors"
|
||||||
|
>
|
||||||
|
Exit fund mode ×
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : managedFunds && managedFunds.length === 1 ? (
|
||||||
|
<Link
|
||||||
|
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(managedFunds[0].slug)}`}
|
||||||
|
className="flex items-center gap-2 text-xs border border-surface-border rounded-lg px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-base">🏦</span>
|
||||||
|
<span>Trade as <span className="font-medium text-slate-200">{managedFunds[0].name}</span></span>
|
||||||
|
<span className="ml-auto">→</span>
|
||||||
|
</Link>
|
||||||
|
) : managedFunds && managedFunds.length > 1 ? (
|
||||||
|
<div className="text-xs border border-surface-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFundMenu((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-base">🏦</span>
|
||||||
|
<span>Trade as a fund</span>
|
||||||
|
<span className="ml-auto text-slate-600">{showFundMenu ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showFundMenu && (
|
||||||
|
<div className="border-t border-surface-border divide-y divide-surface-border">
|
||||||
|
{managedFunds.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.slug}
|
||||||
|
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(f.slug)}`}
|
||||||
|
className="flex items-center justify-between px-3 py-2 font-medium text-indigo-300 hover:text-indigo-200 hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span className="text-slate-500">→</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
|
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
|
||||||
<span className="text-sm text-slate-400">
|
<span className="text-sm text-slate-400">
|
||||||
Balance: <span className="text-white font-medium">{formatCurrency(balance)}</span>
|
Balance: <span className={`font-medium ${balance < 0 ? 'text-red-400' : 'text-white'}`}>{formatCurrency(balance)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,7 +128,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => { setTab(t); setShares(''); setError('') }}
|
onClick={() => { setTab(t); setShares(''); setError('') }}
|
||||||
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors ${
|
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors leading-tight ${
|
||||||
tab === t
|
tab === t
|
||||||
? t.startsWith('BUY')
|
? t.startsWith('BUY')
|
||||||
? 'bg-emerald-600 text-white'
|
? 'bg-emerald-600 text-white'
|
||||||
@@ -84,7 +136,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.replace('_', ' ')}
|
<span className="block sm:inline">{t.split('_')[0]}</span>
|
||||||
|
<span className="hidden sm:inline"> </span>
|
||||||
|
<span className="block sm:inline">{t.split('_')[1]}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,23 +147,35 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="bg-surface rounded-lg p-3">
|
<div className="bg-surface rounded-lg p-3">
|
||||||
<p className="text-slate-500 text-xs mb-1">LONG position</p>
|
<p className="text-slate-500 text-xs mb-1">LONG position</p>
|
||||||
{longPosition ? (
|
{longPosition ? (() => {
|
||||||
|
const pnl = (hashtag.currentPrice - longPosition.avgBuyPrice) * longPosition.shares
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
|
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
|
||||||
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
|
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
|
||||||
|
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
)
|
||||||
|
})() : (
|
||||||
<p className="text-slate-600">None</p>
|
<p className="text-slate-600">None</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-surface rounded-lg p-3">
|
<div className="bg-surface rounded-lg p-3">
|
||||||
<p className="text-slate-500 text-xs mb-1">SHORT position</p>
|
<p className="text-slate-500 text-xs mb-1">SHORT position</p>
|
||||||
{shortPosition ? (
|
{shortPosition ? (() => {
|
||||||
|
const pnl = (shortPosition.avgBuyPrice - hashtag.currentPrice) * shortPosition.shares
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
|
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
|
||||||
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
|
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
|
||||||
|
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
)
|
||||||
|
})() : (
|
||||||
<p className="text-slate-600">None</p>
|
<p className="text-slate-600">None</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +194,14 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
|
|||||||
: String(maxSellShares)
|
: String(maxSellShares)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Max
|
{(tab === 'BUY_LONG' || tab === 'BUY_SHORT')
|
||||||
|
? balance <= 0
|
||||||
|
? <span className="text-red-400">Balance is negative</span>
|
||||||
|
: maxBuyShares === 0
|
||||||
|
? 'Max (limit reached)'
|
||||||
|
: 'Max'
|
||||||
|
: 'Max'
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+106
-10
@@ -6,9 +6,19 @@ import { formatCurrency, formatNumber } from '@/lib/utils'
|
|||||||
import { PriceChart } from '@/components/PriceChart'
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
import { TradePanel } from './TradePanel'
|
import { TradePanel } from './TradePanel'
|
||||||
import { ResearchPanel } from './ResearchPanel'
|
import { ResearchPanel } from './ResearchPanel'
|
||||||
import { Hash, Clock, Link as LinkIcon } from 'lucide-react'
|
import { Hash, Clock, Link as LinkIcon, AlertTriangle } from 'lucide-react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
|
|
||||||
|
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
||||||
|
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
||||||
|
const MAX_POSITION_SHARES = parseInt(process.env.MAX_POSITION_SHARES ?? '100', 10)
|
||||||
|
const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE ?? '1000', 10)
|
||||||
|
const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
|
||||||
|
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10)
|
||||||
|
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -17,6 +27,51 @@ interface Props {
|
|||||||
searchParams: { fund?: string }
|
searchParams: { fund?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
||||||
|
const hashtag = await prisma.hashtag.findUnique({
|
||||||
|
where: { tag },
|
||||||
|
select: {
|
||||||
|
displayTag: true,
|
||||||
|
currentPrice: true,
|
||||||
|
isActive: true,
|
||||||
|
priceHistory: { orderBy: { recordedAt: 'desc' }, take: 2, select: { price: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayTag = hashtag?.displayTag ?? tag
|
||||||
|
const price = hashtag?.currentPrice ?? 0
|
||||||
|
const prevPrice = hashtag?.priceHistory[1]?.price ?? null
|
||||||
|
const changePct = prevPrice && prevPrice > 0
|
||||||
|
? ((price - prevPrice) / prevPrice) * 100
|
||||||
|
: null
|
||||||
|
const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 }).format(price)
|
||||||
|
const changeStr = changePct !== null
|
||||||
|
? ` ${changePct >= 0 ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%`
|
||||||
|
: ''
|
||||||
|
const status = hashtag?.isActive === false ? ' · inactive' : ''
|
||||||
|
|
||||||
|
const title = `#${displayTag} — ${priceStr}${changeStr}`
|
||||||
|
const description = `Trade #${displayTag} on HashEx. Current price: ${priceStr}${changeStr}${status}. Prices driven by real Mastodon activity.`
|
||||||
|
const imageUrl = `/api/og/hashtag/${encodeURIComponent(tag)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [{ url: imageUrl, width: 1200, height: 630, alt: `#${displayTag} price chart` }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [imageUrl],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HashtagPage({ params, searchParams }: Props) {
|
export default async function HashtagPage({ params, searchParams }: Props) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
||||||
@@ -27,11 +82,11 @@ export default async function HashtagPage({ params, searchParams }: Props) {
|
|||||||
where: { tag },
|
where: { tag },
|
||||||
include: {
|
include: {
|
||||||
priceHistory: {
|
priceHistory: {
|
||||||
orderBy: { recordedAt: 'asc' },
|
orderBy: { recordedAt: 'desc' },
|
||||||
take: 200,
|
take: 192, // 192 updates = 2 days of 15-min intervals
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { positions: true },
|
select: { positions: { where: { shares: { gt: 0 } } } },
|
||||||
},
|
},
|
||||||
relatedFrom: {
|
relatedFrom: {
|
||||||
orderBy: { coOccurrences: 'desc' },
|
orderBy: { coOccurrences: 'desc' },
|
||||||
@@ -76,6 +131,16 @@ export default async function HashtagPage({ params, searchParams }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When not in fund mode, fetch funds this user manages for the fund-mode switcher
|
||||||
|
let managedFunds: { slug: string; name: string }[] = []
|
||||||
|
if (session && !fundContext) {
|
||||||
|
const managerships = await prisma.fundManager.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: { fund: { select: { slug: true, name: true } } },
|
||||||
|
})
|
||||||
|
managedFunds = managerships.map((m) => ({ slug: m.fund.slug, name: m.fund.name }))
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown hashtag — show research panel
|
// Unknown hashtag — show research panel
|
||||||
if (!hashtag || !hashtag.isActive) {
|
if (!hashtag || !hashtag.isActive) {
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +188,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -145,11 +211,35 @@ export default async function HashtagPage({ params, searchParams }: Props) {
|
|||||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
|
<h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
|
||||||
<PriceChart
|
<PriceChart
|
||||||
data={hashtag.priceHistory.map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
|
data={hashtag.priceHistory.slice().reverse().map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
|
||||||
height={280}
|
height={280}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zombie warning — only shown when signed in, holding a position, and threshold reached */}
|
||||||
|
{session && (activeLong || activeShort) && hashtag.zeroCount >= ZOMBIE_ZERO_COUNT * 0.9 && (() => {
|
||||||
|
const zombieSince = formatDistanceToNow(
|
||||||
|
new Date(Date.now() - hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
)
|
||||||
|
const zombieEta = formatDistanceToNow(
|
||||||
|
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
{ addSuffix: true },
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-500/5 border border-amber-500/25 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-400">Auto-liquidation risk</p>
|
||||||
|
<p className="text-sm text-slate-400 mt-0.5">
|
||||||
|
No activity detected for <span className="text-slate-200">{zombieSince}</span>.
|
||||||
|
If this continues, your position will be force-closed{' '}
|
||||||
|
<span className="text-slate-200">{zombieEta}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Trade panel or sign-in prompt */}
|
{/* Trade panel or sign-in prompt */}
|
||||||
{session ? (
|
{session ? (
|
||||||
<TradePanel
|
<TradePanel
|
||||||
@@ -164,6 +254,9 @@ export default async function HashtagPage({ params, searchParams }: Props) {
|
|||||||
shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
|
shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
|
||||||
fundId={fundContext?.id}
|
fundId={fundContext?.id}
|
||||||
fundName={fundContext?.name}
|
fundName={fundContext?.name}
|
||||||
|
maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES}
|
||||||
|
maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE}
|
||||||
|
managedFunds={managedFunds}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
|
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
|
||||||
@@ -209,7 +302,7 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
|||||||
where: { hashtagId },
|
where: { hashtagId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
include: { user: { select: { username: true } } },
|
include: { user: { select: { username: true, displayUsername: true, isFund: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (trades.length === 0) return null
|
if (trades.length === 0) return null
|
||||||
@@ -230,12 +323,15 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
|||||||
>
|
>
|
||||||
{t.type.replace('_', ' ')}
|
{t.type.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<div className="flex items-center gap-1">
|
||||||
href={`/profile/${t.user.username}`}
|
{t.user.isFund && <span className="text-indigo-400">🏦</span>}
|
||||||
|
<Link
|
||||||
|
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
|
||||||
className="text-slate-400 hover:text-slate-200"
|
className="text-slate-400 hover:text-slate-200"
|
||||||
>
|
>
|
||||||
{t.user.username}
|
{t.user.displayUsername ?? t.user.username}
|
||||||
</a>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
|
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
skip: (page - 1) * PAGE_SIZE,
|
skip: (page - 1) * PAGE_SIZE,
|
||||||
include: {
|
include: {
|
||||||
hashtag: { select: { tag: true, displayTag: true } },
|
hashtag: { select: { tag: true, displayTag: true } },
|
||||||
|
fund: { select: { name: true, slug: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -59,23 +60,50 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{trades.map((t) => {
|
{trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
|
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
||||||
|
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
isLottery
|
isLiquidation
|
||||||
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
|
: isLottery
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: t.type === 'DONATION'
|
||||||
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
|
: t.type === 'ACCOUNT_OPEN'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
|
: isFundTrade
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
: t.type.startsWith('BUY')
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.type.replace(/_/g, ' ')}
|
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
<span className="text-amber-300">Lucky Dip</span>
|
<span className="text-amber-300">Lucky Dip</span>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{t.type === 'DONATION'
|
||||||
|
? 'Account reset — donated'
|
||||||
|
: t.type === 'BANKRUPTCY'
|
||||||
|
? 'Bankruptcy declared'
|
||||||
|
: 'Account opened'}
|
||||||
|
</span>
|
||||||
|
) : isFundTrade ? (
|
||||||
|
t.fund ? (
|
||||||
|
<Link href={`/fund/${t.fund.slug}`} className="hover:text-indigo-300">
|
||||||
|
{t.fund.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500">Deleted Fund</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
@@ -90,13 +118,26 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{isLottery ? (
|
{isLottery || t.type === 'ACCOUNT_OPEN' ? (
|
||||||
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
|
</>
|
||||||
|
) : isFundTrade ? (
|
||||||
|
<>
|
||||||
|
<p>{formatNumber(t.shares, 6)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
|
{t.type === 'FUND_REDEEM' && (
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
|
||||||
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||||
{formatPnl(t.profit)}
|
{formatPnl(t.profit)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- dark indigo rounded background -->
|
||||||
|
<rect width="32" height="32" rx="7" fill="#1e1b4b"/>
|
||||||
|
<!-- # symbol — slightly angled vertical bars, two horizontal bars -->
|
||||||
|
<!-- left vertical bar (slants slightly: top-right to bottom-left) -->
|
||||||
|
<line x1="12" y1="6" x2="10" y2="26" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
|
||||||
|
<!-- right vertical bar -->
|
||||||
|
<line x1="20" y1="6" x2="18" y2="26" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
|
||||||
|
<!-- upper horizontal bar -->
|
||||||
|
<line x1="6" y1="13" x2="26" y2="13" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
|
||||||
|
<!-- lower horizontal bar -->
|
||||||
|
<line x1="5" y1="20" x2="25" y2="20" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 809 B |
@@ -7,6 +7,7 @@ import { Navbar } from '@/components/Navbar'
|
|||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
|
||||||
title: 'HashEx — The Hashtag Exchange',
|
title: 'HashEx — The Hashtag Exchange',
|
||||||
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.',
|
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,28 @@ import { formatCurrency } from '@/lib/utils'
|
|||||||
import { calcFundNav } from '@/lib/pricing'
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
|
import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
|
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Leaderboard — HashEx',
|
||||||
|
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Leaderboard — HashEx',
|
||||||
|
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
|
||||||
|
images: [{ url: '/api/og/leaderboard', width: 1200, height: 630, alt: 'HashEx leaderboard' }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Leaderboard — HashEx',
|
||||||
|
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
|
||||||
|
images: ['/api/og/leaderboard'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
async function getLeaderboard() {
|
async function getLeaderboard() {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { isFund: false, isHidden: false },
|
where: { isFund: false, isHidden: false },
|
||||||
@@ -119,6 +138,7 @@ export default async function LeaderboardPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Trophy className="h-7 w-7 text-amber-400" />
|
<Trophy className="h-7 w-7 text-amber-400" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+70
-5
@@ -2,16 +2,21 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { HashtagCard } from '@/components/HashtagCard'
|
import { HashtagCard } from '@/components/HashtagCard'
|
||||||
import { TrendingUp, Users, Hash } from 'lucide-react'
|
import { TrendingUp, Users, Hash, AlertTriangle } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { formatPnl, pnlColor } from '@/lib/utils'
|
import { formatPnl, pnlColor } from '@/lib/utils'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
|
|
||||||
|
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
||||||
|
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
async function getStats() {
|
async function getStats() {
|
||||||
const [userCount, hashtagCount, tradeCount, topHashtags, recentTrades] = await Promise.all([
|
const [userCount, hashtagCount, tradeCount, topHashtags, recentTrades] = await Promise.all([
|
||||||
prisma.user.count({ where: { isFund: false } }),
|
prisma.user.count({ where: { isFund: false, isHidden: false } }),
|
||||||
prisma.hashtag.count({ where: { isActive: true } }),
|
prisma.hashtag.count({ where: { isActive: true } }),
|
||||||
prisma.trade.count(),
|
prisma.trade.count(),
|
||||||
// Top by current price (most active)
|
// Top by current price (most active)
|
||||||
@@ -59,14 +64,37 @@ async function getHoldings(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getZombieWarnings(userId: string) {
|
||||||
|
const threshold = Math.floor(ZOMBIE_ZERO_COUNT * 0.9)
|
||||||
|
const positions = await prisma.position.findMany({
|
||||||
|
where: { userId, shares: { gt: 0 }, hashtag: { zeroCount: { gte: threshold } } },
|
||||||
|
select: { hashtag: { select: { tag: true, displayTag: true, zeroCount: true } } },
|
||||||
|
})
|
||||||
|
return positions.map((p) => ({
|
||||||
|
tag: p.hashtag.tag,
|
||||||
|
displayTag: p.hashtag.displayTag,
|
||||||
|
zombieSince: formatDistanceToNow(
|
||||||
|
new Date(Date.now() - p.hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
),
|
||||||
|
zombieEta: formatDistanceToNow(
|
||||||
|
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - p.hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
{ addSuffix: true },
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] =
|
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings, zombieWarnings] =
|
||||||
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)])
|
await Promise.all([
|
||||||
|
getStats(),
|
||||||
|
session ? getHoldings(session.user.id) : Promise.resolve(null),
|
||||||
|
session ? getZombieWarnings(session.user.id) : Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{/* Hero */}
|
<AutoRefresh intervalMs={30_000} />
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
||||||
The{' '}
|
The{' '}
|
||||||
@@ -76,6 +104,9 @@ export default async function HomePage() {
|
|||||||
Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon.
|
Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon.
|
||||||
Research a tag to unlock it, then buy long or short.
|
Research a tag to unlock it, then buy long or short.
|
||||||
</p>
|
</p>
|
||||||
|
<Link href="/about" className="inline-block mt-2 text-sm text-indigo-400 hover:text-indigo-300 underline underline-offset-2 whitespace-nowrap">
|
||||||
|
Learn more →
|
||||||
|
</Link>
|
||||||
<div className="flex justify-center gap-4 mt-6">
|
<div className="flex justify-center gap-4 mt-6">
|
||||||
{session ? (
|
{session ? (
|
||||||
<>
|
<>
|
||||||
@@ -118,12 +149,46 @@ export default async function HomePage() {
|
|||||||
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
|
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zombie / at-risk position warnings */}
|
||||||
|
{zombieWarnings.length > 0 && (
|
||||||
|
<section className="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-1 flex items-center gap-2 text-amber-400">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
At-risk positions
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
|
The following hashtags have had no activity for an extended period and may be auto-liquidated soon.
|
||||||
|
Consider closing these positions manually.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{zombieWarnings.map((w) => (
|
||||||
|
<Link
|
||||||
|
key={w.tag}
|
||||||
|
href={`/hashtag/${w.tag}`}
|
||||||
|
title={`No activity for ${w.zombieSince}. Estimated liquidation ${w.zombieEta}.`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-amber-500/10 border border-amber-500/20 hover:border-amber-400/50 text-amber-300 hover:text-amber-200 px-3 py-1.5 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
#{w.displayTag}
|
||||||
|
<span className="text-amber-500/70">· liquidates {w.zombieEta}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Holdings summary — biggest gain + biggest loss for signed-in users */}
|
{/* Holdings summary — biggest gain + biggest loss for signed-in users */}
|
||||||
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
|
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
<TrendingUp className="h-5 w-5 text-indigo-400" />
|
<TrendingUp className="h-5 w-5 text-indigo-400" />
|
||||||
Your top positions
|
Your top positions
|
||||||
|
<Link
|
||||||
|
href="/positions"
|
||||||
|
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{holdings.biggestGain && (
|
{holdings.biggestGain && (
|
||||||
|
|||||||
+178
-38
@@ -3,8 +3,10 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
||||||
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Coins } from 'lucide-react'
|
import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -37,13 +39,48 @@ function Sparkline({ prices }: { prices: number[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PositionsPage() {
|
type SortKey = 'hashtag' | 'shares' | 'avgBuy' | 'current' | 'costBasis' | 'value' | 'pnl'
|
||||||
|
type SortDir = 'asc' | 'desc'
|
||||||
|
|
||||||
|
function SortHeader({
|
||||||
|
col,
|
||||||
|
label,
|
||||||
|
currentSort,
|
||||||
|
currentDir,
|
||||||
|
right = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
col: SortKey
|
||||||
|
label: string
|
||||||
|
currentSort: SortKey
|
||||||
|
currentDir: SortDir
|
||||||
|
right?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const isActive = currentSort === col
|
||||||
|
const nextDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
|
||||||
|
const Icon = isActive ? (currentDir === 'desc' ? ChevronDown : ChevronUp) : ChevronsUpDown
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`?sort=${col}&dir=${nextDir}`}
|
||||||
|
className={`flex items-center gap-1 text-xs uppercase tracking-wider hover:text-slate-300 transition-colors ${isActive ? 'text-indigo-400' : 'text-slate-500'} ${right ? 'justify-end' : ''} ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<Icon className={`h-3 w-3 shrink-0${isActive ? '' : ' opacity-40'}`} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PositionsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { sort?: string; dir?: string }
|
||||||
|
}) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
if (!session) redirect('/auth/signin')
|
if (!session) redirect('/auth/signin')
|
||||||
|
|
||||||
const positions = await prisma.position.findMany({
|
const rawPositions = await prisma.position.findMany({
|
||||||
where: { userId: session.user.id, shares: { gt: 0 } },
|
where: { userId: session.user.id, shares: { gt: 0 } },
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
include: {
|
include: {
|
||||||
hashtag: {
|
hashtag: {
|
||||||
select: {
|
select: {
|
||||||
@@ -51,7 +88,7 @@ export default async function PositionsPage() {
|
|||||||
displayTag: true,
|
displayTag: true,
|
||||||
currentPrice: true,
|
currentPrice: true,
|
||||||
priceHistory: {
|
priceHistory: {
|
||||||
orderBy: { recordedAt: 'asc' },
|
orderBy: { recordedAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
select: { price: true },
|
select: { price: true },
|
||||||
},
|
},
|
||||||
@@ -60,14 +97,91 @@ export default async function PositionsPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rawFundInvestments = await prisma.fundInvestment.findMany({
|
||||||
|
where: { userId: session.user.id, shares: { gt: 0 } },
|
||||||
|
include: {
|
||||||
|
fund: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fundHoldings = rawFundInvestments.map((inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
const currentValue = inv.shares * nav
|
||||||
|
const costBasis = inv.shares * inv.avgNavAtBuy
|
||||||
|
const pnl = currentValue - costBasis
|
||||||
|
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
||||||
|
return { ...inv, nav, currentValue, costBasis, pnl, pnlPct }
|
||||||
|
})
|
||||||
|
|
||||||
|
const positions = rawPositions.map((pos) => {
|
||||||
|
const pnl =
|
||||||
|
pos.positionType === 'LONG'
|
||||||
|
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
|
||||||
|
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
|
||||||
|
const costBasis = pos.avgBuyPrice * pos.shares
|
||||||
|
const currentValue = pos.hashtag.currentPrice * pos.shares
|
||||||
|
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
||||||
|
const sparkPrices = pos.hashtag.priceHistory.slice().reverse().map((h) => h.price)
|
||||||
|
return { ...pos, pnl, costBasis, currentValue, pnlPct, sparkPrices }
|
||||||
|
})
|
||||||
|
|
||||||
|
const validSorts = new Set<string>(['hashtag', 'shares', 'avgBuy', 'current', 'costBasis', 'value', 'pnl'])
|
||||||
|
const sortKey: SortKey = validSorts.has(searchParams.sort ?? '') ? (searchParams.sort as SortKey) : 'pnl'
|
||||||
|
const sortDir: SortDir = searchParams.dir === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
|
positions.sort((a, b) => {
|
||||||
|
let av: number | string
|
||||||
|
let bv: number | string
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'hashtag': av = a.hashtag.displayTag.toLowerCase(); bv = b.hashtag.displayTag.toLowerCase(); break
|
||||||
|
case 'shares': av = a.shares; bv = b.shares; break
|
||||||
|
case 'avgBuy': av = a.avgBuyPrice; bv = b.avgBuyPrice; break
|
||||||
|
case 'current': av = a.hashtag.currentPrice; bv = b.hashtag.currentPrice; break
|
||||||
|
case 'costBasis': av = a.costBasis; bv = b.costBasis; break
|
||||||
|
case 'value': av = a.currentValue; bv = b.currentValue; break
|
||||||
|
default: av = a.pnl; bv = b.pnl; break
|
||||||
|
}
|
||||||
|
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||||
|
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Coins className="h-6 w-6 text-indigo-400" />
|
<Coins className="h-6 w-6 text-indigo-400" />
|
||||||
<h1 className="text-2xl font-bold">Open Positions</h1>
|
<h1 className="text-2xl font-bold">Open Positions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{positions.length === 0 ? (
|
{positions.length === 0 && fundHoldings.length === 0 ? (
|
||||||
<div className="text-center py-16 text-slate-500">
|
<div className="text-center py-16 text-slate-500">
|
||||||
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
<p>You have no open positions.</p>
|
<p>You have no open positions.</p>
|
||||||
@@ -78,35 +192,27 @@ export default async function PositionsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-slate-500 uppercase tracking-wider border-b border-surface-border">
|
<div className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_4rem_5rem_5rem_6rem_5rem_6rem] gap-4 px-4 py-2 border-b border-surface-border">
|
||||||
<span>Hashtag</span>
|
<SortHeader col="hashtag" label="Hashtag" currentSort={sortKey} currentDir={sortDir} />
|
||||||
<span className="text-right">Shares</span>
|
<SortHeader col="shares" label="Shares" currentSort={sortKey} currentDir={sortDir} right />
|
||||||
<span className="text-right">Avg buy</span>
|
<SortHeader col="avgBuy" label="Avg buy" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
|
||||||
<span className="text-right">Current</span>
|
<SortHeader col="current" label="Current" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
|
||||||
<span className="text-right">Cost basis</span>
|
<SortHeader col="costBasis" label="Cost basis" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
|
||||||
<span className="text-right">Value</span>
|
<SortHeader col="value" label="Value" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
|
||||||
<span className="text-right">P&L</span>
|
<SortHeader col="pnl" label="P&L" currentSort={sortKey} currentDir={sortDir} right />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{positions.map((pos) => {
|
{positions.map((pos) => (
|
||||||
const pnl =
|
|
||||||
pos.positionType === 'LONG'
|
|
||||||
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
|
|
||||||
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
|
|
||||||
const costBasis = pos.avgBuyPrice * pos.shares
|
|
||||||
const currentValue = pos.hashtag.currentPrice * pos.shares
|
|
||||||
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
|
||||||
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={pos.id}
|
key={pos.id}
|
||||||
className="grid grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 items-center px-4 py-3"
|
className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_4rem_5rem_5rem_6rem_5rem_6rem] gap-4 items-center px-4 py-3"
|
||||||
>
|
>
|
||||||
{/* Hashtag + type + sparkline */}
|
{/* Hashtag + type badge (+ sparkline on desktop) */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Sparkline prices={sparkPrices} />
|
<div className="hidden sm:block shrink-0">
|
||||||
|
<Sparkline prices={pos.sparkPrices} />
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${pos.hashtag.tag}`}
|
href={`/hashtag/${pos.hashtag.tag}`}
|
||||||
@@ -127,21 +233,55 @@ export default async function PositionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-right text-sm">{formatNumber(pos.shares)}</span>
|
<span className="text-right text-sm">{formatNumber(pos.shares)}</span>
|
||||||
<span className="text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
|
||||||
<span className="text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
|
||||||
<span className="text-right text-sm text-slate-400">{formatCurrency(costBasis)}</span>
|
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(pos.costBasis)}</span>
|
||||||
<span className="text-right text-sm">{formatCurrency(currentValue)}</span>
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.currentValue)}</span>
|
||||||
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className={`text-sm font-medium ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
|
<p className={`text-sm font-medium ${pnlColor(pos.pnl)}`}>{formatPnl(pos.pnl)}</p>
|
||||||
<p className={`text-xs ${pnlColor(pnlPct)}`}>
|
<p className={`text-xs ${pnlColor(pos.pnlPct)}`}>
|
||||||
{pnlPct >= 0 ? '+' : ''}
|
{pos.pnlPct >= 0 ? '+' : ''}
|
||||||
{pnlPct.toFixed(1)}%
|
{pos.pnlPct.toFixed(1)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fundHoldings.length > 0 && (
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 px-4 py-2 border-b border-surface-border text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<span>Fund</span>
|
||||||
|
<span className="text-right">Shares</span>
|
||||||
|
<span className="hidden sm:block text-right">Avg NAV</span>
|
||||||
|
<span className="hidden sm:block text-right">Cur NAV</span>
|
||||||
|
<span className="hidden sm:block text-right">Cost basis</span>
|
||||||
|
<span className="hidden sm:block text-right">Value</span>
|
||||||
|
<span className="text-right">P&L</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-surface-border">
|
||||||
|
{fundHoldings.map((inv) => (
|
||||||
|
<div key={inv.id} className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 items-center px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link href={`/fund/${inv.fund.slug}`} className="font-medium hover:text-indigo-300 truncate block">
|
||||||
|
{inv.fund.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs font-medium px-1.5 py-0.5 rounded bg-indigo-500/15 text-indigo-400">FUND</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-right text-sm">{formatNumber(inv.shares, 6)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.avgNavAtBuy)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.nav)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(inv.costBasis)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.currentValue)}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-sm font-medium ${pnlColor(inv.pnl)}`}>{formatPnl(inv.pnl)}</p>
|
||||||
|
<p className={`text-xs ${pnlColor(inv.pnlPct)}`}>{inv.pnlPct >= 0 ? '+' : ''}{inv.pnlPct.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CloseAccountForm({ username }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleDelete(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (confirm !== username) {
|
||||||
|
setError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/me', { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Failed to delete account.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await signOut({ callbackUrl: '/' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-surface-card border border-red-500/20 rounded-xl p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen((v) => !v); setError(''); setConfirm('') }}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Close account
|
||||||
|
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleDelete} className="mt-4 space-y-4 max-w-sm">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
This will <span className="text-red-400 font-medium">permanently delete</span> your account,
|
||||||
|
all trade history, positions, and portfolio data. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Any fund investments you hold will be forfeited to the fund.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Type <span className="text-white font-mono">{username}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder={username}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || confirm !== username}
|
||||||
|
className="flex items-center gap-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{loading ? 'Deleting…' : 'Permanently delete my account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetAccountForm({ username }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [keepHistory, setKeepHistory] = useState(false)
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
async function handleReset(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (confirm !== username) {
|
||||||
|
setError('Username does not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/me/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepHistory }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? 'Reset failed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDone(true)
|
||||||
|
setOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="reset" className="bg-surface-card border border-amber-500/20 rounded-xl p-6">
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen((v) => !v); setError(''); setConfirm(''); setDone(false) }}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Reset account
|
||||||
|
{done && <span className="text-emerald-400 text-xs ml-1">✓ Reset</span>}
|
||||||
|
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleReset} className="mt-4 space-y-4 max-w-sm">
|
||||||
|
{keepHistory ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
All positions and fund investments are closed. Your trade history is kept
|
||||||
|
and a <span className="text-purple-400 font-medium">DONATION</span> or{' '}
|
||||||
|
<span className="text-red-400 font-medium">BANKRUPTCY</span> entry marks the
|
||||||
|
reset, followed by an{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">ACCOUNT OPEN</span>. Balance
|
||||||
|
resets to <span className="text-white font-medium">$2,000</span>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
This will <span className="text-amber-400 font-medium">permanently erase</span> your entire
|
||||||
|
trade history, all positions, and fund investments, then reset your cash balance back to{' '}
|
||||||
|
<span className="text-white font-medium">$2,000</span>. A true clean slate.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keep history toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-300">Keep trade history</p>
|
||||||
|
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeepHistory((v) => !v)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
keepHistory ? 'bg-amber-500' : 'bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
keepHistory ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Type <span className="text-white font-mono">{username}</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder={username}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || confirm !== username}
|
||||||
|
className="flex items-center gap-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{loading ? 'Resetting…' : 'Reset my account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,17 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
|
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { getBalanceTier } from '@/lib/pricing'
|
import { getBalanceTier } from '@/lib/pricing'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
import { TrendingUp, TrendingDown, Coins, Building2, AlertTriangle } from 'lucide-react'
|
||||||
import ChangePasswordForm from './ChangePasswordForm'
|
import ChangePasswordForm from './ChangePasswordForm'
|
||||||
import AccountSettingsForm from './AccountSettingsForm'
|
import AccountSettingsForm from './AccountSettingsForm'
|
||||||
|
import CloseAccountForm from './CloseAccountForm'
|
||||||
|
import ResetAccountForm from './ResetAccountForm'
|
||||||
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -15,6 +21,26 @@ interface Props {
|
|||||||
params: { username: string }
|
params: { username: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const username = decodeURIComponent(params.username).toLowerCase()
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: { displayUsername: true, balance: true, _count: { select: { trades: true } } },
|
||||||
|
})
|
||||||
|
const displayName = user?.displayUsername ?? username
|
||||||
|
const title = `${displayName} — HashEx Profile`
|
||||||
|
const description = user
|
||||||
|
? `Check out ${displayName}'s trading profile on HashEx.`
|
||||||
|
: `HashEx trader profile for @${username}.`
|
||||||
|
const imageUrl = `/api/og/profile/${encodeURIComponent(username)}`
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${displayName}'s profile` }] },
|
||||||
|
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProfilePage({ params }: Props) {
|
export default async function ProfilePage({ params }: Props) {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
const username = decodeURIComponent(params.username).toLowerCase()
|
const username = decodeURIComponent(params.username).toLowerCase()
|
||||||
@@ -36,7 +62,10 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
trades: {
|
trades: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 30,
|
take: 30,
|
||||||
include: { hashtag: { select: { tag: true, displayTag: true } } },
|
include: {
|
||||||
|
hashtag: { select: { tag: true, displayTag: true } },
|
||||||
|
fund: { select: { name: true, slug: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
managedFunds: {
|
managedFunds: {
|
||||||
orderBy: { addedAt: 'asc' },
|
orderBy: { addedAt: 'asc' },
|
||||||
@@ -71,10 +100,26 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
.filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT')
|
.filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT')
|
||||||
.reduce((sum, t) => sum + t.profit, 0)
|
.reduce((sum, t) => sum + t.profit, 0)
|
||||||
|
|
||||||
|
const lotteryAggregate = await prisma.trade.aggregate({
|
||||||
|
where: { userId: user.id, type: 'LOTTERY_WIN' },
|
||||||
|
_sum: { profit: true },
|
||||||
|
_count: true,
|
||||||
|
})
|
||||||
|
const lotteryWinnings = lotteryAggregate._sum.profit ?? 0
|
||||||
|
const lotteryCount = lotteryAggregate._count
|
||||||
|
|
||||||
|
const portfolioHistory = await prisma.userPortfolioHistory.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
orderBy: { recordedAt: 'asc' },
|
||||||
|
select: { totalValue: true, recordedAt: true },
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1>
|
<h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1>
|
||||||
{user.displayUsername && user.displayUsername.toLowerCase() !== user.username && (
|
{user.displayUsername && user.displayUsername.toLowerCase() !== user.username && (
|
||||||
@@ -91,9 +136,14 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
<span className="text-slate-600 mx-1.5">·</span>
|
<span className="text-slate-600 mx-1.5">·</span>
|
||||||
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
|
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
|
||||||
{tier.nextThreshold && (
|
{tier.nextThreshold && (
|
||||||
<span className="text-slate-600 text-xs ml-1.5">
|
<>
|
||||||
|
<span className="hidden sm:inline text-slate-600 text-xs ml-1.5">
|
||||||
(next tier at {formatCurrency(tier.nextThreshold)})
|
(next tier at {formatCurrency(tier.nextThreshold)})
|
||||||
</span>
|
</span>
|
||||||
|
<span className="block sm:hidden text-slate-600 text-xs mt-0.5">
|
||||||
|
next tier at {formatCurrency(tier.nextThreshold)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -106,8 +156,13 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p>
|
<p className={`text-3xl font-bold ${totalValue < 0 ? 'text-red-400' : ''}`}>{formatCurrency(totalValue)}</p>
|
||||||
<p className="text-sm text-slate-400">total portfolio value</p>
|
<p className="text-sm text-slate-400">total portfolio value</p>
|
||||||
|
{lotteryCount > 0 && (
|
||||||
|
<p className="text-xs text-amber-400 mt-1">
|
||||||
|
🎰 {formatCurrency(lotteryWinnings)} from Lucky Dip ({lotteryCount} win{lotteryCount !== 1 ? 's' : ''})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,6 +182,64 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bankruptcy warning — only shown to profile owner when cash balance is negative */}
|
||||||
|
{isOwn && user.balance < 0 && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 flex gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-400 mb-1">You're in the red</p>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Your cash balance is negative — you won't be able to buy any positions until it recovers.
|
||||||
|
If you want to start fresh, you can{' '}
|
||||||
|
<a href="#reset" className="text-amber-400 hover:text-amber-300 underline underline-offset-2">reset your account</a>{' '}
|
||||||
|
back to $2,000.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Portfolio value chart */}
|
||||||
|
{portfolioHistory.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">Portfolio Value — Last 7 Days</h2>
|
||||||
|
<PriceChart
|
||||||
|
data={portfolioHistory.map((p) => ({ price: p.totalValue, recordedAt: p.recordedAt.toISOString() }))}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Funds managed — only shown to the profile owner */}
|
||||||
|
{isOwn && user.managedFunds.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-indigo-400" />
|
||||||
|
Funds you manage
|
||||||
|
</h2>
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
<div className="divide-y divide-surface-border">
|
||||||
|
{user.managedFunds.map(({ fund }) => (
|
||||||
|
<div key={fund.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/fund/${fund.slug}`}
|
||||||
|
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
|
||||||
|
{fund.name}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/stocks?fund=${fund.slug}`}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
Trade as this fund →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Positions */}
|
{/* Positions */}
|
||||||
{user.positions.length > 0 && (
|
{user.positions.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
@@ -180,37 +293,6 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Funds managed — only shown to the profile owner */}
|
|
||||||
{isOwn && user.managedFunds.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<Building2 className="h-5 w-5 text-indigo-400" />
|
|
||||||
Funds you manage
|
|
||||||
</h2>
|
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
|
||||||
<div className="divide-y divide-surface-border">
|
|
||||||
{user.managedFunds.map(({ fund }) => (
|
|
||||||
<div key={fund.id} className="flex items-center justify-between px-4 py-3">
|
|
||||||
<Link
|
|
||||||
href={`/fund/${fund.slug}`}
|
|
||||||
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
|
|
||||||
{fund.name}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/stocks?fund=${fund.slug}`}
|
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
|
||||||
>
|
|
||||||
Trade as this fund →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Trade history */}
|
{/* Trade history */}
|
||||||
{user.trades.length > 0 && (
|
{user.trades.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
@@ -234,45 +316,76 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{user.trades.map((t) => {
|
{user.trades.map((t) => {
|
||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
|
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
||||||
|
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
|
||||||
<div className="flex items-center gap-3">
|
{/* Primary row: badge · hashtag/label · total */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded ${
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
isLottery
|
isLiquidation
|
||||||
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
|
: isLottery
|
||||||
? 'bg-amber-500/15 text-amber-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
|
: t.type === 'DONATION'
|
||||||
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
|
: t.type === 'ACCOUNT_OPEN'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
|
: isFundTrade
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
: t.type.startsWith('BUY')
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.type.replace(/_/g, ' ')}
|
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
{isLottery ? (
|
{isLottery ? (
|
||||||
<span className="text-amber-300">Lucky Dip</span>
|
<span className="text-amber-300 font-medium flex-1 min-w-0">Lucky Dip</span>
|
||||||
|
) : isSystemReset ? (
|
||||||
|
<span className="text-slate-300 font-medium flex-1 min-w-0">
|
||||||
|
{t.type === 'DONATION'
|
||||||
|
? 'Account reset — donated'
|
||||||
|
: t.type === 'BANKRUPTCY'
|
||||||
|
? 'Bankruptcy declared'
|
||||||
|
: 'Account opened'}
|
||||||
|
</span>
|
||||||
|
) : isFundTrade ? (
|
||||||
|
t.fund ? (
|
||||||
|
<Link
|
||||||
|
href={`/fund/${t.fund.slug}`}
|
||||||
|
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
{t.fund.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-medium flex-1 min-w-0">Deleted Fund</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
className="hover:text-indigo-300"
|
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
|
||||||
>
|
>
|
||||||
#{t.hashtag!.displayTag}
|
#{t.hashtag!.displayTag}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<span className="shrink-0 font-medium tabular-nums">
|
||||||
|
{formatCurrency(isLottery ? t.profit : t.total)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
{/* Secondary row: time (left) · shares @ price (right) */}
|
||||||
{isLottery ? (
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
<span>{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
|
||||||
) : (
|
{!isLottery && !isSystemReset && (
|
||||||
<>
|
<span className="tabular-nums ml-3">{formatNumber(isFundTrade ? t.shares : t.shares)} sh @ {formatCurrency(t.price)}</span>
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
|
||||||
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
|
||||||
{formatPnl(t.profit)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* PnL: sell, liquidation, reset, and fund redeem trades */}
|
||||||
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation || t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'FUND_REDEEM') && (
|
||||||
|
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -289,6 +402,8 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
currentDisplayUsername={user.displayUsername ?? null}
|
currentDisplayUsername={user.displayUsername ?? null}
|
||||||
/>
|
/>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<ResetAccountForm username={user.username} />
|
||||||
|
<CloseAccountForm username={user.username} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-15
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency, pnlColor } from '@/lib/utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2, Building2 } from 'lucide-react'
|
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2, Building2 } from 'lucide-react'
|
||||||
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { calcFundNav } from '@/lib/pricing'
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
take: 2,
|
take: 2,
|
||||||
select: { price: true, postsPerHour: true },
|
select: { price: true, postsPerHour: true },
|
||||||
},
|
},
|
||||||
_count: { select: { positions: true } },
|
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
take: 2,
|
take: 2,
|
||||||
select: { price: true, postsPerHour: true },
|
select: { price: true, postsPerHour: true },
|
||||||
},
|
},
|
||||||
_count: { select: { positions: true } },
|
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((rows) =>
|
.then((rows) =>
|
||||||
@@ -197,6 +198,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
|
<AutoRefresh intervalMs={30_000} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -243,16 +245,16 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
{tab === 'stocks' && (<>
|
{tab === 'stocks' && (<>
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
{/* Column headers */}
|
{/* Column headers */}
|
||||||
<div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium">
|
<div className="grid grid-cols-[2fr_1fr_1fr] sm:grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium">
|
||||||
<SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
<SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
<div className="text-right">
|
<div className="flex justify-end">
|
||||||
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex justify-end">
|
||||||
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
|
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
|
||||||
<div className="text-right">
|
<div className="hidden sm:flex justify-end">
|
||||||
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,12 +267,10 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
const prev = stock.previousPrice
|
const prev = stock.previousPrice
|
||||||
const change = prev != null ? stock.currentPrice - prev : null
|
const change = prev != null ? stock.currentPrice - prev : null
|
||||||
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
|
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
|
||||||
const up = change == null ? null : change >= 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stock.id}
|
key={stock.id}
|
||||||
className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
|
className="grid grid-cols-[2fr_1fr_1fr] sm:grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Rank + hashtag name */}
|
{/* Rank + hashtag name */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
@@ -296,11 +296,11 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
<span className="text-slate-600 text-xs">—</span>
|
<span className="text-slate-600 text-xs">—</span>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm ${up ? 'text-emerald-400' : 'text-red-400'}`}>
|
<p className={`text-sm ${pnlColor(change)}`}>
|
||||||
{up ? '+' : ''}{formatCurrency(change)}
|
{change > 0 ? '+' : ''}{formatCurrency(change)}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs ${up ? 'text-emerald-400' : 'text-red-400'}`}>
|
<p className={`text-xs ${pnlColor(changePct ?? 0)}`}>
|
||||||
{up ? '+' : ''}{changePct!.toFixed(2)}%
|
{changePct! > 0 ? '+' : ''}{changePct!.toFixed(2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -312,7 +312,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last updated */}
|
{/* Last updated */}
|
||||||
<div className="text-right text-xs text-slate-500">
|
<div className="text-right text-xs text-slate-500 hidden sm:block">
|
||||||
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
|
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-26
@@ -15,15 +15,28 @@ interface PageProps {
|
|||||||
export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
||||||
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
|
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
|
||||||
|
|
||||||
|
const tradeWhere = {
|
||||||
|
OR: [
|
||||||
|
{ hashtagId: { not: null as string | null }, type: { not: 'LOTTERY_WIN' as const } },
|
||||||
|
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as ('FUND_INVEST' | 'FUND_REDEEM')[] } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
const [total, trades] = await Promise.all([
|
const [total, trades] = await Promise.all([
|
||||||
prisma.trade.count({ where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } } }),
|
prisma.trade.count({ where: tradeWhere }),
|
||||||
prisma.trade.findMany({
|
prisma.trade.findMany({
|
||||||
where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' as const } },
|
||||||
|
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as const } },
|
||||||
|
],
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: PAGE_SIZE,
|
take: PAGE_SIZE,
|
||||||
skip: (page - 1) * PAGE_SIZE,
|
skip: (page - 1) * PAGE_SIZE,
|
||||||
include: {
|
include: {
|
||||||
hashtag: { select: { tag: true, displayTag: true } },
|
hashtag: { select: { tag: true, displayTag: true } },
|
||||||
|
fund: { select: { name: true, slug: true } },
|
||||||
user: { select: { username: true, displayUsername: true, isFund: true } },
|
user: { select: { username: true, displayUsername: true, isFund: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -42,46 +55,63 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
|||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
<div className="divide-y divide-surface-border">
|
<div className="divide-y divide-surface-border">
|
||||||
{trades.map((t) => (
|
{trades.map((t) => (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
|
||||||
<div className="flex items-center gap-3">
|
{/* Primary row: type badge · hashtag · total value */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
t.type.startsWith('BUY')
|
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
|
||||||
|
? 'bg-orange-500/15 text-orange-400'
|
||||||
|
: (t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM')
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.type.replace('_', ' ')}
|
{(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
{(t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM') ? (
|
||||||
<div className="flex items-center gap-1.5">
|
t.fund ? (
|
||||||
{t.user.isFund ? (
|
<Link
|
||||||
<span className="text-xs text-indigo-400">🏦</span>
|
href={`/fund/${t.fund.slug}`}
|
||||||
) : null}
|
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
{t.fund.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 font-medium flex-1 min-w-0">Deleted Fund</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
|
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
#{t.hashtag!.displayTag}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span>
|
||||||
|
</div>
|
||||||
|
{/* Secondary row: user · time (left) shares @ price (right) */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{t.user.isFund && <span className="text-indigo-400 shrink-0">🏦</span>}
|
||||||
<Link
|
<Link
|
||||||
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
|
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
|
||||||
className="text-slate-300 hover:text-white"
|
className="text-slate-400 hover:text-slate-200 truncate"
|
||||||
>
|
>
|
||||||
{t.user.displayUsername ?? t.user.username}
|
{t.user.displayUsername ?? t.user.username}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-slate-600">·</span>
|
<span className="text-slate-700 shrink-0">·</span>
|
||||||
<Link href={`/hashtag/${t.hashtag!.tag}`} className="text-indigo-300 hover:text-indigo-200">
|
<span className="shrink-0">{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
|
||||||
#{t.hashtag!.displayTag}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
|
||||||
{formatDistanceToNow(t.createdAt, { addSuffix: true })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* PnL: sell, liquidation, and fund redeem trades */}
|
||||||
<div className="text-right shrink-0">
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT' || t.type === 'FUND_REDEEM') && (
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
|
||||||
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
|
||||||
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently refreshes all server-component data on the current page by calling
|
||||||
|
* router.refresh() on an interval and whenever the tab regains focus.
|
||||||
|
*
|
||||||
|
* Drop this anywhere inside a server-component page — it renders nothing.
|
||||||
|
*/
|
||||||
|
export function AutoRefresh({ intervalMs = 30_000 }: { intervalMs?: number }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => router.refresh(), intervalMs)
|
||||||
|
|
||||||
|
const onFocus = () => router.refresh()
|
||||||
|
document.addEventListener('visibilitychange', onFocus)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(id)
|
||||||
|
document.removeEventListener('visibilitychange', onFocus)
|
||||||
|
}
|
||||||
|
}, [router, intervalMs])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react'
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,7 +16,7 @@ export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, post
|
|||||||
? ((currentPrice - previousPrice) / previousPrice) * 100
|
? ((currentPrice - previousPrice) / previousPrice) * 100
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const up = pctChange === null ? null : pctChange >= 0
|
const up = pctChange === null ? null : pctChange > 0 ? 'up' : pctChange < 0 ? 'down' : 'flat'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -24,26 +24,28 @@ export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, post
|
|||||||
className="block bg-surface-card border border-surface-border hover:border-indigo-500/50 rounded-xl p-4 transition-all hover:shadow-lg hover:shadow-indigo-500/5"
|
className="block bg-surface-card border border-surface-border hover:border-indigo-500/50 rounded-xl p-4 transition-all hover:shadow-lg hover:shadow-indigo-500/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-sm">#{displayTag}</p>
|
<p className="font-semibold text-sm truncate">#{displayTag}</p>
|
||||||
{postsPerHour !== undefined && (
|
{postsPerHour !== undefined && (
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
{postsPerHour.toFixed(1)} posts/hr
|
{postsPerHour.toFixed(1)} posts/hr
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right shrink-0">
|
||||||
<p className="font-bold text-sm">{formatCurrency(currentPrice)}</p>
|
<p className="font-bold text-sm">{formatCurrency(currentPrice)}</p>
|
||||||
{pctChange !== null && (
|
{pctChange !== null && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-end gap-0.5 text-xs mt-0.5 ${up ? 'text-emerald-400' : 'text-red-400'}`}
|
className={`flex items-center justify-end gap-0.5 text-xs mt-0.5 ${up === 'up' ? 'text-emerald-400' : up === 'down' ? 'text-red-400' : 'text-slate-400'}`}
|
||||||
>
|
>
|
||||||
{up ? (
|
{up === 'up' ? (
|
||||||
<TrendingUp className="h-3 w-3" />
|
<TrendingUp className="h-3 w-3" />
|
||||||
) : (
|
) : up === 'down' ? (
|
||||||
<TrendingDown className="h-3 w-3" />
|
<TrendingDown className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
{up ? '+' : ''}
|
{up === 'up' ? '+' : ''}
|
||||||
{pctChange.toFixed(1)}%
|
{pctChange.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+66
-31
@@ -3,30 +3,34 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSession, signOut } from 'next-auth/react'
|
import { useSession, signOut } from 'next-auth/react'
|
||||||
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
|
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect, Suspense } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
import { normalizeTag } from '@/lib/utils'
|
import { normalizeTag } from '@/lib/utils'
|
||||||
|
|
||||||
type Suggestion = { tag: string; displayTag: string; currentPrice: number }
|
type Suggestion = { tag: string; displayTag: string; currentPrice: number }
|
||||||
|
|
||||||
export function Navbar() {
|
function NavSearchInner() {
|
||||||
const { data: session } = useSession()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const fundSlug = searchParams.get('fund')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
function handleSearch(e: React.FormEvent) {
|
function navigate(tag: string) {
|
||||||
e.preventDefault()
|
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
|
||||||
const tag = normalizeTag(query)
|
router.push(url)
|
||||||
if (tag) {
|
|
||||||
router.push(`/hashtag/${tag}`)
|
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const tag = normalizeTag(query)
|
||||||
|
if (tag) navigate(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -52,16 +56,6 @@ export function Navbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b border-surface-border bg-surface-card">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex items-center justify-between h-14 gap-4">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/" className="flex items-center gap-2 shrink-0">
|
|
||||||
<TrendingUp className="h-6 w-6 text-indigo-500" />
|
|
||||||
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
<form onSubmit={handleSearch} className="flex-1 max-w-md">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
@@ -71,7 +65,7 @@ export function Navbar() {
|
|||||||
onChange={handleQueryChange}
|
onChange={handleQueryChange}
|
||||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||||
placeholder="#hashtag"
|
placeholder={fundSlug ? `#hashtag (as ${fundSlug})` : '#hashtag'}
|
||||||
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
/>
|
/>
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
@@ -80,12 +74,7 @@ export function Navbar() {
|
|||||||
<button
|
<button
|
||||||
key={s.tag}
|
key={s.tag}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={() => {
|
onMouseDown={() => navigate(s.tag)}
|
||||||
router.push(`/hashtag/${s.tag}`)
|
|
||||||
setQuery('')
|
|
||||||
setSuggestions([])
|
|
||||||
setShowSuggestions(false)
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
|
||||||
>
|
>
|
||||||
<span className="font-medium">#{s.displayTag}</span>
|
<span className="font-medium">#{s.displayTag}</span>
|
||||||
@@ -96,6 +85,33 @@ export function Navbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-b border-surface-border bg-surface-card">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-14 gap-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-2 shrink-0">
|
||||||
|
<TrendingUp className="h-6 w-6 text-indigo-500" />
|
||||||
|
<span className="font-bold text-lg hidden sm:block">HashEx</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
|
<input disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<NavSearchInner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
@@ -155,21 +171,40 @@ export function Navbar() {
|
|||||||
|
|
||||||
// Lazy balance fetcher so the navbar always shows current value
|
// Lazy balance fetcher so the navbar always shows current value
|
||||||
function BalanceBadge({ userId }: { userId: string }) {
|
function BalanceBadge({ userId }: { userId: string }) {
|
||||||
// We read balance from the API to stay fresh; use SWR-style approach
|
|
||||||
const [balance, setBalance] = useState<number | null>(null)
|
const [balance, setBalance] = useState<number | null>(null)
|
||||||
|
|
||||||
// One-shot fetch on mount
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && balance === null) {
|
let cancelled = false
|
||||||
|
|
||||||
|
function fetchBalance() {
|
||||||
fetch('/api/user/me')
|
fetch('/api/user/me')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setBalance(d.balance ?? null))
|
.then((d) => { if (!cancelled) setBalance(d.balance ?? null) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchBalance()
|
||||||
|
|
||||||
|
// Re-fetch every 30 seconds
|
||||||
|
const interval = setInterval(fetchBalance, 30_000)
|
||||||
|
|
||||||
|
// Re-fetch when the tab regains focus
|
||||||
|
function onVisible() {
|
||||||
|
if (document.visibilityState === 'visible') fetchBalance()
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', onVisible)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearInterval(interval)
|
||||||
|
document.removeEventListener('visibilitychange', onVisible)
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
if (balance === null) return null
|
if (balance === null) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-emerald-400 text-sm font-medium hidden md:block">
|
<span className={`text-sm font-medium hidden md:block ${balance < 0 ? 'text-red-400' : 'text-emerald-400'}`}>
|
||||||
{formatCurrency(balance)}
|
{formatCurrency(balance)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function TriggerJobButton({ queueName, label }: { queueName: string; label: string }) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleTrigger() {
|
||||||
|
setLoading(true)
|
||||||
|
setDone(false)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/queues/${encodeURIComponent(queueName)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'trigger' }),
|
||||||
|
})
|
||||||
|
setDone(true)
|
||||||
|
setTimeout(() => setDone(false), 3000)
|
||||||
|
} finally {
|
||||||
|
router.refresh()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleTrigger}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-indigo-500/20 text-indigo-400 hover:bg-indigo-500/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Triggering…' : done ? '✓ Triggered' : label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+115
-24
@@ -17,11 +17,25 @@ function extractMaxId(linkHeader: string | null): string | null {
|
|||||||
return match ? match[1] : null
|
return match ? match[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
|
/**
|
||||||
|
* Extracts hashtag names from Mastodon HTML content with original casing preserved.
|
||||||
|
* Mastodon renders hashtags as: #<span>TagName</span> inside an anchor.
|
||||||
|
*/
|
||||||
|
function extractTagsFromHtml(html: string): string[] {
|
||||||
|
const results: string[] = []
|
||||||
|
const re = /#<span>([^<]+)<\/span>/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(html)) !== null) {
|
||||||
|
results.push(m[1]) // preserve original casing
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(tag: string, maxId?: string, postLimit = 20): Promise<TimelineResult> {
|
||||||
const instance = process.env.MASTODON_INSTANCE
|
const instance = process.env.MASTODON_INSTANCE
|
||||||
if (!instance) throw new Error('MASTODON_INSTANCE is not configured')
|
if (!instance) throw new Error('MASTODON_INSTANCE is not configured')
|
||||||
|
|
||||||
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=40`
|
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${postLimit}`
|
||||||
if (maxId) url += `&max_id=${maxId}`
|
if (maxId) url += `&max_id=${maxId}`
|
||||||
|
|
||||||
const headers: HeadersInit = { Accept: 'application/json' }
|
const headers: HeadersInit = { Accept: 'application/json' }
|
||||||
@@ -46,8 +60,6 @@ async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches recent posts for a hashtag and returns posts-per-hour.
|
* Fetches recent posts for a hashtag and returns posts-per-hour.
|
||||||
* Paginates when all fetched posts share a very tight timestamp window
|
|
||||||
* (e.g., #happynewyear at midnight) up to MAX_PAGES_PER_HASHTAG pages.
|
|
||||||
*/
|
*/
|
||||||
export async function getPostsPerHour(tag: string): Promise<number> {
|
export async function getPostsPerHour(tag: string): Promise<number> {
|
||||||
const { postsPerHour } = await getPostsData(tag)
|
const { postsPerHour } = await getPostsData(tag)
|
||||||
@@ -57,43 +69,90 @@ export async function getPostsPerHour(tag: string): Promise<number> {
|
|||||||
/**
|
/**
|
||||||
* Returns posts-per-hour AND a sorted list of co-occurring tag names
|
* Returns posts-per-hour AND a sorted list of co-occurring tag names
|
||||||
* (lowercased, excluding the queried tag itself).
|
* (lowercased, excluding the queried tag itself).
|
||||||
|
*
|
||||||
|
* Pagination strategy:
|
||||||
|
* - Keep fetching pages until >= 50% of posts in a page fall outside the 1-hour window,
|
||||||
|
* OR the timeline is exhausted, OR MAX_PAGES_PER_HASHTAG is reached.
|
||||||
|
* - The 50% rule handles federated out-of-order posts gracefully: Mastodon timelines are
|
||||||
|
* ordered by post ID (local receive time), not created_at. A remote post from hours or
|
||||||
|
* even years ago can arrive late, get a fresh ID, and appear at the top of the stream.
|
||||||
|
* A minority of such posts won't trigger the stop condition; only once the majority of
|
||||||
|
* a page is old content do we consider the 1-hour window fully covered.
|
||||||
|
* - After collecting all pages, sort by created_at and filter to the last hour for an
|
||||||
|
* accurate count regardless of any remaining ordering noise.
|
||||||
|
*
|
||||||
|
* PPH calculation:
|
||||||
|
* - Crossed horizon (direct): we have a full window — count posts with created_at >= cutoff.
|
||||||
|
* - Hit page cap without crossing (burst): more posts exist beyond what we fetched —
|
||||||
|
* extrapolate from the covered time span (count / coveredHours).
|
||||||
|
* - Timeline exhausted without crossing (sparse): all posts in the last hour are accounted
|
||||||
|
* for — use the raw count directly (no extrapolation).
|
||||||
*/
|
*/
|
||||||
export async function getPostsData(
|
export async function getPostsData(
|
||||||
tag: string,
|
tag: string,
|
||||||
): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string }> {
|
): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string; hasAnyPosts: boolean }> {
|
||||||
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
|
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
|
||||||
|
const postLimit = Math.min(parseInt(process.env.MASTODON_POST_LIMIT ?? '20', 10), 40)
|
||||||
|
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
const cutoff = now - ONE_HOUR_MS
|
||||||
|
|
||||||
let allPosts: MastodonPost[] = []
|
let allPosts: MastodonPost[] = []
|
||||||
let maxId: string | undefined
|
let maxId: string | undefined
|
||||||
|
let hitPageCap = false
|
||||||
|
|
||||||
for (let page = 0; page < maxPages; page++) {
|
for (let page = 0; page < maxPages; page++) {
|
||||||
const { posts, nextMaxId } = await fetchPage(tag, maxId)
|
const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
|
||||||
|
|
||||||
if (posts.length === 0) break
|
if (posts.length === 0) break
|
||||||
allPosts = [...allPosts, ...posts]
|
allPosts = [...allPosts, ...posts]
|
||||||
|
|
||||||
// Stop paginating if we got fewer than 40 posts (end of timeline)
|
// End of timeline or no more pages
|
||||||
if (posts.length < 40 || !nextMaxId) break
|
if (posts.length < postLimit || !nextMaxId) break
|
||||||
|
|
||||||
// Stop paginating if the time span of what we have is already > 5 minutes
|
// Stop when >= 50% of this page's posts are outside the 1-hour window.
|
||||||
const times = allPosts.map((p) => new Date(p.created_at).getTime())
|
// A handful of old federated posts won't trigger this; once the majority of a page
|
||||||
const spanMs = Math.max(...times) - Math.min(...times)
|
// is old content we have a reliable picture of the last hour.
|
||||||
if (spanMs > 5 * 60 * 1000) break
|
const outsideWindow = posts.filter((p) => new Date(p.created_at).getTime() < cutoff).length
|
||||||
|
if (outsideWindow / posts.length >= 0.5) break
|
||||||
|
|
||||||
maxId = nextMaxId
|
maxId = nextMaxId
|
||||||
|
|
||||||
|
if (page === maxPages - 1) hitPageCap = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [] }
|
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false }
|
||||||
|
|
||||||
|
// Sort globally by created_at so the window filter is accurate regardless of federation order
|
||||||
|
allPosts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
|
||||||
const times = allPosts.map((p) => new Date(p.created_at).getTime())
|
const times = allPosts.map((p) => new Date(p.created_at).getTime())
|
||||||
const newestMs = Math.max(...times)
|
const newestMs = times[0]
|
||||||
const oldestMs = Math.min(...times)
|
const oldestMs = times[times.length - 1]
|
||||||
|
|
||||||
// Minimum 1-minute span to handle flood scenario (all same timestamp)
|
let postsPerHour: number
|
||||||
const spanHours = Math.max((newestMs - oldestMs) / (1000 * 60 * 60), 1 / 60)
|
if (oldestMs < cutoff) {
|
||||||
const postsPerHour = allPosts.length / spanHours
|
// We reached (or passed) the 1-hour horizon — count posts within the last hour directly
|
||||||
|
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
|
||||||
|
} else if (hitPageCap) {
|
||||||
|
// Hit the page cap and never reached the horizon — burst scenario, more posts exist
|
||||||
|
// beyond what we fetched. Extrapolate using only in-window posts:
|
||||||
|
// rate = inWindowCount / coveredHours, where coveredHours = (now - oldestInWindowPost) / ONE_HOUR_MS
|
||||||
|
// This gives posts-per-hour as if the same rate continued for the full 60 minutes.
|
||||||
|
// Minimum 1-minute covered span to avoid divide-by-zero on a single-post window.
|
||||||
|
const inWindowPosts = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff)
|
||||||
|
const oldestInWindowMs = inWindowPosts.length > 0
|
||||||
|
? Math.min(...inWindowPosts.map((p) => new Date(p.created_at).getTime()))
|
||||||
|
: newestMs
|
||||||
|
const coveredMs = Math.max(now - oldestInWindowMs, 60_000)
|
||||||
|
postsPerHour = inWindowPosts.length / (coveredMs / ONE_HOUR_MS)
|
||||||
|
} else {
|
||||||
|
// Timeline exhausted — these are all the posts that exist within the last hour.
|
||||||
|
// Use the raw count directly; extrapolating would inflate a sparse tag.
|
||||||
|
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
|
||||||
|
}
|
||||||
|
|
||||||
// Count co-occurring tags
|
// Count co-occurring tags from the API tags object (authoritative for membership)
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
const lowerTag = tag.toLowerCase()
|
const lowerTag = tag.toLowerCase()
|
||||||
for (const post of allPosts) {
|
for (const post of allPosts) {
|
||||||
@@ -110,12 +169,21 @@ export async function getPostsData(
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([name]) => name)
|
.map(([name]) => name)
|
||||||
|
|
||||||
// Derive the most common casing variant for the queried tag itself
|
// Derive the most common casing variant for the queried tag itself.
|
||||||
|
// Merges post.tags (e.g. "JavaScript") with HTML-rendered span variants for better coverage.
|
||||||
const casingCounts = new Map<string, number>()
|
const casingCounts = new Map<string, number>()
|
||||||
for (const post of allPosts) {
|
for (const post of allPosts) {
|
||||||
for (const t of post.tags ?? []) {
|
// From API tags array - this is the source of truth for which tags are actually present on the post, but it doesn't preserve original casing (all lowercase).
|
||||||
if (t.name.toLowerCase() === lowerTag) {
|
// for (const t of post.tags ?? []) {
|
||||||
casingCounts.set(t.name, (casingCounts.get(t.name) ?? 0) + 1)
|
// if (t.name.toLowerCase() === lowerTag) {
|
||||||
|
// casingCounts.set(t.name, (casingCounts.get(t.name) ?? 0) + 1)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// From HTML content (preserves original casing as typed by the user)
|
||||||
|
for (const variant of extractTagsFromHtml(post.content)) {
|
||||||
|
if (variant.toLowerCase() === lowerTag) {
|
||||||
|
casingCounts.set(variant, (casingCounts.get(variant) ?? 0) + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,5 +194,28 @@ export async function getPostsData(
|
|||||||
if (topCount / total >= 0.5) displayTag = topVariant
|
if (topCount / total >= 0.5) displayTag = topVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
return { postsPerHour, relatedTags, displayTag }
|
const pagesFetched = hitPageCap
|
||||||
|
? maxPages
|
||||||
|
: allPosts.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.ceil(allPosts.length / postLimit)
|
||||||
|
|
||||||
|
const relAge = (ms: number) => {
|
||||||
|
const diffMs = now - ms
|
||||||
|
const d = Math.floor(diffMs / 86_400_000)
|
||||||
|
const h = Math.floor((diffMs % 86_400_000) / 3_600_000)
|
||||||
|
const m = Math.floor((diffMs % 3_600_000) / 60_000)
|
||||||
|
return `${d}d ${h}h ${m}m ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inWindowCount = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
|
||||||
|
const method = oldestMs < cutoff ? 'direct' : hitPageCap ? 'extrapolated' : 'raw'
|
||||||
|
console.log(
|
||||||
|
`[mastodon] #${tag} — pages: ${pagesFetched}, posts: ${allPosts.length} (${inWindowCount} in-window), ` +
|
||||||
|
`between: ${relAge(oldestMs)} - ${relAge(newestMs)}, ` +
|
||||||
|
`pph: ${postsPerHour.toFixed(2)} (${method})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { postsPerHour: Math.round(postsPerHour * 10) / 10, relatedTags, displayTag, hasAnyPosts: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-12
@@ -1,17 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Converts posts-per-hour to a share price.
|
* Converts posts-per-hour to a share price using a saturating (Michaelis-Menten) curve.
|
||||||
*
|
*
|
||||||
* Linear scale: $0.25 per post/hour, minimum $0.25.
|
* Formula: price = base * pph / (1 + k * pph)
|
||||||
* Examples:
|
* where k is chosen so the curve hits $250 at 3 600 PPH.
|
||||||
* 1 post/hr → $0.25
|
*
|
||||||
* 10 posts/hr → $2.50
|
* Anchor points:
|
||||||
* 100 → $25.00
|
* 1 post/hr → ~$0.25
|
||||||
* 1000 → $250.00
|
* 10 posts/hr → ~$2.48
|
||||||
* 12 000 (viral #happynewyear) → $3 000
|
* 100 posts/hr → ~$23.32
|
||||||
|
* 1 000 → ~$145
|
||||||
|
* 3 600 (viral) → $250.00 (design target)
|
||||||
|
* Asymptote → ~$346
|
||||||
|
*
|
||||||
|
* Floor: $0.25 for ≤ 1 PPH.
|
||||||
*/
|
*/
|
||||||
export function calcPrice(postsPerHour: number): number {
|
export function calcPrice(postsPerHour: number): number {
|
||||||
if (postsPerHour <= 0) return 0.25
|
if (postsPerHour <= 1) return 0.25
|
||||||
return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100)
|
const base = 0.25 // The base price at low volumes (1 PPH)
|
||||||
|
const anchor = 3600 // PPH at which we want the target price (1 PPS)
|
||||||
|
const target = 250 // price at the anchor PPH
|
||||||
|
const k = ((base * anchor / target) - 1) / anchor
|
||||||
|
const price = base * postsPerHour / (1 + k * postsPerHour)
|
||||||
|
return Math.max(0.25, Math.round(price * 100) / 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +49,9 @@ export function getBalanceTier(balance: number): BalanceTier {
|
|||||||
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Round a dollar amount to 2 decimal places for DB storage. */
|
||||||
|
export const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
|
|
||||||
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
|
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
|
||||||
export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
|
export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
|
||||||
if (sharesOutstanding <= 0) return 1.00
|
if (sharesOutstanding <= 0) return 1.00
|
||||||
@@ -78,8 +91,12 @@ export function calcTrade(
|
|||||||
return { total, balanceDelta: -total, profit: 0 }
|
return { total, balanceDelta: -total, profit: 0 }
|
||||||
}
|
}
|
||||||
case 'SELL_SHORT': {
|
case 'SELL_SHORT': {
|
||||||
const returned = Math.max(0, (2 * avgBuyPrice - price) * shares)
|
// The collateral model: BUY_SHORT debited avgBuyPrice*shares. On close the
|
||||||
const profit = returned - avgBuyPrice * shares
|
// formula (2*avgBuyPrice - price)*shares returns the net credit/debit so that
|
||||||
|
// the combined two-leg P&L equals (avgBuyPrice - price)*shares.
|
||||||
|
// No cap: when price > 2*avgBuyPrice the balance goes negative (realistic loss).
|
||||||
|
const returned = (2 * avgBuyPrice - price) * shares
|
||||||
|
const profit = returned - avgBuyPrice * shares // = (avgBuyPrice - price) * shares
|
||||||
return { total: returned, balanceDelta: returned, profit }
|
return { total: returned, balanceDelta: returned, profit }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,11 @@ export const schedulerQueue = new Queue('hashex-scheduler', {
|
|||||||
removeOnFail: { count: 5 },
|
removeOnFail: { count: 5 },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', {
|
||||||
|
connection: redisOpts(),
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: { count: 10 },
|
||||||
|
removeOnFail: { count: 10 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
+15
-2
@@ -6,6 +6,15 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(value: number): string {
|
export function formatCurrency(value: number): string {
|
||||||
|
const abs = Math.abs(value)
|
||||||
|
if (abs >= 10_000) {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
@@ -32,7 +41,11 @@ export function pnlColor(value: number): string {
|
|||||||
return 'text-slate-400'
|
return 'text-slate-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize a hashtag: lowercase, strip leading #, trim whitespace */
|
/** Normalize a hashtag: lowercase, strip leading #, remove all whitespace and
|
||||||
|
* any character that isn't a letter, digit, or underscore. */
|
||||||
export function normalizeTag(raw: string): string {
|
export function normalizeTag(raw: string): string {
|
||||||
return raw.trim().replace(/^#+/, '').toLowerCase()
|
return raw
|
||||||
|
.replace(/^#+/, '') // strip leading #
|
||||||
|
.replace(/[\s]/g, '') // remove all whitespace
|
||||||
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { default } from 'next-auth/middleware'
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/profile/:path*',
|
|
||||||
'/positions',
|
'/positions',
|
||||||
'/history',
|
'/history',
|
||||||
'/admin/:path*',
|
'/admin/:path*',
|
||||||
|
|||||||
+250
-16
@@ -13,7 +13,7 @@
|
|||||||
import { Worker, Queue } from 'bullmq'
|
import { Worker, Queue } from 'bullmq'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { getPostsData } from '../lib/mastodon'
|
import { getPostsData } from '../lib/mastodon'
|
||||||
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
|
import { calcPrice, calcTrade, dailyResearchPoints, calcFundNav, round2 } from '../lib/pricing'
|
||||||
|
|
||||||
// ── Connection options ────────────────────────────────────────────────────────
|
// ── Connection options ────────────────────────────────────────────────────────
|
||||||
// Use plain connection options so BullMQ uses its own bundled ioredis,
|
// Use plain connection options so BullMQ uses its own bundled ioredis,
|
||||||
@@ -43,6 +43,7 @@ const prisma = new PrismaClient({
|
|||||||
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
|
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
|
||||||
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
||||||
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
|
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
|
||||||
|
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
||||||
|
|
||||||
function activeUntilFromNow(): Date {
|
function activeUntilFromNow(): Date {
|
||||||
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
|
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
|
||||||
@@ -53,6 +54,48 @@ function activeUntilFromNow(): Date {
|
|||||||
const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() })
|
const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() })
|
||||||
const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
|
const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
|
||||||
const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() })
|
const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() })
|
||||||
|
const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', { connection: redisOpts() })
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-close every open position on a hashtag at the given price.
|
||||||
|
* Creates SELL_LONG / SELL_SHORT trade records and credits users' balances.
|
||||||
|
* Returns the number of positions closed.
|
||||||
|
*/
|
||||||
|
async function forceClosePositions(hashtagId: string, price: number, tag: string): Promise<number> {
|
||||||
|
const positions = await prisma.position.findMany({
|
||||||
|
where: { hashtagId, shares: { gt: 0 } },
|
||||||
|
})
|
||||||
|
for (const pos of positions) {
|
||||||
|
const type = pos.positionType === 'LONG' ? 'LIQUIDATE_LONG' as const : 'LIQUIDATE_SHORT' as const
|
||||||
|
const calcType = pos.positionType === 'LONG' ? 'SELL_LONG' as const : 'SELL_SHORT' as const
|
||||||
|
const { total, balanceDelta, profit } = calcTrade(calcType, pos.shares, price, pos.avgBuyPrice)
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: pos.userId },
|
||||||
|
data: { balance: { increment: round2(balanceDelta) } },
|
||||||
|
}),
|
||||||
|
prisma.position.update({
|
||||||
|
where: { id: pos.id },
|
||||||
|
data: { shares: 0 },
|
||||||
|
}),
|
||||||
|
prisma.trade.create({
|
||||||
|
data: {
|
||||||
|
userId: pos.userId,
|
||||||
|
hashtagId,
|
||||||
|
type,
|
||||||
|
shares: pos.shares,
|
||||||
|
price,
|
||||||
|
total,
|
||||||
|
profit,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
console.log(`[price] #${tag} force-closed ${type} for user ${pos.userId} — ${pos.shares} sh @ $${price.toFixed(2)}, P&L $${profit.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
return positions.length
|
||||||
|
}
|
||||||
|
|
||||||
// ── Workers ───────────────────────────────────────────────────────────────────
|
// ── Workers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -85,27 +128,54 @@ const priceWorker = new Worker(
|
|||||||
|
|
||||||
if (postsPerHour === 0) {
|
if (postsPerHour === 0) {
|
||||||
const newZeroCount = hashtag.zeroCount + 1
|
const newZeroCount = hashtag.zeroCount + 1
|
||||||
const shouldDeactivate = ttlExpired && ownerCount === 0
|
|
||||||
|
|
||||||
|
// Zombie threshold: retire the hashtag and force-close any remaining positions
|
||||||
|
if (newZeroCount >= ZOMBIE_ZERO_COUNT) {
|
||||||
|
if (ownerCount > 0) {
|
||||||
|
const closed = await forceClosePositions(hashtagId, hashtag.currentPrice, tag)
|
||||||
|
console.log(`[price] #${tag} zombie threshold (zeroCount=${newZeroCount}) — force-closed ${closed} position(s)`)
|
||||||
|
}
|
||||||
await prisma.hashtag.update({
|
await prisma.hashtag.update({
|
||||||
|
where: { id: hashtagId },
|
||||||
|
data: { zeroCount: newZeroCount, isActive: false, lastUpdated: new Date() },
|
||||||
|
})
|
||||||
|
console.log(`[price] #${tag} retired (zeroCount=${newZeroCount})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDeactivate = ttlExpired && ownerCount === 0
|
||||||
|
const floorPrice = calcPrice(0)
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.hashtag.update({
|
||||||
where: { id: hashtagId },
|
where: { id: hashtagId },
|
||||||
data: {
|
data: {
|
||||||
zeroCount: newZeroCount,
|
zeroCount: newZeroCount,
|
||||||
isActive: shouldDeactivate ? false : hashtag.isActive,
|
isActive: shouldDeactivate ? false : hashtag.isActive,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
// Record floor price in history while the stock is still active so the chart has no gaps
|
||||||
|
...(!shouldDeactivate ? [prisma.priceHistory.create({
|
||||||
|
data: { hashtagId, price: floorPrice, postsPerHour: 0 },
|
||||||
|
})] : []),
|
||||||
|
])
|
||||||
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`)
|
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TTL expired and no holders, deactivate instead of updating
|
// If TTL expired and no holders, record final price then deactivate
|
||||||
if (ttlExpired && ownerCount === 0) {
|
if (ttlExpired && ownerCount === 0) {
|
||||||
await prisma.hashtag.update({
|
const finalPrice = calcPrice(postsPerHour)
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.hashtag.update({
|
||||||
where: { id: hashtagId },
|
where: { id: hashtagId },
|
||||||
data: { isActive: false, lastUpdated: new Date() },
|
data: { currentPrice: finalPrice, isActive: false, lastUpdated: new Date() },
|
||||||
})
|
}),
|
||||||
console.log(`[price] #${tag} deactivated — TTL expired, no holders`)
|
prisma.priceHistory.create({
|
||||||
|
data: { hashtagId, price: finalPrice, postsPerHour },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
console.log(`[price] #${tag} deactivated — TTL expired, no holders (final price $${finalPrice.toFixed(2)})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +234,12 @@ const priceWorker = new Worker(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily maintenance worker — awards research points based on balance milestones.
|
* Daily maintenance worker — awards research points based on balance milestones,
|
||||||
|
* and prunes old price history to control data growth.
|
||||||
|
*
|
||||||
|
* Retention policy:
|
||||||
|
* - Active hashtags: keep last 7 days
|
||||||
|
* - Inactive hashtags: keep last 24 hours
|
||||||
*/
|
*/
|
||||||
const maintenanceWorker = new Worker(
|
const maintenanceWorker = new Worker(
|
||||||
'hashex-maintenance',
|
'hashex-maintenance',
|
||||||
@@ -172,7 +247,7 @@ const maintenanceWorker = new Worker(
|
|||||||
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
|
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
|
||||||
|
|
||||||
const MAX_RESEARCH_POINTS = 10
|
const MAX_RESEARCH_POINTS = 10
|
||||||
const users = await prisma.user.findMany({ select: { id: true, balance: true, researchPoints: true } })
|
const users = await prisma.user.findMany({ where: { isFund: false }, select: { id: true, balance: true, researchPoints: true } })
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const points = dailyResearchPoints(user.balance)
|
const points = dailyResearchPoints(user.balance)
|
||||||
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
|
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
|
||||||
@@ -185,6 +260,148 @@ const maintenanceWorker = new Worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[maintenance] awarded research points to ${users.length} users`)
|
console.log(`[maintenance] awarded research points to ${users.length} users`)
|
||||||
|
|
||||||
|
// ── Price history pruning ──────────────────────────────────────────────
|
||||||
|
const now = new Date()
|
||||||
|
const activeDays = parseInt(process.env.PRICE_HISTORY_ACTIVE_DAYS ?? '7', 10)
|
||||||
|
const inactiveHours = parseInt(process.env.PRICE_HISTORY_INACTIVE_HOURS ?? '24', 10)
|
||||||
|
const cutoff7d = new Date(now.getTime() - activeDays * 24 * 60 * 60 * 1000)
|
||||||
|
const cutoff24h = new Date(now.getTime() - inactiveHours * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Active hashtags: drop history older than 7 days
|
||||||
|
const activeIds = (
|
||||||
|
await prisma.hashtag.findMany({ where: { isActive: true }, select: { id: true } })
|
||||||
|
).map((h) => h.id)
|
||||||
|
|
||||||
|
const deletedActive = activeIds.length > 0
|
||||||
|
? await prisma.priceHistory.deleteMany({
|
||||||
|
where: { hashtagId: { in: activeIds }, recordedAt: { lt: cutoff7d } },
|
||||||
|
})
|
||||||
|
: { count: 0 }
|
||||||
|
|
||||||
|
// Inactive hashtags: drop history older than 24 hours
|
||||||
|
const inactiveIds = (
|
||||||
|
await prisma.hashtag.findMany({ where: { isActive: false }, select: { id: true } })
|
||||||
|
).map((h) => h.id)
|
||||||
|
|
||||||
|
const deletedInactive = inactiveIds.length > 0
|
||||||
|
? await prisma.priceHistory.deleteMany({
|
||||||
|
where: { hashtagId: { in: inactiveIds }, recordedAt: { lt: cutoff24h } },
|
||||||
|
})
|
||||||
|
: { count: 0 }
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
|
||||||
|
`inactive: ${deletedInactive.count} rows removed (>${inactiveHours}h)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Snapshot history pruning (7 days for both) ─────────────────────────
|
||||||
|
const snapshotCutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
const [deletedFundNav, deletedUserPortfolio] = await Promise.all([
|
||||||
|
prisma.fundNavHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
|
||||||
|
prisma.userPortfolioHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
|
||||||
|
])
|
||||||
|
console.log(
|
||||||
|
`[maintenance] pruned snapshots — fund NAV: ${deletedFundNav.count} rows, user portfolio: ${deletedUserPortfolio.count} rows (>7d)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Related hashtag pruning ────────────────────────────────────────────
|
||||||
|
// 1. Null-target records: the related hashtag was deleted (onDelete: SetNull)
|
||||||
|
// or was never tracked. Delete unconditionally — the upsert in the price
|
||||||
|
// worker will recreate them if the co-occurrence is seen again.
|
||||||
|
// 2. Weak associations: low co-occurrence records that were never reinforced,
|
||||||
|
// pruned after 30 days of inactivity.
|
||||||
|
const relatedCutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
const [deletedNullTarget, deletedWeak] = await Promise.all([
|
||||||
|
prisma.relatedHashtag.deleteMany({ where: { relatedId: null } }),
|
||||||
|
prisma.relatedHashtag.deleteMany({
|
||||||
|
where: { relatedId: { not: null }, coOccurrences: { lt: 5 }, updatedAt: { lt: relatedCutoff } },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
console.log(
|
||||||
|
`[maintenance] pruned relatedHashtag — ${deletedNullTarget.count} null-target, ${deletedWeak.count} weak (>30d, <5 co-occurrences)`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ connection: redisOpts() },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fund NAV snapshot worker — records the current NAV of every fund once per hour.
|
||||||
|
*/
|
||||||
|
const fundNavSnapshotWorker = new Worker(
|
||||||
|
'hashex-fund-nav-snapshot',
|
||||||
|
async (job) => {
|
||||||
|
console.log(`[fund-nav] snapshotting all fund NAVs (job ${job.id})`)
|
||||||
|
|
||||||
|
const funds = await prisma.hedgeFund.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
positionType: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const fund of funds) {
|
||||||
|
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const totalValue = fund.user.balance + portfolioValue
|
||||||
|
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||||
|
await prisma.fundNavHistory.create({
|
||||||
|
data: { fundId: fund.id, nav, totalValue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fund-nav] snapshotted ${funds.length} fund(s)`)
|
||||||
|
|
||||||
|
// ── User portfolio snapshots ──────────────────────────────────────────
|
||||||
|
const regularUsers = await prisma.user.findMany({
|
||||||
|
where: { isFund: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
positionType: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const user of regularUsers) {
|
||||||
|
const portfolioValue = user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const totalValue = user.balance + portfolioValue
|
||||||
|
await prisma.userPortfolioHistory.create({
|
||||||
|
data: { userId: user.id, totalValue, portfolioValue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fund-nav] snapshotted ${regularUsers.length} user portfolio(s)`)
|
||||||
},
|
},
|
||||||
{ connection: redisOpts() },
|
{ connection: redisOpts() },
|
||||||
)
|
)
|
||||||
@@ -238,7 +455,12 @@ const schedulerWorker = new Worker(
|
|||||||
// ── Error handlers ────────────────────────────────────────────────────────────
|
// ── Error handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Worker-level connection errors (separate from per-job failures)
|
// Worker-level connection errors (separate from per-job failures)
|
||||||
for (const [name, worker] of [['price', priceWorker], ['maintenance', maintenanceWorker], ['scheduler', schedulerWorker]] as const) {
|
for (const [name, worker] of [
|
||||||
|
['price', priceWorker],
|
||||||
|
['maintenance', maintenanceWorker],
|
||||||
|
['fund-nav', fundNavSnapshotWorker],
|
||||||
|
['scheduler', schedulerWorker],
|
||||||
|
] as const) {
|
||||||
worker.on('error', (err) => {
|
worker.on('error', (err) => {
|
||||||
console.error(`[${name}-worker] connection error:`, err.message)
|
console.error(`[${name}-worker] connection error:`, err.message)
|
||||||
})
|
})
|
||||||
@@ -257,16 +479,18 @@ async function setupRepeatableJobs() {
|
|||||||
// Always wipe existing repeatable registrations first so that:
|
// Always wipe existing repeatable registrations first so that:
|
||||||
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
|
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
|
||||||
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly
|
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly
|
||||||
const [existingScheduler, existingMaintenance] = await Promise.all([
|
const [existingScheduler, existingMaintenance, existingFundNav] = await Promise.all([
|
||||||
schedulerQueue.getRepeatableJobs(),
|
schedulerQueue.getRepeatableJobs(),
|
||||||
maintenanceQueue.getRepeatableJobs(),
|
maintenanceQueue.getRepeatableJobs(),
|
||||||
|
fundNavSnapshotQueue.getRepeatableJobs(),
|
||||||
])
|
])
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
|
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
|
||||||
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
|
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
|
||||||
|
...existingFundNav.map((j) => fundNavSnapshotQueue.removeRepeatableByKey(j.key)),
|
||||||
])
|
])
|
||||||
if (existingScheduler.length || existingMaintenance.length) {
|
if (existingScheduler.length || existingMaintenance.length || existingFundNav.length) {
|
||||||
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance repeatable(s)`)
|
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance + ${existingFundNav.length} fund-nav repeatable(s)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Price update sweep — every N minutes
|
// Price update sweep — every N minutes
|
||||||
@@ -278,12 +502,21 @@ async function setupRepeatableJobs() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Daily maintenance — every day at 00:05 UTC
|
// Daily maintenance — every day at 00:00 Eastern (midnight)
|
||||||
await maintenanceQueue.add(
|
await maintenanceQueue.add(
|
||||||
'daily-maintenance',
|
'daily-maintenance',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { pattern: '5 0 * * *' },
|
repeat: { pattern: '0 0 * * *' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hourly fund NAV snapshot — every 15 minutes
|
||||||
|
await fundNavSnapshotQueue.add(
|
||||||
|
'fund-nav-snapshot',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '*/15 * * * *' },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,6 +539,7 @@ async function shutdown() {
|
|||||||
console.log('[worker] shutting down…')
|
console.log('[worker] shutting down…')
|
||||||
await priceWorker.close()
|
await priceWorker.close()
|
||||||
await maintenanceWorker.close()
|
await maintenanceWorker.close()
|
||||||
|
await fundNavSnapshotWorker.close()
|
||||||
await schedulerWorker.close()
|
await schedulerWorker.close()
|
||||||
await prisma.$disconnect()
|
await prisma.$disconnect()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user