diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index d40089d7..c3d9595d 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -12,15 +12,16 @@ import { import { Col } from './layout/col' import { parseTags } from '../lib/util/parse' import dayjs from 'dayjs' -import { TrendingUpIcon } from '@heroicons/react/solid' +import { TrendingUpIcon, ClockIcon } from '@heroicons/react/solid' import { DateTimeTooltip } from './datetime-tooltip' export function ContractCard(props: { contract: Contract showHotVolume?: boolean + showCloseTime?: boolean className?: string }) { - const { contract, showHotVolume, className } = props + const { contract, showHotVolume, showCloseTime, className } = props const { question, resolution } = contract const { probPercent } = contractMetrics(contract) @@ -42,7 +43,11 @@ export function ContractCard(props: { probPercent={probPercent} /> - + ) } @@ -99,9 +104,10 @@ export function ResolutionOrChance(props: { export function AbbrContractDetails(props: { contract: Contract showHotVolume?: boolean + showCloseTime?: boolean }) { - const { contract, showHotVolume } = props - const { volume24Hours, creatorName, creatorUsername } = contract + const { contract, showHotVolume, showCloseTime } = props + const { volume24Hours, creatorName, creatorUsername, closeTime } = contract const { truePool } = contractMetrics(contract) return ( @@ -118,6 +124,11 @@ export function AbbrContractDetails(props: { {' '} {formatMoney(volume24Hours)} + ) : showCloseTime ? ( +
+ {' '} + {dayjs(closeTime).format('MMM D')} +
) : (
{formatMoney(truePool)} pool
)} diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 4a25e4c6..dbeecfb9 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -1,5 +1,6 @@ // From https://tailwindui.com/components/application-ui/lists/feeds import { useState } from 'react' +import _ from 'lodash' import { BanIcon, ChatAltIcon, @@ -10,11 +11,11 @@ import { UsersIcon, XIcon, } from '@heroicons/react/solid' -import { useBets } from '../hooks/use-bets' -import { Bet, withoutAnteBets } from '../lib/firebase/bets' -import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' + import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' +dayjs.extend(relativeTime) + import { OutcomeLabel } from './outcome-label' import { contractMetrics, @@ -33,11 +34,18 @@ import { SiteLink } from './site-link' import { Col } from './layout/col' import { UserLink } from './user-page' import { DateTimeTooltip } from './datetime-tooltip' -dayjs.extend(relativeTime) +import { useBets } from '../hooks/use-bets' +import { Bet, withoutAnteBets } from '../lib/firebase/bets' +import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' +import { JoinSpans } from './join-spans' function FeedComment(props: { activityItem: any }) { const { activityItem } = props const { person, text, amount, outcome, createdTime } = activityItem + + const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) + return ( <> @@ -59,7 +67,7 @@ function FeedComment(props: { activityItem: any }) { username={person.username} name={person.name} />{' '} - placed {formatMoney(amount)} on {' '} + {bought} {money} of {' '}

@@ -97,6 +105,10 @@ function FeedBet(props: { activityItem: any }) { if (!user || !comment) return await createComment(contractId, id, comment, user) } + + const bought = amount >= 0 ? 'bought' : 'sold' + const money = formatMoney(Math.abs(amount)) + return ( <>
@@ -108,9 +120,8 @@ function FeedBet(props: { activityItem: any }) {
- {isCreator ? 'You' : 'A trader'} placed{' '} - {formatMoney(amount)} on {' '} - + {isCreator ? 'You' : 'A trader'} {bought} {money} of{' '} + {canComment && ( // Allow user to comment in an textarea if they are the creator
@@ -452,44 +463,51 @@ function groupBets( return items as ActivityItem[] } +function BetGroupSpan(props: { bets: Bet[]; outcome: 'YES' | 'NO' }) { + const { bets, outcome } = props + + const numberTraders = _.uniqBy(bets, (b) => b.userId).length + + const [buys, sells] = _.partition(bets, (bet) => bet.amount >= 0) + const buyTotal = _.sumBy(buys, (b) => b.amount) + const sellTotal = _.sumBy(sells, (b) => -b.amount) + + return ( + + {numberTraders} {numberTraders > 1 ? 'traders' : 'trader'}{' '} + + {buyTotal > 0 && <>bought {formatMoney(buyTotal)} } + {sellTotal > 0 && <>sold {formatMoney(sellTotal)} } + + of + + ) +} + // TODO: Make this expandable to show all grouped bets? function FeedBetGroup(props: { activityItem: any }) { const { activityItem } = props const bets: Bet[] = activityItem.bets - const yesAmount = bets - .filter((b) => b.outcome == 'YES') - .reduce((acc, bet) => acc + bet.amount, 0) - const yesSpan = yesAmount ? ( - - {formatMoney(yesAmount)} on - - ) : null - const noAmount = bets - .filter((b) => b.outcome == 'NO') - .reduce((acc, bet) => acc + bet.amount, 0) - const noSpan = noAmount ? ( - - {formatMoney(noAmount)} on - - ) : null - const traderCount = bets.length + const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') + const createdTime = bets[0].createdTime return ( <>
-
-
- {traderCount} traders placed {yesSpan} - {yesAmount && noAmount ? ' and ' : ''} - {noSpan} + {yesBets.length > 0 && } + {yesBets.length > 0 && noBets.length > 0 &&
} + {noBets.length > 0 && } +
diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index f90eb4db..d19c0212 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -18,8 +18,9 @@ import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' export function ContractsGrid(props: { contracts: Contract[] showHotVolume?: boolean + showCloseTime?: boolean }) { - const { showHotVolume } = props + const { showHotVolume, showCloseTime } = props const [resolvedContracts, activeContracts] = _.partition( props.contracts, @@ -51,6 +52,7 @@ export function ContractsGrid(props: { contract={contract} key={contract.id} showHotVolume={showHotVolume} + showCloseTime={showCloseTime} /> ))} diff --git a/web/components/datetime-tooltip.tsx b/web/components/datetime-tooltip.tsx index 522d4da1..c6bd1723 100644 --- a/web/components/datetime-tooltip.tsx +++ b/web/components/datetime-tooltip.tsx @@ -13,11 +13,14 @@ export function DateTimeTooltip(props: { }) { const { time } = props return ( - - {props.children} - + <> + + {props.children} + + {props.children} + ) } diff --git a/web/components/join-spans.tsx b/web/components/join-spans.tsx new file mode 100644 index 00000000..e6947ee8 --- /dev/null +++ b/web/components/join-spans.tsx @@ -0,0 +1,31 @@ +export const JoinSpans = (props: { + children: any[] + separator?: JSX.Element | string +}) => { + const { separator } = props + const children = props.children.filter((x) => !!x) + + if (children.length === 0) return <> + if (children.length === 1) return children[0] + if (children.length === 2) + return ( + <> + {children[0]} and {children[1]} + + ) + + const head = children.slice(0, -1).map((child) => ( + <> + {child} + {separator || ','}{' '} + + )) + + const tail = children[children.length - 1] + + return ( + <> + {head}and {tail} + + ) +} diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 1d685769..e97a337e 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -51,6 +51,10 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) { name: 'Your markets', href: `/${user.username}`, }, + { + name: 'Leaderboards', + href: '/leaderboards', + }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index b674296a..d98fa2db 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -14,6 +14,7 @@ import { updateDoc, limit, } from 'firebase/firestore' +import _ from 'lodash' import { app } from './init' import { getValues, listenForValues } from './utils' @@ -123,6 +124,13 @@ export function listenForContract( }) } +function chooseRandomSubset(contracts: Contract[], count: number) { + const fiveMinutes = 5 * 60 * 1000 + const seed = Math.round(Date.now() / fiveMinutes).toString() + shuffle(contracts, createRNG(seed)) + return contracts.slice(0, count) +} + const hotContractsQuery = query( contractCollection, where('isResolved', '==', false), @@ -131,21 +139,38 @@ const hotContractsQuery = query( limit(16) ) -function chooseHotContracts(contracts: Contract[]) { - const fiveMinutes = 5 * 60 * 1000 - const seed = Math.round(Date.now() / fiveMinutes).toString() - shuffle(contracts, createRNG(seed)) - return contracts.slice(0, 4) -} - export function listenForHotContracts( setHotContracts: (contracts: Contract[]) => void ) { - return listenForValues(hotContractsQuery, (contracts) => - setHotContracts(chooseHotContracts(contracts)) + return listenForValues(hotContractsQuery, (contracts) => { + const hotContracts = _.sortBy( + chooseRandomSubset(contracts, 4), + (contract) => contract.volume24Hours + ) + setHotContracts(hotContracts) + }) +} + +export async function getHotContracts() { + const contracts = await getValues(hotContractsQuery) + return _.sortBy( + chooseRandomSubset(contracts, 4), + (contract) => -1 * contract.volume24Hours ) } -export function getHotContracts() { - return getValues(hotContractsQuery).then(chooseHotContracts) +const closingSoonQuery = query( + contractCollection, + where('isResolved', '==', false), + where('closeTime', '>', Date.now()), + orderBy('closeTime', 'asc'), + limit(6) +) + +export async function getClosingSoonContracts() { + const contracts = await getValues(closingSoonQuery) + return _.sortBy( + chooseRandomSubset(contracts, 2), + (contract) => contract.closeTime + ) } diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 59f97c32..0f9ecf98 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -9,6 +9,7 @@ import { where, limit, getDocs, + orderBy, } from 'firebase/firestore' import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' @@ -20,8 +21,8 @@ import { import { app } from './init' import { User } from '../../../common/user' -import { listenForValues } from './utils' import { createUser } from './api-call' +import { getValues, listenForValues } from './utils' export type { User } const db = getFirestore(app) @@ -123,3 +124,24 @@ export function listenForAllUsers(setUsers: (users: User[]) => void) { const q = query(userCollection) listenForValues(q, setUsers) } + +const topTradersQuery = query( + collection(db, 'users'), + orderBy('totalPnLCached', 'desc'), + limit(21) +) + +export async function getTopTraders() { + const users = await getValues(topTradersQuery) + return users.filter((user) => user.username !== 'SG').slice(0, 20) +} + +const topCreatorsQuery = query( + collection(db, 'users'), + orderBy('creatorVolumeCached', 'desc'), + limit(20) +) + +export function getTopCreators() { + return getValues(topCreatorsQuery) +} diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 727253b0..acf1eb2a 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import _ from 'lodash' import { Contract, + getClosingSoonContracts, getHotContracts, listAllContracts, } from '../lib/firebase/contracts' @@ -14,16 +15,17 @@ import { Comment, listAllComments, } from '../lib/firebase/comments' -import { Col } from '../components/layout/col' -import { ContractCard } from '../components/contract-card' import { Bet, listAllBets } from '../lib/firebase/bets' +import { ContractsGrid } from '../components/contracts-list' export async function getStaticProps() { - const [contracts, hotContracts, recentComments] = await Promise.all([ - listAllContracts().catch((_) => []), - getHotContracts().catch(() => []), - getRecentComments().catch(() => []), - ]) + const [contracts, hotContracts, closingSoonContracts, recentComments] = + await Promise.all([ + listAllContracts().catch((_) => []), + getHotContracts().catch(() => []), + getClosingSoonContracts().catch(() => []), + getRecentComments().catch(() => []), + ]) const activeContracts = findActiveContracts(contracts, recentComments) const activeContractBets = await Promise.all( @@ -39,6 +41,7 @@ export async function getStaticProps() { activeContractBets, activeContractComments, hotContracts, + closingSoonContracts, }, revalidate: 60, // regenerate after a minute @@ -50,17 +53,21 @@ const Home = (props: { activeContractBets: Bet[][] activeContractComments: Comment[][] hotContracts: Contract[] + closingSoonContracts: Contract[] }) => { const { activeContracts, activeContractBets, activeContractComments, hotContracts, + closingSoonContracts, } = props return ( - + + + { - const { hotContracts } = props - if (hotContracts.length < 4) return <> - - const [c1, c2, c3, c4] = hotContracts +const HotMarkets = (props: { contracts: Contract[] }) => { + const { contracts } = props + if (contracts.length === 0) return <> return (
- <Col className="gap-6"> - <Col className="md:flex-row items-start gap-6"> - <ContractCard className="flex-1" contract={c1} showHotVolume /> - <ContractCard className="flex-1" contract={c2} showHotVolume /> - </Col> - <Col className="md:flex-row items-start gap-6"> - <ContractCard className="flex-1" contract={c3} showHotVolume /> - <ContractCard className="flex-1" contract={c4} showHotVolume /> - </Col> - </Col> + <ContractsGrid contracts={contracts} showHotVolume /> + </div> + ) +} + +const ClosingSoonMarkets = (props: { contracts: Contract[] }) => { + const { contracts } = props + if (contracts.length === 0) return <></> + + return ( + <div className="w-full bg-green-50 border-2 border-green-100 p-6 rounded-lg shadow-md"> + <Title className="mt-0" text="⏰ Closing soon" /> + <ContractsGrid contracts={contracts} showCloseTime /> </div> ) } diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx new file mode 100644 index 00000000..30a22185 --- /dev/null +++ b/web/pages/leaderboards.tsx @@ -0,0 +1,112 @@ +import _ from 'lodash' +import Image from 'next/image' +import { Col } from '../components/layout/col' +import { Row } from '../components/layout/row' +import { Page } from '../components/page' +import { SiteLink } from '../components/site-link' +import { Title } from '../components/title' +import { getTopCreators, getTopTraders, User } from '../lib/firebase/users' +import { formatMoney } from '../lib/util/format' + +export async function getStaticProps() { + const [topTraders, topCreators] = await Promise.all([ + getTopTraders().catch((_) => {}), + getTopCreators().catch((_) => {}), + ]) + + return { + props: { + topTraders, + topCreators, + }, + + revalidate: 60, // regenerate after a minute + } +} + +export default function Leaderboards(props: { + topTraders: User[] + topCreators: User[] +}) { + const { topTraders, topCreators } = props + + return ( + <Page> + <Col className="items-center lg:flex-row gap-10"> + <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 Leaderboard(props: { + title: string + users: User[] + columns: { + header: string + renderCell: (user: User) => any + }[] +}) { + const { title, users, columns } = props + return ( + <div className="max-w-xl w-full px-1"> + <Title text={title} /> + <div className="overflow-x-auto"> + <table className="table table-zebra table-compact text-gray-500 w-full"> + <thead> + <tr className="p-2"> + <th>#</th> + <th>Name</th> + {columns.map((column) => ( + <th key={column.header}>{column.header}</th> + ))} + </tr> + </thead> + <tbody> + {users.map((user, index) => ( + <tr key={user.id}> + <td>{index + 1}</td> + <td> + <SiteLink className="relative" href={`/${user.username}`}> + <Row className="items-center gap-4"> + <Image + className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50" + src={user.avatarUrl} + alt="" + width={32} + height={32} + /> + <div>{user.name}</div> + </Row> + </SiteLink> + </td> + {columns.map((column) => ( + <td key={column.header}>{column.renderCell(user)}</td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + ) +}