Compare commits

...

31 Commits
main ... fools

Author SHA1 Message Date
Austin Chen
3a2598edfe Embed Discord widget on the right 2022-04-01 17:23:29 -07:00
Austin Chen
4191969ab1 Fix user avatar links 2022-04-01 16:27:05 -07:00
Austin Chen
a71c2226d2 Fallback to user.balance 2022-04-01 13:54:43 -07:00
Austin Chen
aa37d3afde Update limit to 3 slots at a time 2022-04-01 10:59:54 -07:00
Austin Chen
d2ed6f745e Add an expandable explanation 2022-04-01 10:08:31 -07:00
Austin Chen
3b313f319c Call out Manafold's earnings up top 2022-04-01 09:59:19 -07:00
Austin Chen
d9a1ef7328 Correctly categorize transactions 2022-04-01 09:51:45 -07:00
Austin Chen
98ab5e27c2 Permit user to update their own entry for free 2022-04-01 09:37:15 -07:00
Austin Chen
68fb5b578c Restrict to buying 1 slot at a time 2022-04-01 09:24:33 -07:00
Austin Chen
ee3102d092 Show setting value of 0 as a sale 2022-04-01 09:12:42 -07:00
Austin Chen
6ca04b911c Change launch time to 9am 2022-04-01 08:59:17 -07:00
Austin Chen
b28956d2e5 Hide explanation for now 2022-04-01 08:30:00 -07:00
Austin Chen
804a2bb357 Fix build 2022-04-01 08:28:56 -07:00
Austin Chen
297516d092 Prevent buying slots when fake balance is low 2022-04-01 08:26:36 -07:00
Austin Chen
3b1a01f2f8 Show a fake balance involving transactions 2022-04-01 08:10:12 -07:00
Austin Chen
77c9d40751 Fix keys 2022-04-01 08:06:04 -07:00
Austin Chen
9f9df5c4e9 Expand description; track individual earnings 2022-04-01 00:14:18 -07:00
Austin Chen
e687907fdd Deemphasize the original leaderboarders 2022-04-01 00:08:47 -07:00
Austin Chen
7e33c3a68f Increase tax rate to 25%/hour 2022-03-31 23:35:46 -07:00
Austin Chen
432575ae41 Calculate the tax, and show a table of all transactions 2022-03-31 23:31:48 -07:00
Austin Chen
7ffef0294a Grab up to 50 traders 2022-03-31 22:41:40 -07:00
Austin Chen
37d5d5fc93 Store values as well 2022-03-31 22:16:22 -07:00
Austin Chen
709ddfa7a9 Store transactions and manipulate client-side 2022-03-31 21:52:06 -07:00
Austin Chen
5311718619 Tweak padding 2022-03-31 21:50:22 -07:00
James Grugett
85c362d357 Call buyLeaderboardSlot from client 2022-03-31 23:49:10 -05:00
James Grugett
de562799f4 Create skeleton of buyLeaderboardSlot cloud function. 2022-03-31 23:39:24 -05:00
Austin Chen
17b9ccae83 UI tweaks, message updates 2022-03-31 15:54:40 -07:00
Austin Chen
52c4e829da Add more information to popup 2022-03-31 12:55:40 -07:00
Austin Chen
97f6bddabc Stack leaderboards; add explanation 2022-03-31 12:41:25 -07:00
Austin Chen
c1a84e23e0 Replace leaderboards with buyable Manaboards 2022-03-31 12:14:29 -07:00
Austin Chen
838798a553 Link to Leaderboard from sidebar 2022-03-31 10:35:57 -07:00
13 changed files with 790 additions and 8 deletions

View File

@ -52,5 +52,10 @@ service cloud.firestore {
allow read; allow read;
allow write: if request.auth.uid == userId; allow write: if request.auth.uid == userId;
} }
match /transactions/{transactionId} {
allow read;
allow write;
}
} }
} }

View File

@ -0,0 +1,84 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from '../../common/user'
export const buyLeaderboardSlot = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
slotId: string
reassessValue: number
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists)
return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User
const { slotId, reassessValue } = data
// TODO: find most recent purchase of slotId.
// Fake data below:
const prevSlotPurchase = {
id: slotId,
reassessValue: 100,
userId: '',
timestamp: 0,
}
if (prevSlotPurchase) {
const prevSlotUserDoc = firestore.doc(
`users/${prevSlotPurchase.userId}`
)
const prevSlotUserSnap = await transaction.get(prevSlotUserDoc)
if (!prevSlotUserSnap.exists)
return { status: 'error', message: 'Previous slot owner not found' }
const prevSlotUser = prevSlotUserSnap.data() as User
const timeSinceLastPurchase = Date.now() - prevSlotPurchase.timestamp
const hoursSinceLastPurchase =
timeSinceLastPurchase / (1000 * 60 * 60)
const harbergerTax =
prevSlotPurchase.reassessValue * 0.1 * hoursSinceLastPurchase
const prevSlotUserBalance = prevSlotUser.balance - harbergerTax
if (!isFinite(prevSlotUserBalance)) {
throw new Error(
'Invalid user balance for previous slot owner ' +
prevSlotUser.username
)
}
transaction.update(prevSlotUserDoc, { balance: prevSlotUserBalance })
}
// TODO: If no prevSlotPurchase, use a default purchase price?
const newBalance = user.balance - prevSlotPurchase.reassessValue
if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
const newSlotPurchase = {
id: slotId,
reassessValue,
userId,
timestamp: Date.now(),
}
// TODO: save doc newSlotPurchase in some collection.
return { status: 'success', slotPurchase: newSlotPurchase }
})
}
)
const firestore = admin.firestore()

View File

@ -21,3 +21,4 @@ export * from './update-user-metrics'
export * from './backup-db' export * from './backup-db'
export * from './change-user-info' export * from './change-user-info'
export * from './market-close-emails' export * from './market-close-emails'
export * from './buy-leaderboard-slot'

View File

@ -0,0 +1,315 @@
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { ENV_CONFIG } from '../../common/envs/constants'
import { User } from '../../common/user'
import { formatMoney } from '../../common/util/format'
import { useUser } from '../hooks/use-user'
import { buyLeaderboardSlot } from '../lib/firebase/api-call'
import {
SlotData,
Transaction,
writeTransaction,
} from '../lib/firebase/transactions'
import { loadFakeBalance } from '../pages/leaderboards'
import { AddFundsButton } from './add-funds-button'
import { AmountInput } from './amount-input'
import { Avatar } from './avatar'
import { Col } from './layout/col'
import { Modal } from './layout/modal'
import { Row } from './layout/row'
import { SiteLink } from './site-link'
import { Title } from './title'
export function Manaboard(props: {
title: string
users: User[]
values: number[]
createdTimes: number[]
className?: string
}) {
// TODO: Ideally, highlight your own entry on the leaderboard
let { title, users, className, values, createdTimes } = props
const [expanded, setExpanded] = useState(false)
if (!expanded) {
users = users.slice(0, 25)
values = values.slice(0, 25)
}
return (
<div className={clsx('w-full px-1', className)}>
<Title text={title} className="!mt-0" />
{users.length === 0 ? (
<div className="ml-2 text-gray-500">None yet</div>
) : (
<div className="overflow-x-auto">
<table className="table-zebra table-compact table w-full text-gray-500">
<thead>
<tr className="p-2">
<th>
<div className="pl-2">#</div>
</th>
<th>Name</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={user.id + index}>
<td>
<div className="pl-2">{index + 1}</div>
</td>
<td className="w-full" style={{ maxWidth: 190 }}>
<Row className="items-center gap-4">
<SiteLink href={`/${user.username}`}>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={8}
/>
</SiteLink>
<div
className={clsx(
'truncate',
createdTimes[index]
? 'text-gray-600'
: 'text-gray-300'
)}
>
{user.name}
</div>
</Row>
</td>
<td>
<Row className="items-center gap-4">
{formatMoney(values[index])}
<BuySlotModal
slot={index + 1}
title={`${title}`}
holder={user}
value={values[index]}
createdTime={createdTimes[index]}
allSlots={users}
/>
</Row>
</td>
<td></td>
</tr>
))}
</tbody>
</table>
<button
className="btn btn-sm btn-outline m-2"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Fewer slots' : 'More slots'}
</button>
</div>
)}
</div>
)
}
function Label(props: { children: React.ReactNode }) {
return <label className="-mb-3 text-sm">{props.children}</label>
}
export function BuySlotModal(props: {
title: string
holder: User
slot: number
value: number
createdTime: number
allSlots: User[]
}) {
const { slot, allSlots, holder, value, createdTime } = props
const user = useUser()
const [open, setOpen] = useState(false)
const [newValue, setNewValue] = useState(value)
const [message, setMessage] = useState('')
useEffect(() => {
if (user?.name) {
setMessage(user.name)
}
}, [user])
// const onBuy = async () => {
// // Feel free to change this. - James
// const slotId = `${title}-${slot}`
// await buyLeaderboardSlot({ slotId, reassessValue: newValue })
// }
// Find all slots the user currently owns
const existingSlots = []
for (let i = 0; i < allSlots.length; i++) {
if (allSlots[i].id === user?.id) {
existingSlots.push(i + 1)
}
}
// Prevent them from holding more than three slots at once
let errorMsg = ''
if (existingSlots.length >= 3 && !existingSlots.includes(slot)) {
errorMsg = 'Sell another slot first (by re-valuing it to M$ 0)'
}
async function onBuy() {
if (user) {
// Start transactions, but don't block
const buyData = { slot, newValue, message }
const buyTxn = buyTransaction({
buyer: user,
holder,
amount: value,
buyData,
})
await Promise.all([
writeTransaction(buyTxn),
writeTransaction(taxTransaction({ holder, slot, value, createdTime })),
])
setOpen(false)
}
}
const fakeBalance = loadFakeBalance()
const noFundsMsg =
value > fakeBalance && holder.id !== user?.id
? `You only have ${formatMoney(fakeBalance)}!`
: ''
return (
<>
<Modal open={open} setOpen={setOpen}>
<Col className="gap-5 rounded-md bg-white p-6 text-gray-500">
<Title text={`Buy slot #${slot}`} className="!mt-0" />
<Label>Current value: {formatMoney(value)}</Label>
{user && (
<Row className="items-center gap-4 rounded-md bg-gray-100 p-2 text-sm">
<div className="pl-2">{slot}</div>
<Avatar avatarUrl={user.avatarUrl} size={8} />
<div className="truncate">{message}</div>
</Row>
)}
<Label>(Optional) set message</Label>
<input
type="text"
className="input input-bordered w-full max-w-xs"
onChange={(e) => {
setMessage(e.target.value)
}}
value={message}
/>
<Label>Reassess value</Label>
<AmountInput
amount={newValue}
onChange={(amount) =>
setNewValue(amount && amount >= 1 ? amount : 0)
}
error={errorMsg}
label={ENV_CONFIG.moneyMoniker}
/>
{noFundsMsg ? (
<div className="alert alert-error">
{noFundsMsg}{' '}
<span className="!text-gray-600">
<AddFundsButton />
</span>
</div>
) : (
<Col>
<button
className="btn btn-primary"
onClick={onBuy}
disabled={!!errorMsg}
>
Buy Slot ({formatMoney(value)})
</button>
<div className="mt-2 text-sm">
Additional fees: {formatMoney(newValue * 0.25)} per hour
</div>
</Col>
)}
</Col>
</Modal>
<button
className="btn btn-outline btn-sm normal-case"
onClick={() => setOpen(true)}
>
Buy
</button>
</>
)
}
function buyTransaction(options: {
buyer: User
holder: User
buyData: SlotData
amount: number
}): Transaction {
const { buyer, holder, buyData, amount } = options
return {
id: '',
createdTime: Date.now(),
fromId: buyer.id,
fromName: buyer.name,
fromUsername: buyer.username,
fromAvatarUrl: buyer.avatarUrl,
toId: holder.id,
toName: holder.name,
toUsername: holder.username,
toAvatarUrl: holder.avatarUrl,
amount,
category: 'BUY_LEADERBOARD_SLOT',
description: `${buyer.name} bought a slot from ${holder.name}`,
data: buyData,
}
}
function taxTransaction(options: {
holder: User
slot: number
value: number
createdTime: number
}): Transaction {
const { holder, slot, value, createdTime } = options
const APRIL_FOOLS_9AM_PT = 1648828800000
const elapsedMs = Date.now() - (createdTime || APRIL_FOOLS_9AM_PT)
const elapsedHours = elapsedMs / 1000 / 60 / 60
const tax = elapsedHours * value * 0.25
return {
id: '',
createdTime: Date.now(),
fromId: holder.id,
fromName: holder.name,
fromUsername: holder.username,
fromAvatarUrl: holder.avatarUrl,
// Send fee to Manifold Markets official account
toId: 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2',
toName: 'Manifold Markets',
toUsername: 'ManifoldMarkets',
toAvatarUrl: 'https://manifold.markets/logo-bg-white.png',
amount: tax,
category: 'LEADERBOARD_TAX',
description: `${holder.name} paid M$ 10 in fees`,
data: {
slot,
},
}
}

View File

@ -3,6 +3,7 @@ import { formatMoney } from '../../../common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { IS_PRIVATE_MANIFOLD } from '../../../common/envs/constants' import { IS_PRIVATE_MANIFOLD } from '../../../common/envs/constants'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { loadFakeBalance } from '../../pages/leaderboards'
export function getNavigationOptions(user?: User | null) { export function getNavigationOptions(user?: User | null) {
if (IS_PRIVATE_MANIFOLD) { if (IS_PRIVATE_MANIFOLD) {
@ -34,7 +35,9 @@ export function ProfileSummary(props: { user: User | undefined }) {
<div className="truncate text-left"> <div className="truncate text-left">
<div>{user?.name}</div> <div>{user?.name}</div>
<div className="text-sm"> <div className="text-sm">
{user ? formatMoney(Math.floor(user.balance)) : ' '} {user
? formatMoney(Math.floor(loadFakeBalance() || user.balance))
: ' '}
</div> </div>
</div> </div>
</Row> </Row>

View File

@ -4,6 +4,7 @@ import {
SearchIcon, SearchIcon,
BookOpenIcon, BookOpenIcon,
DotsHorizontalIcon, DotsHorizontalIcon,
ChartSquareBarIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import _ from 'lodash' import _ from 'lodash'
@ -19,6 +20,7 @@ import { getNavigationOptions, ProfileSummary } from './profile-menu'
const navigation = [ const navigation = [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Markets', href: '/markets', icon: SearchIcon }, { name: 'Markets', href: '/markets', icon: SearchIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: ChartSquareBarIcon },
{ name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon },
] ]

View File

@ -0,0 +1,11 @@
import { useState, useEffect } from 'react'
import {
listenForTransactions,
Transaction,
} from '../lib/firebase/transactions'
export const useTransactions = () => {
const [transactions, setTransactions] = useState<Transaction[] | undefined>()
useEffect(() => listenForTransactions(setTransactions), [])
return transactions
}

View File

@ -66,3 +66,8 @@ export const changeUserInfo = (data: {
.then((r) => r.data as { status: string; message?: string }) .then((r) => r.data as { status: string; message?: string })
.catch((e) => ({ status: 'error', message: e.message })) .catch((e) => ({ status: 'error', message: e.message }))
} }
export const buyLeaderboardSlot = cloudFunction<
{ slotId: string; reassessValue: number },
{ status: 'success' | 'error'; message?: string }
>('buyLeaderboardSlot')

View File

@ -0,0 +1,57 @@
import { collection, doc, query, setDoc } from 'firebase/firestore'
import { db } from './init'
import { getValues, listenForValues } from './utils'
export type Transaction = {
id: string
createdTime: number
fromId: string
fromName: string
fromUsername: string
fromAvatarUrl?: string
toId: string
toName: string
toUsername: string
toAvatarUrl?: string
amount: number
category: 'BUY_LEADERBOARD_SLOT' | 'LEADERBOARD_TAX'
// Human-readable description
description?: string
// Structured metadata for different kinds of transactions
data?: SlotData | TaxData
}
export type SlotData = {
slot: number
newValue: number
message: string
}
export type TaxData = {
slot: number
}
export async function listAllTransactions() {
const col = collection(db, 'transactions')
const transactions = await getValues<Transaction>(col)
transactions.sort((t1, t2) => t1.createdTime - t2.createdTime)
return transactions
}
export function listenForTransactions(setTxns: (txns: Transaction[]) => void) {
const col = collection(db, 'transactions')
const queryAll = query(col)
return listenForValues<Transaction>(queryAll, setTxns)
}
export async function writeTransaction(transaction: Transaction) {
const col = collection(db, 'transactions')
const newRef = doc(col)
transaction.id = newRef.id
await setDoc(newRef, transaction)
}

View File

@ -160,12 +160,12 @@ export function listenForPrivateUsers(
const topTradersQuery = query( const topTradersQuery = query(
collection(db, 'users'), collection(db, 'users'),
orderBy('totalPnLCached', 'desc'), orderBy('totalPnLCached', 'desc'),
limit(21) limit(51)
) )
export async function getTopTraders() { export async function getTopTraders() {
const users = await getValues<User>(topTradersQuery) const users = await getValues<User>(topTradersQuery)
return users.slice(0, 20) return users.slice(0, 50)
} }
const topCreatorsQuery = query( const topCreatorsQuery = query(

View File

@ -20,6 +20,7 @@
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.5",
"@nivo/core": "0.74.0", "@nivo/core": "0.74.0",
"@nivo/line": "0.74.0", "@nivo/line": "0.74.0",
"@widgetbot/react-embed": "^1.4.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"daisyui": "1.16.4", "daisyui": "1.16.4",
"dayjs": "1.10.7", "dayjs": "1.10.7",

View File

@ -6,6 +6,18 @@ import { Page } from '../components/page'
import { getTopCreators, getTopTraders, User } from '../lib/firebase/users' import { getTopCreators, getTopTraders, User } from '../lib/firebase/users'
import { formatMoney } from '../../common/util/format' import { formatMoney } from '../../common/util/format'
import { fromPropz, usePropz } from '../hooks/use-propz' 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'
import { useState } from 'react'
import WidgetBot from '@widgetbot/react-embed'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() { export async function getStaticPropz() {
@ -24,10 +36,7 @@ export async function getStaticPropz() {
} }
} }
export default function Leaderboards(props: { function Leaderboards(props: { topTraders: User[]; topCreators: User[] }) {
topTraders: User[]
topCreators: User[]
}) {
props = usePropz(props, getStaticPropz) ?? { props = usePropz(props, getStaticPropz) ?? {
topTraders: [], topTraders: [],
topCreators: [], topCreators: [],
@ -61,3 +70,270 @@ export default function Leaderboards(props: {
</Page> </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 &quot;assessed value&quot;: 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&#39;s mana go?
</h3>
<p>
Honestly, we&#39;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&#39;);DROP TABLE Balances;--</code>
</li>
</ul>
<p>
We&#39;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] = 50 - 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)
}
const [expandInfo, setExpandInfo] = useState(false)
return (
<Page margin rightSidebar={<DiscordWidget />}>
<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&#39;re selling our
leaderboard slots to recoup our losses. Buy one now to earn fleeting
glory and keep Manafold afloat!
</p>
<div className="alert alert-success">
Mana replenished: {formatMoney(userProfits(MANIFOLD_ID, txns))}
<button
className="btn btn-outline btn-sm normal-case"
onClick={() => setExpandInfo(!expandInfo)}
>
More info
</button>
</div>
{expandInfo && <Explanation />}
</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>
)}
<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
// sort={true}
pagination={{
enabled: true,
limit: 25,
}}
columns={[
{
id: 'data',
name: 'Slot',
formatter: (cell) => (cell as SlotData).slot,
},
{
id: 'category',
name: 'Type',
formatter: (cell, row) => {
if (cell === 'LEADERBOARD_TAX') {
return 'Tax'
}
// If newValue === 0
// @ts-ignore
if (row.cells[6].data?.newValue === 0) {
return 'Sell'
}
// If fromUser === toUser
if (row.cells[3].data === row.cells[4].data) {
return 'Edit'
}
return 'Buy'
},
},
{
id: 'amount',
name: 'Transfer',
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>`
),
},
{
hidden: true,
id: 'data',
name: 'New Value',
formatter: (cell) => (cell as SlotData).newValue ?? '',
},
]}
/>
)
}
function DiscordWidget() {
return typeof window === 'undefined' ? null : (
<WidgetBot
className="mt-4 h-[80vh]"
server="915138780216823849"
channel="959499868089507930"
/>
)
}

View File

@ -1112,6 +1112,19 @@
"@typescript-eslint/types" "4.33.0" "@typescript-eslint/types" "4.33.0"
eslint-visitor-keys "^2.0.0" eslint-visitor-keys "^2.0.0"
"@widgetbot/embed-api@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@widgetbot/embed-api/-/embed-api-1.1.3.tgz#c7fd8069d7ce2ec7740d8bf4140c786c636fb3d6"
integrity sha1-x/2AadfOLsd0DYv0FAx4bGNvs9Y=
"@widgetbot/react-embed@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@widgetbot/react-embed/-/react-embed-1.4.0.tgz#b0b617629e0e2cd6ff7a4770db34e0c52e056a43"
integrity sha512-rN/zyv8ndn+I3g1fCMql2NN+2Yn04XVhwL1GHQlSKEvFWNXsqEDyXO1MaDxcvJFcG7cSQLRTcvgVWzAVe+3Fag==
dependencies:
"@widgetbot/embed-api" "^1.1.3"
react "^16.13.1"
abort-controller@^3.0.0: abort-controller@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
@ -4303,7 +4316,7 @@ promisify-call@^2.0.2:
dependencies: dependencies:
with-callback "^1.0.2" with-callback "^1.0.2"
prop-types@^15.5.8, prop-types@^15.7.2: prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.8.1" version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -4493,6 +4506,15 @@ react@17.0.2:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
react@^16.13.1:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
readable-stream@1.1.x: readable-stream@1.1.x:
version "1.1.14" version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"