297 lines
8.3 KiB
TypeScript
297 lines
8.3 KiB
TypeScript
import _ from 'lodash'
|
|
|
|
import { Col } from '../components/layout/col'
|
|
import { Leaderboard } from '../components/leaderboard'
|
|
import { Page } from '../components/page'
|
|
import { getTopCreators, getTopTraders, User } from '../lib/firebase/users'
|
|
import { formatMoney } from '../../common/util/format'
|
|
import { fromPropz, usePropz } from '../hooks/use-propz'
|
|
import { Manaboard } from '../components/manaboard'
|
|
import { Title } from '../components/title'
|
|
import { useTransactions } from '../hooks/use-transactions'
|
|
import { SlotData, Transaction } from '../lib/firebase/transactions'
|
|
|
|
import { Grid, _ as r } from 'gridjs-react'
|
|
import 'gridjs/dist/theme/mermaid.css'
|
|
import { html } from 'gridjs'
|
|
import dayjs from 'dayjs'
|
|
import { useUser } from '../hooks/use-user'
|
|
|
|
export const getStaticProps = fromPropz(getStaticPropz)
|
|
export async function getStaticPropz() {
|
|
const [topTraders, topCreators] = await Promise.all([
|
|
getTopTraders().catch((_) => {}),
|
|
getTopCreators().catch((_) => {}),
|
|
])
|
|
|
|
return {
|
|
props: {
|
|
topTraders,
|
|
topCreators,
|
|
},
|
|
|
|
revalidate: 60, // regenerate after a minute
|
|
}
|
|
}
|
|
|
|
function Leaderboards(props: { topTraders: User[]; topCreators: User[] }) {
|
|
props = usePropz(props, getStaticPropz) ?? {
|
|
topTraders: [],
|
|
topCreators: [],
|
|
}
|
|
const { topTraders, topCreators } = props
|
|
|
|
return (
|
|
<Page margin>
|
|
<Col className="items-center gap-10 lg:flex-row">
|
|
<Leaderboard
|
|
title="🏅 Top traders"
|
|
users={topTraders}
|
|
columns={[
|
|
{
|
|
header: 'Total profit',
|
|
renderCell: (user) => formatMoney(user.totalPnLCached),
|
|
},
|
|
]}
|
|
/>
|
|
<Leaderboard
|
|
title="🏅 Top creators"
|
|
users={topCreators}
|
|
columns={[
|
|
{
|
|
header: 'Market volume',
|
|
renderCell: (user) => formatMoney(user.creatorVolumeCached),
|
|
},
|
|
]}
|
|
/>
|
|
</Col>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
function Explanation() {
|
|
return (
|
|
<div className="prose mt-8 text-gray-600">
|
|
<h3 id="how-this-works">How this works</h3>
|
|
<ol>
|
|
<li>
|
|
Every slot has an "assessed value": what the current holder
|
|
thinks their slot is worth.
|
|
</li>
|
|
<li>Slot holders pay a continuous fee of 25% per hour to Manafold.</li>
|
|
<li>
|
|
At any time, you can pay the assessed value of a slot to buy it from
|
|
the current holder.
|
|
</li>
|
|
<li>
|
|
The slot is now yours! You can customize the message, or reassess it
|
|
to a new value.
|
|
</li>
|
|
</ol>
|
|
<p>
|
|
<em>
|
|
Note: this mechanism is known as a{' '}
|
|
<a href="https://medium.com/@simondlr/what-is-harberger-tax-where-does-the-blockchain-fit-in-1329046922c6">
|
|
Harberger Tax
|
|
</a>
|
|
!
|
|
</em>
|
|
</p>
|
|
|
|
<h3 id="where-did-manafold-s-mana-go-">
|
|
Where did Manafold's mana go?
|
|
</h3>
|
|
<p>
|
|
Honestly, we're as puzzled as you are. Leading theories include:
|
|
</p>
|
|
<ul>
|
|
<li>Leaky abstractions in our manabase</li>
|
|
<li>One too many floating-point rounding errors</li>
|
|
<li>
|
|
Our newest user <code>Robert');DROP TABLE Balances;--</code>
|
|
</li>
|
|
</ul>
|
|
<p>
|
|
We'd be happy to pay a bounty to anyone who can help us solve this
|
|
riddle... oh wait.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// TODOs
|
|
// [ ] Expandable text for explainer
|
|
// [ ] Draw attention to leaderboard
|
|
// [ ] Restrict to at most buying one slot per user?
|
|
export default function Manaboards(props: {
|
|
topTraders: User[]
|
|
topCreators: User[]
|
|
}) {
|
|
props = usePropz(props, getStaticPropz) ?? {
|
|
topTraders: [],
|
|
topCreators: [],
|
|
}
|
|
const { topTraders, topCreators } = props
|
|
const slots = _.clone(topTraders)
|
|
const user = useUser()
|
|
|
|
const values = Array.from(Array(slots.length).keys())
|
|
.map((i) => i + 1)
|
|
.reverse()
|
|
const createdTimes = new Array(slots.length).fill(0)
|
|
|
|
// Find the most recent purchases of each slot, and replace the entries in slots
|
|
const txns = useTransactions() ?? []
|
|
// Iterate from oldest to newest transactions, so recent purchases overwrite older ones
|
|
const sortedTxns = _.sortBy(txns, 'createdTime')
|
|
for (const txn of sortedTxns) {
|
|
if (txn.category === 'BUY_LEADERBOARD_SLOT') {
|
|
const buyer = userFromBuy(txn)
|
|
const data = txn.data as SlotData
|
|
const slot = data.slot
|
|
slots[slot - 1] = buyer
|
|
values[slot - 1] = data.newValue
|
|
createdTimes[slot - 1] = txn.createdTime
|
|
|
|
// If new value is 0, that's a sell; reset to topTrader
|
|
if (data.newValue === 0) {
|
|
slots[slot - 1] = topTraders[slot - 1]
|
|
values[slot - 1] = slot
|
|
createdTimes[slot - 1] = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
function userFromBuy(txn: Transaction): User {
|
|
return {
|
|
id: txn.fromId,
|
|
// @ts-ignore
|
|
name: txn.data?.message ?? txn.fromName,
|
|
username: txn.fromUsername,
|
|
avatarUrl: txn.fromAvatarUrl,
|
|
|
|
// Dummy data which shouldn't be relied on
|
|
createdTime: 0,
|
|
creatorVolumeCached: 0,
|
|
totalPnLCached: 0,
|
|
balance: 0,
|
|
totalDeposits: 0,
|
|
}
|
|
}
|
|
|
|
const MANIFOLD_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2'
|
|
if (user?.balance) {
|
|
saveFakeBalance(userProfits(user.id, txns) + user.balance)
|
|
}
|
|
|
|
return (
|
|
<Page margin>
|
|
<Title text={'🏅 Leaderboard slots, for sale!'} />
|
|
{/* <div className="absolute right-[700px] top-8">
|
|
<img
|
|
className="h-18 mx-auto w-24 object-cover transition hover:rotate-12"
|
|
src="https://i.etsystatic.com/8800089/r/il/b79fe6/1591362635/il_fullxfull.1591362635_4523.jpg"
|
|
/>
|
|
</div> */}
|
|
<div className="prose mb-8 text-gray-600">
|
|
<p>
|
|
Manafold Markets is running out of mana... so we're selling our
|
|
leaderboard slots to recoup our losses. Buy one now to earn fleeting
|
|
glory and keep Manafold afloat!
|
|
</p>
|
|
</div>
|
|
|
|
<Col className="mt-6 gap-10">
|
|
<Manaboard
|
|
title=""
|
|
users={slots}
|
|
values={values}
|
|
createdTimes={createdTimes}
|
|
/>
|
|
{/* <Manaboard title="🏅 Top creators" users={topCreators} /> */}
|
|
|
|
<div className="text-sm">
|
|
<Title text={'Transaction history'} />
|
|
{user && (
|
|
<p>Your earnings: {formatMoney(userProfits(user.id, txns))}</p>
|
|
)}
|
|
<p>
|
|
Manafold's earnings: {formatMoney(userProfits(MANIFOLD_ID, txns))}
|
|
</p>
|
|
<TransactionsTable txns={_.reverse(sortedTxns)} />
|
|
</div>
|
|
</Col>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
function userProfits(userId: string, txns: Transaction[]) {
|
|
const losses = txns.filter((txn) => txn.fromId === userId)
|
|
const loss = _.sumBy(losses, (txn) => txn.amount)
|
|
const profits = txns.filter((txn) => txn.toId === userId)
|
|
const profit = _.sumBy(profits, (txn) => txn.amount)
|
|
return profit - loss
|
|
}
|
|
|
|
// Cache user's transaction profits to localStorage
|
|
const FAKE_BALANCE_KEY = 'fake-balance'
|
|
export function saveFakeBalance(profit: number) {
|
|
localStorage.setItem(FAKE_BALANCE_KEY, JSON.stringify(profit))
|
|
}
|
|
|
|
export function loadFakeBalance() {
|
|
if (typeof window !== 'undefined') {
|
|
const profit = localStorage.getItem(FAKE_BALANCE_KEY)
|
|
return profit ? JSON.parse(profit) : 0
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function TransactionsTable(props: { txns: Transaction[] }) {
|
|
const { txns } = props
|
|
return (
|
|
<Grid
|
|
data={txns}
|
|
search={true}
|
|
// sort={true}
|
|
pagination={{
|
|
enabled: true,
|
|
limit: 25,
|
|
}}
|
|
columns={[
|
|
{
|
|
id: 'data',
|
|
name: 'Slot',
|
|
formatter: (cell) => (cell as SlotData).slot,
|
|
},
|
|
{
|
|
id: 'category',
|
|
name: 'Type',
|
|
formatter: (cell) =>
|
|
cell === 'BUY_LEADERBOARD_SLOT' ? 'Buy' : 'Tax',
|
|
},
|
|
{
|
|
id: 'amount',
|
|
name: 'Amount',
|
|
formatter: (cell) => formatMoney(cell as number),
|
|
},
|
|
{
|
|
id: 'fromUsername',
|
|
name: 'From',
|
|
},
|
|
{ id: 'toUsername', name: 'To' },
|
|
{
|
|
id: 'createdTime',
|
|
name: 'Time',
|
|
formatter: (cell) =>
|
|
html(
|
|
`<span class="whitespace-nowrap">${dayjs(cell as number).format(
|
|
'h:mma'
|
|
)}</span>`
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
)
|
|
}
|